Skip to main content

loom-event-driven

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

Informations de source

Dépôt
cosmix/loom
Dernière activité de la source
19 septembre 2026 à 08:01
Langue détectée de SKILL.md
anglais
Étoiles
61
Forks
4

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
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! });
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub