Add kafka-data: to the bottom volumes: list. ADVERTISED_LISTENERS is the address Kafka tells clients to use; kafka:9092 works from other containers, which is where all our clients run.
Real output
Created topic todo-events.
Created topic todo-events-dlq.
Topic: todo-events PartitionCount: 3 ReplicationFactor: 1 Configs: min.insync.replicas=1
Topic: todo-events Partition: 0 Leader: 1 Replicas: 1 Isr: 1
Topic: todo-events Partition: 1 Leader: 1 Replicas: 1 Isr: 1
Topic: todo-events Partition: 2 Leader: 1 Replicas: 1 Isr: 1
If Kafka has only just started, wait 10–20 seconds before creating topics.
app/consumers.py (new file)
- The feed turns off auto-commit and commits each offset after the row is in Postgres. That gives at-least-once delivery: a crash can mean seeing a message twice, never losing it. The unique
event_id makes the second time harmless.
- Messages that can never be processed go to the dead-letter topic instead of blocking everything behind them.
- On
docker compose stop, the consumer finishes its message and calls close(), so its partitions move to other consumers at once.
Your todo ids depend on what's already in your table: use an id that belongs to tenant 1.
Real output (trimmed)
[{"type":"todo.completed","todo_id":1,"title":"Renew SSL","at":"2026-10-12T13:55:40.055194+00:00"},
{"type":"todo.created","todo_id":1,"title":"Renew SSL","at":"2026-10-12T13:55:39.987908+00:00"}] ← tenant 1 only (RLS)
Partition:1 2 {"event_id": "cae720ed-…", "type": "todo.created", "tenant_id": 2, … "title": "Ship v2", …}
Partition:2 1 {"event_id": "adacaf70-…", "type": "todo.created", "tenant_id": 1, … "title": "Renew SSL", …}
Partition:2 1 {"event_id": "c350edeb-…", "type": "todo.completed", "tenant_id": 1, … "title": "Renew SSL", …}
The key (1 or 2) decides the partition: both tenant 1 events are in partition 2, in order. The very first time, the feed can take 10–20 seconds to join its group before rows appear.
Windows: the loops below are bash. Run them in WSL Ubuntu, or in PowerShell 7 write the loop as 1..30 | ForEach-Object { ... } with curl.exe.
1. "The activity feed stopped updating." Stop the feed, keep using the app, then start it again.
Real output
PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
1 1 16 15
2 2 17 15 ← 30 events waiting; nothing lost
after start: caught up in about 11 seconds (container start included)
Kafka kept the events, so the consumer resumes from its committed offset. The lag panel in Grafana shows the same climb and fall.
2. "We added consumers but it isn't faster."
Real output (columns trimmed)
CONSUMER-ID #PARTITIONS
rdkafka-08241046-ffce-48b7-ac1e-54dd76144bd6 1
rdkafka-0dbdd684-c5e1-448c-b3e9-3a872343a2df 1
rdkafka-7f7ab749-050c-47a8-810a-69ab091428b9 1
rdkafka-de65a4f2-d5c3-489f-97e8-cff82981ea88 0 ← 3 partitions = at most 3 busy consumers
3. "Some activity shows up twice." Make the consumer crash after writing a row but before committing its offset.
Real output
{"level": "error", "logger": "consumer", "msg": "simulated crash: row written, offset NOT committed"}
lag on that partition: 1 ← the event will be delivered again
count
-------
1 ← redelivered, but ON CONFLICT skipped it
A consumer that crashes never says "I'm leaving", so the group waits for its session to time out (45 s by default) before handing its partitions to another consumer. That's why recovery can take a minute. Now remove the safety net: drop the unique constraint and the redelivered insert fails with there is no unique or exclusion constraint matching the ON CONFLICT specification. Remove ON CONFLICT too, and the same event is stored twice. Put both back.
4. "The feed is stuck." Publish a message that isn't JSON.
Real output
{"level": "error", "logger": "consumer", "msg": "bad event sent to DLQ", "partition": 1, "offset": 17,
"error": "Expecting value: line 1 column 1 (char 0)"}
this is not json ← parked in the dead-letter topic; later events kept flowing
Without the except block, the consumer would crash before committing, restart, read the same message and crash again, forever. One bad message blocks its whole partition.
5. "Rebuild the feed from scratch." Replay: move the group back to the start of the topic.
Real output
GROUP TOPIC PARTITION NEW-OFFSET
activity-feed todo-events 0 0
activity-feed todo-events 1 0
activity-feed todo-events 2 0
activity rows: 49 before → 0 after TRUNCATE → 49 after replay
Run the reset while the consumer is still running and Kafka refuses: Error: Assignments can only be reset if the group 'activity-feed' is inactive, but the current state is Stable. Use --dry-run instead of --execute to preview.
6. "A todo exists but never appeared in the feed." Kafka is down while the API writes.
Real output
A: API 201 → event waited in the producer's memory → in the feed once Kafka returned
B: API 201 → todo saved in Postgres → API restarted → event gone (not in the topic, not in the feed)
The producer buffers and retries for a while (librdkafka's default message timeout is 5 minutes), but that buffer lives in process memory. The standard fix is the outbox pattern: write the event into an outbox table in the same transaction as the todo, and let a separate process publish from that table. The event is then as durable as the todo itself.