Skip to main content

loom-event-driven

Event-driven architecture: message queues, pub/sub, event sourcing, CQRS, sagas. Not for: webhooks.

ソース情報

リポジトリ
cosmix/loom
ソースの最終更新活動
2026年9月19日 08:01
検出された SKILL.md の言語
英語
スター
61
フォーク
4

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
name
loom-event-driven
description
Event-driven architecture: message queues, pub/sub, event sourcing, CQRS, sagas. Not for: webhooks.
triggers
["message","messaging","pub/sub","pubsub","publish/subscribe","kafka","rabbitmq","sqs","sns","nats","pulsar","event sourcing","CQRS","saga","choreography","orchestration","outbox","event store","domain event","integration event","message queue","message broker","event bus","data streaming","stream processing","event-driven","exactly-once","idempotent consumer"]
# Event-Driven Architecture ## Overview Patterns for decoupling services via events instead of synchronous calls: message queues, pub/sub, event sourcing, CQRS, sagas, and streaming. Scope of THIS skill = the architecture and its distributed-systems traps (delivery semantics, ordering, outbox, schema evolution). For job-queue *mechanics* — worker pools, scheduling/cron, retry/backoff internals, generic DLQ plumbing — see `loom-background-jobs`; cross-reference rather than duplicate. **The one law that governs everything below:** networked delivery is *at-least-once*; therefore **every consumer must be idempotent.** Read the Delivery Semantics section first — most EDA bugs are a violation of it. Code samples are TypeScript for concreteness; the patterns are language-agnostic. Assume `crypto.randomUUID()`, a broker client, and a datastore are in scope. --- ## Delivery Semantics — "Exactly-once" Is (Mostly) a Lie Two-Generals: across an unreliable network you cannot guarantee a message is delivered *exactly* once. You get to pick a *failure mode*: | Semantic | Mechanism | Failure mode | Use when | | --- | --- | --- | --- | | At-most-once | fire-and-forget, ack before processing | **loses** messages on crash | metrics/telemetry where loss is OK | | At-least-once | ack *after* processing, redeliver on no-ack | **duplicates** on retry/redelivery | **default for everything that matters** | | "Exactly-once" | at-least-once transport **+ idempotent consumer + dedup** | none observable | any state-changing handler | **Exactly-once is an *effect* you engineer at the consumer, not a transport guarantee you buy.** The real recipe: 1. At-least-once delivery (durable broker, ack after commit). 2. Idempotent consumer: processing the same message twice == processing it once. 3. A dedup/idempotency store keyed by a stable message id or business key. ⚠️ **Kafka "exactly-once semantics (EOS)" is real but scoped to Kafka.** Idempotent producer + transactions give exactly-once for **read-topic → process → write-topic-and-offsets** *inside Kafka*. It does **not** cover external side effects — a DB write, an email, a charge. The moment a handler touches the outside world, you are back to at-least-once and must be idempotent. Never tell a stakeholder Kafka gives you exactly-once for a payment. ### Idempotent consumer (the load-bearing pattern) ```typescript // Dedup by stable id (producer-assigned messageId or a business key). // Store the result so a duplicate returns the SAME answer, not a re-execution. async function handleIdempotent<T>(messageId: string, work: () => Promise<T>): Promise<T> { const key = `dedup:${messageId}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached) as T; // already processed → no-op replay const result = await work(); // side effects happen here // SETNX + TTL: window must exceed max redelivery lag (broker retention + retry horizon) await redis.set(key, JSON.stringify(result), "EX", 24 * 3600, "NX"); return result; } ``` **Gotchas.** - ⚠ **Dedup window vs. redelivery horizon.** If the TTL expires before the broker could still redeliver (retention + max retry delay + DLQ replay), a late duplicate re-executes. Size the window to the *worst-case* redelivery age, not the happy path. - ⚠ **The "check then act" race.** Two workers both miss the key and both execute. Either make the side effect itself idempotent (DB `INSERT ... ON CONFLICT DO NOTHING` on a unique business key — the strongest option, atomic with the write), or gate with a lock. A Redis dedup key is best-effort unless it shares the transaction with the side effect. - ⚠ **Prefer a natural idempotency key** (orderId, paymentIntentId) over the broker's message id. Redelivery may carry a *new* transport id for the same business fact, and a producer retry can emit two transport messages for one intent. --- ## The Dual-Write Problem & the Transactional Outbox The single most common EDA correctness bug: **updating the database and publishing an event as two separate operations.** ```typescript // ❌ BROKEN: two systems, no atomicity await db.orders.insert(order); // commits await broker.publish("order.created"); // crash here → DB has order, world never hears about it ``` Reorder it and you get the opposite: publish succeeds, DB write fails, phantom event for an order that doesn't exist. There is **no ordering of these two lines that is safe** — you cannot atomically commit across a DB and a broker without distributed transactions (which brokers don't support and you don't want; see 2PC below). **Fix: Transactional Outbox.** Write the event into an `outbox` table **in the same local DB transaction** as the state change. A separate *relay* reads the outbox and publishes, marking rows sent. One atomic commit; publishing becomes at-least-once (relay may crash after publish, before marking) → consumers idempotent. ```typescript // Producer: one transaction, two writes to the SAME db await db.tx(async (t) => { await t.orders.insert(order); await t.outbox.insert({ id: crypto.randomUUID(), aggregate_id: order.id, type: "order.created", payload: JSON.stringify(order), created_at: new Date(), published_at: null, // relay flips this }); }); ``` **Two ways to run the relay:** - **Polling publisher** — `SELECT ... WHERE published_at IS NULL ORDER BY created_at` (add `FOR UPDATE SKIP LOCKED` so multiple relays don't double-publish), publish, set `published_at`. Simple, portable; adds latency and DB load. - **CDC / log-tailing (Debezium, etc.)** — a connector tails the DB WAL/binlog and streams outbox inserts to the broker. No polling, near-real-time, no query load — but operational weight (Kafka Connect) and DB-specific. Debezium has a dedicated **Outbox Event Router**. **Gotchas.** - ⚠ Outbox is **at-least-once**, never exactly-once — relay can publish then die before marking sent. Non-negotiable: idempotent consumers. - ⚠ **Ordering:** to preserve per-aggregate order, publish with the aggregate id as the partition key and have the relay process a given aggregate's rows in `created_at`/sequence order. - ⚠ **Outbox bloat:** prune `published_at IS NOT NULL` rows on a schedule; an unbounded outbox degrades the polling query. - **Inbox pattern** is the mirror on the consumer: record processed message ids in an inbox table inside the same transaction as the write — atomic dedup without an external store. --- ## Ordering — Only Per-Partition/Per-Key, Never Global **Global total order across a topic does not scale and is not offered.** Kafka guarantees order **only within a single partition**; RabbitMQ only within a single queue with a single consumer; SQS FIFO only within a `MessageGroupId`. **Consequences you must design around:** - **Choose the partition key = the entity whose events must stay ordered** (orderId, userId, accountId). All events for one entity land on one partition → ordered. Cross-entity order is *not* guaranteed and you must not depend on it. - **Hot-partition skew.** A skewed key ("region=US", or one whale tenant) piles most traffic on one partition → that consumer lags while others idle. Pick a high-cardinality, evenly-distributed key; composite keys or salting for known-hot entities. - **Consumer parallelism is capped by partition count** per consumer group. 6 partitions → at most 6 useful consumers. Repartitioning later is disruptive (rehashes keys, breaks in-flight order). Over-provision partitions modestly up front. - **Retries reorder.** Reprocessing message N after N+1 already went breaks order. Kafka's idempotent producer preserves send order with up to `max.in.flight.requests.per.connection=5`; *without* idempotence, `>1` in-flight can reorder on retry — set it to 1 or enable idempotence. - ⚠ If handlers must be ordered per key, **do not fan a single partition out to a worker pool** — that reintroduces reordering. Parallelize across partitions/keys, serialize within a key. --- ## Fat vs. Thin Events (the coupling / staleness trade-off) How much state does an event carry? Two poles: | | Thin / notification | Fat / event-carried state transfer | | --- | --- | --- | | Payload | ids only (`{orderId}`) | full snapshot (`{orderId, items, total, status,...}`) | | Consumer action | **call back / refetch** source | read straight from the event | | Coupling | temporal (source must be up *now*) + API coupling | schema coupling to the event shape | | Autonomy | low | high (consumer needs nothing else) | | PII/size | small | larger; **spreads PII** to every subscriber | ⚠ **The thin-event stale-read race:** consumer gets `order.updated {id}`, refetches the order — but events are delivered async and can arrive *before* the source's own read replica is consistent, or after a *newer* update. You can read a **stale or wrong version**. Mitigations: include a **version/sequence number** in the thin event and refetch-then-check `version >=`, or make the source's read strongly consistent for this path, or just send a fat event. **Default to event-carried state transfer** (fat) for integration events between services — it removes temporal coupling and the refetch storm. Use thin events when payloads are huge, PII-sensitive, or consumers legitimately need the freshest value at handling time. Always version either way. --- ## Broker Selection | Broker | Model | Ordering | Delivery | Retention/replay | Reach for it when | | --- | --- | --- | --- | --- | --- | | **Kafka** | partitioned log, pull | per-partition | at-least-once; EOS *within Kafka* | long, offset-based replay | high-throughput streaming, event sourcing, replayable log, many independent consumer groups | | **RabbitMQ** | queues + exchanges, push | per-queue (single consumer) | at-least-once | consumed msgs gone (no replay) | complex routing (topic/headers/fanout), per-message TTL, RPC, priority queues | | **Pulsar** | log, segmented storage | per-partition/key-shared | at-least-once; effectively-once dedup | tiered (offload to S3), long | Kafka-like + multi-tenancy, geo-replication, unified queue+stream, decoupled compute/storage | | **SQS + SNS** | managed queue (+ fanout) | FIFO: per-MessageGroupId; Standard: none | at-least-once (Std); FIFO exactly-once *in-queue* | up to 14 days; no arbitrary replay | AWS-native, zero-ops, native DLQ redrive; SNS/EventBridge fan-out | | **NATS JetStream** | log/stream, pull | per-subject-sequence | at-least-once (+ msg-id dedup window) | limits/interest/workqueue, replay | low-latency, lightweight ops, edge/IoT, request-reply + streams | | **Redis Streams** | log | per-stream | at-least-once (consumer groups + PEL) | capped (MAXLEN) | already-have-Redis, modest scale, simple durable queue | **Selection heuristics.** - Need **replay / rebuild projections / event sourcing** → log-based (Kafka/Pulsar). Queue brokers discard consumed messages. - Need **rich routing / per-message priority / TTL** → RabbitMQ. - **On AWS and don't want to run brokers** → SQS/SNS/EventBridge; native DLQ redrive is a real ergonomics win. - Extreme throughput or multi-consumer-group replay → Kafka. Kafka is overkill (and heavy ops) for a simple work queue — don't Kafka a to-do list. - ⚠ Don't conflate SNS (fanout pub/sub, no per-subscriber durability by itself) with SQS (durable queue). The durable fanout pattern is **SNS → SQS per consumer**. --- ## Pub/Sub — Kafka (canonical producer/consumer) ```typescript import { Kafka, logLevel } from "kafkajs"; const kafka = new Kafka({ clientId: "order-service", brokers: ["localhost:9092"] }); // Producer: idempotent → no duplicates from producer-side retries, preserves order. const producer = kafka.producer({ idempotent: true, maxInFlightRequests: 5 }); await producer.connect(); await producer.send({ topic: "orders", messages: [{ key: order.id, // partition key = ordering key value: JSON.stringify({ type: "order.created", data: order, v: 1 }), headers: { "event-type": "order.created", "correlation-id": corrId }, }], }); // Consumer: one group per logically-distinct subscriber; each group gets all messages. const consumer = kafka.consumer({ groupId: "inventory-service" }); await consumer.subscribe({ topic: "orders", fromBeginning: false }); await consumer.run({ eachMessage: async ({ message }) => { const evt = JSON.parse(message.value!.toString()); await handleIdempotent(message.key!.toString() + ":" + evt.v, () => react(evt)); // throwing here does NOT auto-DLQ in Kafka — offset isn't committed, msg redelivers. // Bound retries yourself, then produce to an error topic (see Poison Pills). }, }); ``` **Kafka gotchas.** - ⚠ **A crashing handler blocks the partition.** No auto-DLQ: the offset isn't committed, so the same message redelivers forever (poison pill) and everything behind it stalls. You must implement bounded-retry-then-error-topic. - **Consumer group == subscription.** Two services that both need every event use two *different* group ids. Two instances that should *share* the load use the *same* group id (partitions split among them). - **`fromBeginning`** only matters the first time a group has no committed offset; afterwards it resumes from the committed offset. - Manual offset commit after successful processing = at-least-once. Auto-commit before processing = at-most-once (silent loss on crash). **RabbitMQ** (routing-first): declare queue with `x-dead-letter-exchange` + `x-message-ttl`, publish `persistent: true`, `prefetch(n)` for backpressure, `ack` after success / `nack(requeue=false)` to route to the DLX. `nack(requeue=true)` in a tight loop is a poison-pill amplifier — track a retry-count header and DLX after N. **NATS JetStream** (lightweight, durable): create a stream (`subjects: ["events.*"]`, retention `limits`), a **durable** pull consumer with `ack_policy: explicit` and `max_deliver: N` (built-in redelivery cap), publish with `Nats-Msg-Id` for the server-side dedup window. `msg.ack()` / `msg.nak()` / `msg.term()` (term = don't redeliver, straight to poison handling). --- ## Poison Pills & Dead-Letter Policy A **poison pill** is a message that *always* fails — malformed payload, unparseable schema, a referenced entity that will never exist. In-band infinite retry blocks the partition/queue behind it and can pin CPU. Policy: 1. **Bounded retries with backoff** (exponential + jitter), retry count carried in a header/attribute. 2. On exhaustion, **park it in a DLQ** (never drop silently) with metadata: original topic/queue, error, stack, retry count, first-seen time, correlation id. 3. **Alert** on DLQ arrivals and on DLQ size/age crossing a threshold — a growing DLQ is an incident, not a metric. 4. **Replay tooling**: inspect, edit/fix, selectively redrive, and purge. A DLQ you can't replay from is a graveyard. **Broker specifics.** - **SQS:** native `RedrivePolicy` (`maxReceiveCount` → DLQ) and console/SDK **redrive back** to source. Prefer it over hand-rolling. - **Kafka:** no native DLQ. Kafka Connect sinks have `errors.deadletterqueue.topic.name`; app consumers publish failures to an `<topic>.DLT` error topic yourself. - **RabbitMQ:** DLX + a retry queue with per-message TTL that dead-letters *back* to the work queue implements delayed retry. ⚠ **Retry vs. DLQ classification:** only retry *transient* failures (timeouts, 5xx, lock contention). A deserialization error or a 4xx/validation failure is deterministic — retrying wastes time and delays the DLQ; fast-fail those straight to the DLQ. Distinguish retryable from non-retryable in the handler. See `loom-background-jobs` for generic retry/backoff and worker-pool mechanics. --- ## Message Queues (work distribution) Competing-consumers: N workers pull from one queue, each message handled once (per successful ack). Contrast with pub/sub (every subscriber group gets every message). ```typescript // SQS competing-consumers loop. Delete = ack; not-deleting = redelivery after visibility timeout. while (running) { const { Messages = [] } = await sqs.receiveMessage({ QueueUrl: url, MaxNumberOfMessages: 10, WaitTimeSeconds: 20, // long-poll AttributeNames: ["ApproximateReceiveCount"], }); await Promise.all(Messages.map(async (m) => { try { await handleIdempotent(m.MessageId!, () => process(JSON.parse(m.Body!))); await sqs.deleteMessage({ QueueUrl: url, ReceiptHandle: m.ReceiptHandle! });
GitHubで見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る