1 — Billboard Activity Graph
Rank 1 of 28 · Tier S · app/BillboardActivityService, app/workers/billboard_users
Stack: Go 1.25, Kafka (segmentio/kafka-go), Cassandra (gocql), PostgreSQL, Cloudflare Workers
Status: Built end-to-end and running. Commit 9d31b7d28.
Why this is ranked first. This is the system design interview. Not like it — it is the exact question ("design a social feed / activity stream") with the exact primitives (outbox, log, consumer groups, idempotency, hybrid fan-out) implemented in production Go against real Kafka and real Cassandra. Every follow-up an interviewer can ask has an answer already written down in the commit message.
The problem
Playlists, follows, feed, and posts are one social graph. The naive build gives each of them its own event pipeline, its own fan-out, and its own consistency story — four systems that drift apart. The directive was one activity log and one materializer.
Complication: the service that writes activity, billboard-users, is a
Cloudflare Worker. Workers cannot open raw TCP sockets — not with a
library, not with a shim. The Kafka wire protocol and CQL are both permanently
unreachable from a Worker.
The architecture
billboard-users (Cloudflare Worker)
│ writes billboard_feed_events in the SAME transaction as the post
▼
Postgres billboard_feed_events ← the activity log / outbox
│ relay: claim → produce → stamp published_at
▼
Kafka billboard.activity.v1 (6 partitions, keyed by actor)
│
├─ group feed-materializer → Cassandra billboard_feed.feed_timeline
├─ group notification-worker → Postgres billboard_notifications
└─ group fanout → realtime delivery
Four processes in one binary because they share a database pool and a Cassandra session — not because they are coupled. Each consumer owns its group, so splitting them across hosts later is a deployment change, not a rewrite.
The four decisions worth defending
1. Outbox, not direct produce
A Worker cannot enlist Kafka in a Postgres transaction. Producing directly leaves a window where either a post exists with no event, or an event survives a rolled-back post. Writing the event inside the post's transaction and relaying it afterwards closes that window.
2. At-least-once, with the cost paid explicitly at every sink
The relay produces before stamping published_at. A crash between the two
re-delivers. That is deliberate: a lost event cannot be recovered, a
duplicate can be absorbed. Each consumer absorbs it differently:
| Consumer | Idempotency mechanism |
|---|---|
| feed-materializer | billboard_feed.applied_activity, Cassandra, 7-day TTL |
| notification-worker | unique index on activity_id + ON CONFLICT DO NOTHING |
| fanout | repeated realtime frame dropped client-side |
The idempotency key is the outbox row id — stable across re-delivery by construction.
3. Three groups, not three stages
A Cassandra outage must not stop notifications. A notification failure must not keep posts out of feeds. A single shared pipeline does exactly that. Three independent groups off one topic means each can fail, lag, and retry alone.
4. Thin events, not denormalized ones
billboard_feed_events carries ~40 denormalized display columns for the
activity-feed UI. None of them are in the Kafka event. A consumer that
needs the actor's avatar joins for it. Baking display fields into the event
freezes every one of them at write time — and they are wrong the moment a user
renames themselves.
Related design directives implemented
- Fractional / LexoRank ordering, not integer positions — reordering one track must not rewrite every row after it.
- System playlists are fixed-ID playlists. "Liked Songs" is a row with a reserved id, not a parallel table with duplicate code paths.
- One
followstable discriminated bytarget_type, not one per followable kind. - Hybrid fan-out — push below ~10k–50k followers, pull above. The threshold is a tuning constant, not a fork in the architecture.
What was verified before building
Directive 6 ("feed materialization is a Kafka consumer writing to Cassandra")
required a Kafka and a Cassandra reachable from this host. Both were confirmed
present before a line was written. Directive 2 was identified as a migration,
not a new column — /v1/playlists/{id}/shuffle already rewrites an integer
position holding live data.
Where a directive would have required infrastructure that was not present, it was written up as blocked rather than quietly substituted.
Scope
| Metric | Value |
|---|---|
| Go source | 977 lines across 5 internal packages |
| Kafka partitions | 6, keyed by actor id |
| Consumer groups | 3, independent |
| Datastores written | Cassandra, Postgres, realtime channel |
| Delivery semantics | At-least-once, deduped at every sink |
Interview surface this opens
- Exactly-once vs at-least-once, and why you never got to claim the former
- Outbox pattern vs CDC vs dual-write, and what each costs
- Partition key choice (actor id) and the hot-partition risk it carries
- Consumer group rebalancing and lag monitoring
- Push/pull fan-out crossover and where the celebrity problem bites
- LexoRank and why integer positions do not survive reordering at scale