M13 — Event-Driven Architecture
Key motivations:
- Temporal decoupling: producer and consumer run independently — if the consumer is down, events buffer in the broker
- Fanout: one event reaches multiple consumers simultaneously (notifications, analytics, search indexing — all from one order-placed event)
- Audit log / replay: full event history is replayable — rebuild any read model from scratch, debug production issues with real data
- Reduces synchronous blocking chains: checkout doesn't wait for email service; email service consumes the event asynchronously
- Enables eventual consistency: services converge on consistent state over time, trading strong consistency for availability and partition tolerance
A work queue (RabbitMQ) is like a restaurant ticket system — a ticket is torn off by one chef, cooked, and discarded. A log-based stream (Kafka) is like a newspaper printing press — each edition is stamped with a page number (offset), archived forever, and any subscriber can re-read any past edition at any time.
- Message consumed → deleted from queue
- Push-based delivery to consumers
- Competing consumers share load
- At-most-once or at-least-once via ACK
- Dead-letter exchange handles failures
- Complex routing via exchanges
- Great for: task queues, RPC, work distribution
- Messages retained for configurable period
- Pull-based: consumers control their pace
- Consumer groups: each partition → one consumer
- Exactly-once via idempotent producer + transactions
- Any consumer can replay from any offset
- Ordered within partition
- Great for: event sourcing, audit log, stream processing
| Pattern | Problem Solved | Trade-off |
|---|---|---|
| Outbox | Atomic: DB write + event publish in one transaction | Extra table + relay process |
| Saga (Orchestration) | Multi-service transactions without 2PC | Central coordinator = SPOF risk |
| Saga (Choreography) | Distributed coordination via events | Hard to trace overall flow |
| CQRS | Separate write model from read model | Eventual consistency on reads |
| Event Sourcing | State as immutable event log | Complex queries, snapshot management |
| Idempotent Consumer | Handle at-least-once delivery safely | Dedup table storage + lookup cost |
| Module | Topic | Key Concepts |
|---|---|---|
| M13 (this) | Event-Driven Architecture | Kafka, RabbitMQ, Saga, Outbox, CQRS, Event Sourcing |
| M14 | Stream Processing | Kafka Streams, Flink windowing, stateful operators, exactly-once |
┌─── Broker 1 (leader P0, follower P1) ───┐ │ Broker 2 (leader P1, follower P2) │ │ Broker 3 (leader P2, follower P0) │ └─────────────────────────────────────────┘ Producer → assigns partition by: hash(key) % num_partitions (key set) or: round-robin (key=null)
Consumer Group A: consumer-A1 reads P0, consumer-A2 reads P1, consumer-A3 reads P2 Consumer Group B: consumer-B1 reads P0+P1, consumer-B2 reads P2 (independent read positions)
- Key set:
partition = murmur2(key) % num_partitions— all events for the same key go to the same partition → ordering guaranteed per key - Key null: sticky partitioner (batch to one partition, rotate after batch) — maximizes throughput
- Custom partitioner: implement
Partitionerinterface for business logic (e.g., tenant-based routing)
order_id + "_" + random(0,N)- Eager rebalance (stop-the-world): all consumers stop, revoke all partitions, reassign — processing pauses
- Cooperative rebalance (incremental): only affected partitions are moved; others keep processing — reduces pause
partition.assignment.strategy = CooperativeStickyAssignorfor incrementalsession.timeout.ms: consumer must send heartbeat within this window or be considered dead → rebalance
replica.lag.time.max.ms.
| Setting | Meaning | Trade-off |
|---|---|---|
acks=0 | Producer doesn't wait for any ack | Fastest, may lose messages |
acks=1 | Leader acknowledges write | Lost if leader fails before replication |
acks=all (-1) | All ISR members acknowledge | Slowest, strongest durability |
min.insync.replicas=2 | Minimum ISR for acks=all to succeed | Prevents silent data loss with small ISR |
acks=all + min.insync.replicas=2 + replication.factor=3 — survives one broker failure without data loss.__consumer_offsets internal topic.
Commit strategies:
- Auto-commit (
enable.auto.commit=true): commits periodically. Risk: commit before processing → data loss on crash; or process but not commit → reprocess on restart - Manual sync commit (
commitSync()): blocks until broker confirms. Safe but slower - Manual async commit (
commitAsync()): fire-and-forget. Higher throughput, retry on failure is tricky (stale offset may overwrite newer commit) - Best practice: manual sync commit after batch processing, async for throughput-critical paths with idempotent processing
cleanup.policy=compact) — keeps only the latest value per key, discards older records. Used for changelogs (KTable in Kafka Streams).| Config | Effect | Tuning Guidance |
|---|---|---|
batch.size | Max bytes per batch per partition | Increase (e.g. 64KB) for throughput |
linger.ms | Wait up to N ms to fill a batch | 5–50ms trades latency for throughput |
compression.type | snappy/lz4/zstd per batch | zstd for best ratio, lz4 for speed |
buffer.memory | Total producer buffer bytes | Increase if producers block frequently |
max.in.flight.requests.per.connection | Concurrent unacked requests | Set to 1 for strict ordering (without idempotence) |
| Guarantee | How | Risk | Use When |
|---|---|---|---|
| At-most-once | acks=0, no retry | Messages lost if broker is down | Metrics where loss is acceptable |
| At-least-once | acks=all + retries | Duplicates on retry after timeout | Most use-cases with idempotent consumers |
| Exactly-once | Idempotent producer + transactions | Higher latency, more complex | Financial transfers, order processing |
(PID, partition).
// Usage pattern rd_kafka_init_transactions(rk, 10000); // once at startup rd_kafka_begin_transaction(rk); // … produce messages … err = rd_kafka_commit_transaction(rk, 10000); // on error: rd_kafka_abort_transaction(rk, 10000);
isolation.level=read_committed to skip uncommitted messages (aborted transaction leftovers).read_committed isolation + atomic offset commit within the transaction. This is what Kafka Streams provides out-of-the-box with processing.guarantee=exactly_once_v2.- At-most-once consumer: commit offset before processing. If processing fails, message is lost (offset already moved forward)
- At-least-once consumer: commit offset after processing. If crash after process but before commit, message is reprocessed on restart → make consumers idempotent
- Exactly-once consumer: atomic commit — use Kafka transactions to write processing result AND commit offset in one transaction
| Type | Routing Logic |
|---|---|
| direct | Exact routing key match — one queue per key |
| topic | Pattern match: * (one word) / # (zero+). e.g. order.*.created |
| fanout | Ignores routing key — copies to ALL bound queues |
| headers | Match on message headers (key-value), ignores routing key |
| default | Built-in direct exchange — routes to queue named = routing key |
- Consumer sends NACK with
requeue=false - Message TTL expires in queue
- Queue length overflow (x-max-length)
- Retry queues with exponential backoff (TTL + DLX chain)
- Poison message parking for inspection
- Alerting on repeated failures
/* retry-30s: TTL=30000ms, dead-letter back to “orders-exchange” */ amqp_table_entry_t args[2] = { { amqp_cstring_bytes(“x-message-ttl”), { .kind = AMQP_FIELD_KIND_I32, .value.i32 = 30000 } }, { amqp_cstring_bytes(“x-dead-letter-exchange”), { .kind = AMQP_FIELD_KIND_BYTES, .value.bytes = amqp_cstring_bytes(“orders-exchange”) } } }; amqp_table_t tbl = { .num_entries = 2, .entries = args }; amqp_queue_declare(ch, 1, amqp_cstring_bytes(“orders.retry.30s”), 0, 1, 0, 0, tbl);
| Mode | Behavior | Risk |
|---|---|---|
autoAck=true | Broker removes message as soon as delivered (at-most-once) | Lost if consumer crashes before processing |
| Manual ACK | Consumer confirms success; broker removes message | None — correct at-least-once |
| Manual NACK requeue=true | Message returned to front of queue | Infinite loop on poison messages |
| Manual NACK requeue=false | Message sent to DLX (if configured) or dropped | Dropped if no DLX |
| Reject | Same as NACK for single message | — |
basicQos): limits unacked messages per consumer. Set to 1 for strict round-robin fairness; increase (e.g. 20–100) for throughput when processing is fast.| Criterion | Use Kafka | Use RabbitMQ |
|---|---|---|
| Message retention | Need replay / audit log | Process-and-forget |
| Multiple consumers | Independent consumer groups reading same events | One consumer per message (competing consumers) |
| Ordering | Strict ordering per key (partition) | Best-effort (priority queues) |
| Throughput | Millions of msg/sec (sequential disk I/O) | Tens of thousands/sec |
| Routing complexity | Simple (partition key) | Rich (exchange types, header matching) |
| RPC / request-reply | Awkward | Built-in (correlation ID + reply-to) |
| Stream processing | Kafka Streams, ksqlDB native | Needs external framework |
The Saga pattern replaces distributed ACID transactions with a sequence of local transactions, each publishing an event. If a step fails, compensating transactions undo the completed steps.
Cons: orchestrator = potential SPOF; logic centralized may become god object
Cons: hard to understand overall flow; compensations scattered across services
Solution: write to an
outbox table in the same local transaction as the business data. A separate relay process publishes outbox records to the broker and marks them as sent.
Relay Process (separate, runs continuously) SELECT * FROM outbox WHERE status=‘PENDING’ LIMIT 100 FOR EACH row: publish to Kafka/RabbitMQ UPDATE outbox SET status=‘SENT’, sent_at=NOW() WHERE id=row.id
Alternatives to polling relay: CDC (Change Data Capture) via Debezium — reads DB WAL log directly, zero-latency, no polling overhead, works with Postgres/MySQL logical replication
| Failure | Outcome | Why Safe |
|---|---|---|
| Process crashes after DB write, before relay runs | Relay picks up PENDING row on restart | Row persists in outbox |
| Relay publishes to Kafka but crashes before marking SENT | Relay re-publishes on restart → duplicate | Consumer must be idempotent |
| DB transaction rolled back | Outbox row never created → no event | Atomicity maintained |
| Kafka broker down during relay | Relay retries; PENDING rows accumulate | Broker recovery unblocks relay |
CQRS separates them into two explicit models:
- Command side: mutates state, normalized DB optimized for writes and integrity (e.g., PostgreSQL with foreign keys)
- Query side: returns projections, denormalized read model optimized for queries (e.g., Elasticsearch, Redis, materialized views)
- Read model can be independently scaled (horizontal replicas)
- Read model optimized for each query pattern (denormalized)
- Write model optimized for correctness (normalized, transactions)
- Multiple specialized read models from the same events
- Replay events to rebuild read models after bugs
- Eventual consistency: read model lags behind writes (typically milliseconds)
- Additional infrastructure (separate read store)
- Synchronization complexity (events must be reliably published)
- Read-your-own-writes: a user who just placed an order may see stale data on immediate reload → handle with version tokens or direct write-model reads for the write's own session
Normal CRUD:
accounts: {id:1, balance:850}Event Sourced:
- Every N events, save a snapshot:
{account_id:1, balance:850, version:3} - On read: load latest snapshot, replay events since that snapshot version
- Typical threshold: every 100–1000 events
- Upcasting: transform old event format to new format at read time
- Versioned events:
MoneyWithdrawn_v1vsMoneyWithdrawn_v2 - Additive changes only: add fields, never remove or rename
- Use Avro/Protobuf with Schema Registry for versioned schemas
| Use Event Sourcing When… | Avoid When… |
|---|---|
| Complete audit log is required by regulation | Simple CRUD with no history requirement |
| State reconstruction / time-travel debugging needed | Team unfamiliar with the pattern — steep learning curve |
| Multiple read models from the same data | Queries span multiple aggregates (event sourcing is aggregate-scoped) |
| Business domain is naturally event-oriented (banking, order management) | Simple content management, settings, static data |
Two approaches:
- Natural idempotency: the operation is inherently idempotent — e.g.,
SET balance=850vsbalance = balance - 200. If the operation can be expressed as an absolute state (upsert), duplicates are safe. - Deduplication table: track processed message IDs; reject re-processed messages.
processed_events table.
— Atomic check-and-process (PostgreSQL) BEGIN; — Attempt to insert event ID (fails with UNIQUE violation if duplicate) INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
— If no row returned: already processed, skip — If row returned: new event, proceed with business logic UPDATE accounts SET balance = balance - 3; COMMIT;
INSERT ... ON CONFLICT DO NOTHING + business logic must be in the same transaction for atomicity. If the transaction rolls back, the event ID is not recorded and can be safely reprocessed.| Source | Key Strategy | Notes |
|---|---|---|
| Kafka messages | topic:partition:offset | Globally unique per message position |
| RabbitMQ messages | Set message-id AMQP property at publish time | Producer responsibility; use UUID |
| Business events | order_id:event_type | Natural key; handles schema-level dedup |
| HTTP API calls | Client-supplied Idempotency-Key header (UUID) | Store response and return cached result on repeat |
- librdkafka — official C Kafka client; used by the Python/Go/Ruby clients under the hood
- rabbitmq-c (amqp.h) — C AMQP 0-9-1 client for RabbitMQ
# Compile gcc -o kafka_producer kafka_producer.c -lrdkafka gcc -o amqp_consumer amqp_consumer.c -lrabbitmq
static volatile int run = 1; static void sigterm(int sig) { run = 0; }
/* Called for every message after produce attempt */ static void delivery_report_cb(rd_kafka_t *rk, const rd_kafka_message_t *msg, void *opaque) { (void)rk; (void)opaque; if (msg->err) { fprintf(stderr, “[DR] FAILED: topic=%s err=%s key=%.*s\n”, rd_kafka_topic_name(msg->rkt), rd_kafka_err2str(msg->err), (int)msg->key_len, (const char *)msg->key); } else { fprintf(stdout, “[DR] OK: topic=%s partition=%” PRId32 ” offset=%” PRId64 ” key=%.*s\n”, rd_kafka_topic_name(msg->rkt), msg->partition, msg->offset, (int)msg->key_len, (const char *)msg->key); } }
int main(int argc, char *argv[]) { if (argc < 4) { fprintf(stderr, “Usage: %s <brokers> <topic> <message>\n”, argv[0]); return 1; }
char errstr[512]; rd_kafka_conf_t *conf = rd_kafka_conf_new();
/* Broker list */ if (rd_kafka_conf_set(conf, “bootstrap.servers”, argv[1], errstr, sizeof(errstr)) != RD_KAFKA_CONF_OK) { fprintf(stderr, “%s\n”, errstr); return 1; }
/* Idempotent producer: deduplicates retries automatically */ rd_kafka_conf_set(conf, “enable.idempotence”, “true”, errstr, sizeof(errstr));
/* Delivery callback for per-message success/failure reporting */ rd_kafka_conf_set_dr_msg_cb(conf, delivery_report_cb);
rd_kafka_t *rk = rd_kafka_new(RD_KAFKA_PRODUCER, conf,
errstr, <span class="ck">sizeof</span>(errstr));
if (!rk) { fprintf(stderr, “Failed to create producer: %s\n”, errstr); return 1; }
const char *topic = argv[2]; const char *msg = argv[3]; const char key = “order-key-001”; / determines partition */
signal(SIGINT, sigterm);
signal(SIGTERM, sigterm);
retry_produce: rd_kafka_resp_err_t err = rd_kafka_producev( rk, RD_KAFKA_V_TOPIC(topic), RD_KAFKA_V_MSGFLAGS(RD_KAFKA_MSG_F_COPY), RD_KAFKA_V_KEY(key, strlen(key)), RD_KAFKA_V_VALUE((void *)msg, strlen(msg)), RD_KAFKA_V_OPAQUE(NULL), RD_KAFKA_V_END );
if (err) { fprintf(stderr, “Produce failed: %s\n”, rd_kafka_err2str(err)); if (err == RD_KAFKA_RESP_ERR__QUEUE_FULL) { rd_kafka_poll(rk, 1000); /* drain delivery queue, then retry */ goto retry_produce; } }
/* Poll for delivery reports (fires delivery_report_cb) / rd_kafka_poll(rk, 0); / non-blocking poll / / Wait for all outstanding messages to be delivered / fprintf(stdout, “Flushing…\n”); rd_kafka_flush(rk, 10 * 1000); / wait up to 10s */ if (rd_kafka_outq_len(rk) > 0) fprintf(stderr, “%d message(s) not delivered\n”, rd_kafka_outq_len(rk));
rd_kafka_destroy(rk);
return 0; }
static volatile int run = 1;
static void rebalance_cb(rd_kafka_t *rk, rd_kafka_resp_err_t err, rd_kafka_topic_partition_list_t *parts, void opaque) { switch (err) { case RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS: fprintf(stdout, “Rebalance: assigned %d partitions\n”, parts->cnt); rd_kafka_assign(rk, parts); break; case RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS: fprintf(stdout, “Rebalance: revoking partitions, committing offsets\n”); rd_kafka_commit(rk, parts, 0); / sync commit before revoke */ rd_kafka_assign(rk, NULL); break; default: rd_kafka_assign(rk, NULL); break; } }
int main(int argc, char *argv[]) { if (argc < 4) { fprintf(stderr, “Usage: %s <brokers> <group> <topic>\n”, argv[0]); return 1; }
char errstr[512]; rd_kafka_conf_t conf = rd_kafka_conf_new(); rd_kafka_conf_set(conf, “bootstrap.servers”, argv[1], errstr, sizeof(errstr)); rd_kafka_conf_set(conf, “group.id”, argv[2], errstr, sizeof(errstr)); rd_kafka_conf_set(conf, “auto.offset.reset”, “earliest”, errstr, sizeof(errstr)); / Disable auto-commit: we commit manually after processing */ rd_kafka_conf_set(conf, “enable.auto.commit”, “false”, errstr, sizeof(errstr)); rd_kafka_conf_set_rebalance_cb(conf, rebalance_cb);
rd_kafka_t *rk = rd_kafka_new(RD_KAFKA_CONSUMER, conf, errstr, <span class="ck">sizeof</span>(errstr));
rd_kafka_poll_set_consumer(rk);
/* Subscribe to topic */ rd_kafka_topic_partition_list_t *topics = rd_kafka_topic_partition_list_new(1); rd_kafka_topic_partition_list_add(topics, argv[3], RD_KAFKA_PARTITION_UA); rd_kafka_subscribe(rk, topics); rd_kafka_topic_partition_list_destroy(topics);
int msg_count = 0; while (run) { rd_kafka_message_t *msg = rd_kafka_consumer_poll(rk, 1000); if (!msg) continue;
if (msg->err) { if (msg->err == RD_KAFKA_RESP_ERR__PARTITION_EOF) fprintf(stdout, “Reached end of partition\n”); else fprintf(stderr, “Consumer error: %s\n”, rd_kafka_message_errstr(msg)); } else { /* Process message */ fprintf(stdout, “Message: partition=%” PRId32 ” offset=%” PRId64 ” key=%.*s value=%.*s\n”, msg->partition, msg->offset, (int)msg->key_len, (const char *)msg->key, (int)msg->len, (const char *)msg->payload);
/* TODO: process_message(msg->payload, msg->len); / / Commit offset after successful processing (at-least-once) / if (++msg_count % 100 == 0) { / Sync commit every 100 messages for durability */ rd_kafka_commit_message(rk, msg, /async=/0); } }
rd_kafka_message_destroy(msg);
}
/* Final commit before shutdown */ rd_kafka_commit(rk, NULL, 0); rd_kafka_consumer_close(rk); rd_kafka_destroy(rk); return 0; }
static void die_on_amqp_error(amqp_rpc_reply_t x, const char *context) { if (x.reply_type != AMQP_RESPONSE_NORMAL) { fprintf(stderr, “%s: AMQP error\n”, context); exit(1); } }
int main() { amqp_connection_state_t conn = amqp_new_connection(); amqp_socket_t *socket = amqp_tcp_socket_new(conn); amqp_tcp_socket_open(socket, “localhost”, 5672, NULL); die_on_amqp_error( amqp_login(conn, ”/”, 0, 131072, 0, AMQP_SASL_METHOD_PLAIN, “guest”, “guest”), “Login”);
amqp_channel_open(conn, <span class="cn">1</span>);
die_on_amqp_error(amqp_get_rpc_reply(conn), <span class="cv">"Open channel"</span>);
/* Declare dead-letter exchange */ amqp_exchange_declare(conn, 1, amqp_cstring_bytes(“dlx.orders”), amqp_cstring_bytes(“fanout”), 0, 1, 0, 0, amqp_empty_table); die_on_amqp_error(amqp_get_rpc_reply(conn), “Declare DLX”);
/* Declare dead-letter queue */ amqp_queue_declare(conn, 1, amqp_cstring_bytes(“orders.dead-letter”), 0, 1, 0, 0, amqp_empty_table); die_on_amqp_error(amqp_get_rpc_reply(conn), “Declare DLQ”);
amqp_queue_bind(conn, <span class="cn">1</span>,
amqp_cstring_bytes(<span class="cv">"orders.dead-letter"</span>),
amqp_cstring_bytes(<span class="cv">"dlx.orders"</span>),
amqp_cstring_bytes(<span class="cv">""</span>), amqp_empty_table);
die_on_amqp_error(amqp_get_rpc_reply(conn), <span class="cv">"Bind DLQ"</span>);
/* Declare main queue with DLX configured */ amqp_table_entry_t dlx_arg = { .key = amqp_cstring_bytes(“x-dead-letter-exchange”), .value = { .kind = AMQP_FIELD_KIND_UTF8, .value.bytes = amqp_cstring_bytes(“dlx.orders”) } }; amqp_table_t dlx_args = { .num_entries = 1, .entries = &dlx_arg }; amqp_queue_declare(conn, 1, amqp_cstring_bytes(“orders”), 0, 1, 0, 0, dlx_args); die_on_amqp_error(amqp_get_rpc_reply(conn), “Declare orders queue”);
/* Set prefetch count — process 10 at a time */ amqp_basic_qos(conn, 1, 0, 10, 0);
/* Start consuming (no-ack=false: manual ACK mode) */ amqp_basic_consume(conn, 1, amqp_cstring_bytes(“orders”), amqp_empty_bytes, 0, 0 /no_ack=false/, 0, amqp_empty_table); die_on_amqp_error(amqp_get_rpc_reply(conn), “Consume”);
while (1) { amqp_envelope_t envelope; amqp_maybe_release_buffers(conn); amqp_rpc_reply_t res = amqp_consume_message(conn, &envelope, NULL, 0); if (res.reply_type != AMQP_RESPONSE_NORMAL) break;
fprintf(stdout, <span class="cv">"Delivery tag: %"</span> PRIu64 <span class="cv">" body: %.*s\n"</span>,
envelope.delivery_tag,
(<span class="cs">int</span>)envelope.message.body.len,
(<span class="cs">const char</span> *)envelope.message.body.bytes);
int success = 1; /* TODO: replace with actual processing / if (success) { / ACK: remove from queue / amqp_basic_ack(conn, 1, envelope.delivery_tag, 0); } else { / NACK + requeue=false: sends to DLX */ amqp_basic_nack(conn, 1, envelope.delivery_tag, 0, 0); }
amqp_destroy_envelope(&envelope);
}
amqp_channel_close(conn, <span class="cn">1</span>, AMQP_REPLY_SUCCESS);
amqp_connection_close(conn, AMQP_REPLY_SUCCESS);
amqp_destroy_connection(conn);
return 0; }
#define BATCH_SIZE 100 #define POLL_INTERVAL_MS 100 typedef struct { PGconn *pg; rd_kafka_t *rk; rd_kafka_topic_t *rkt; } relay_ctx_t;
static void relay_batch(relay_ctx_t ctx) { / Fetch pending outbox rows */ PGresult *res = PQexec(ctx->pg, “SELECT id, event_type, payload FROM outbox ” “WHERE status = ‘PENDING’ ORDER BY created_at LIMIT ” BATCH_SIZE_STR ” FOR UPDATE SKIP LOCKED”);
if (PQresultStatus(res) != PGRES_TUPLES_OK) { fprintf(stderr, “Query failed: %s\n”, PQerrorMessage(ctx->pg)); PQclear(res); return; }
int rows = PQntuples(res); for (int i = 0; i < rows; i++) { const char *id = PQgetvalue(res, i, 0); const char *event_type = PQgetvalue(res, i, 1); const char *payload = PQgetvalue(res, i, 2);
/* Produce to Kafka (fire-and-forget here; use delivery callback for ACK) */ rd_kafka_produce(ctx->rkt, RD_KAFKA_PARTITION_UA, RD_KAFKA_MSG_F_COPY, (void *)payload, strlen(payload), event_type, strlen(event_type), NULL);
/* Mark as sent in same connection (no distributed txn needed: at-least-once — may re-publish if crash here before UPDATE) */ const char *params[1] = { id }; PQexecParams(ctx->pg, “UPDATE outbox SET status=‘SENT’, sent_at=NOW() WHERE id=$1”, 1, NULL, params, NULL, NULL, 0); }
PQclear(res);
rd_kafka_flush(ctx->rk, <span class="cn">5000</span>);
}
int main() { relay_ctx_t ctx; char errstr[512];
ctx.pg = PQconnectdb(<span class="cv">"host=localhost dbname=myapp user=relay_user"</span>);
if (PQstatus(ctx.pg) != CONNECTION_OK) { fprintf(stderr, “PG connect failed: %s\n”, PQerrorMessage(ctx.pg)); return 1; }
rd_kafka_conf_t *conf = rd_kafka_conf_new();
rd_kafka_conf_set(conf, <span class="cv">"bootstrap.servers"</span>, <span class="cv">"localhost:9092"</span>,
errstr, <span class="ck">sizeof</span>(errstr));
rd_kafka_conf_set(conf, <span class="cv">"enable.idempotence"</span>, <span class="cv">"true"</span>, errstr, <span class="ck">sizeof</span>(errstr));
ctx.rk = rd_kafka_new(RD_KAFKA_PRODUCER, conf, errstr, <span class="ck">sizeof</span>(errstr));
ctx.rkt = rd_kafka_topic_new(ctx.rk, <span class="cv">"domain-events"</span>, NULL);
fprintf(stdout, <span class="cv">"Outbox relay started\n"</span>);
while (1) { relay_batch(&ctx); usleep(POLL_INTERVAL_MS * 1000); } }
docker compose up kafka zookeeper. Create topic events with 3 partitions, replication-factor 1.enable.idempotence=true. Send 10,000 messages with keys user-{i%100} (100 distinct keys).kafka-console-consumer --partition output.isolation.level=read_committed consumer that aborted batches are not visible.docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management. Open management UI at http://localhost:15672.ordersqueue → DLX:retry-exchangeorders.retry.5squeue → TTL: 5000ms → DLX:orders-exchange(routes back toorders)orders.dead-finalqueue for permanently failed messages (retry limit exceeded)
orders table in PostgreSQL with normalized schema. Each INSERT/UPDATE publishes an event to Kafka topic order-events.order-events. For each event, upsert a denormalized Redis hash: HSET order:{id} customer_name "..." status "..." total "...".ZADD). Update on every OrderPlaced event.- Explain topic, partition, offset, consumer group, ISR
- Describe producer partition assignment: key hash vs round-robin
- Explain rebalance: eager vs cooperative, triggers
- Configure
acks=all + min.insync.replicas=2for durability - Implement idempotent producer with librdkafka
- Implement manual offset commit consumer
- Explain log compaction and when to use it
- Explain the 5 exchange types and when to use each
- Configure dead-letter exchange for failed messages
- Build retry chain with TTL + DLX
- Implement manual ACK/NACK consumer
- Set prefetch count for flow control
- Explain at-most-once, at-least-once, exactly-once trade-offs
- Configure idempotent + transactional producer for EOS
- Set
isolation.level=read_committedfor EOS consumers
- Implement Outbox: atomic DB write + relay process
- Explain Saga orchestration vs choreography trade-offs
- Design CQRS write/read model split for a given domain
- Explain Event Sourcing: replay, snapshots, schema versioning
- Implement idempotent consumer with dedup table (PostgreSQL)
- Design idempotency keys for Kafka, RabbitMQ, HTTP APIs