Skip to content

Real-time sync (SSE)

When one client changes a task, every other client viewing that project sees it within a couple of seconds, with no polling. This is built on Server-Sent Events (ADR 0003) and an in-process broadcaster wired to the database commit lifecycle.

The core idea: events are committed writes

There is no separate "publish" step a developer has to remember. When a handler records an audit entry for a change, it also:

  1. builds the SSE event payload from the just-written row,
  2. stores that payload on the audit_log.payload column, and
  3. queues a copy on the session.

When the request's transaction commits, the queued copies fan out to live subscribers. When a client later reconnects, the stored payloads replay. Because live and catch-up come from the same payload, a client can't tell which path delivered an event — they're byte-identical.

The corollary matters: if the transaction rolls back, the queued events are dropped, so no subscriber ever observes an event for a write that didn't land. This "no phantom events" invariant is free, because it rides the same commit/rollback the data does.

The moving parts

events/
├── broadcaster.py   in-process fan-out: per-subscriber asyncio.Queue, project filtering
├── publisher.py     SQLAlchemy after_commit / after_rollback listeners
└── payload.py       the event types and wire format

publisher.py — the commit hook

A SQLAlchemy after_commit listener (registered by importing the module — app/main.py does this for its side effect) drains the events queued on the session and hands them to the broadcaster. An after_rollback listener clears the queue instead. Queuing is just session.info["pending_events"].append(event); the listeners do the rest.

broadcaster.py — the fan-out

A single in-process EventBroadcaster instance holds a set of subscriptions, each with its own bounded asyncio.Queue and the subscriber's project_id and user_id. Publishing:

  • Sync→async bridge. Writes commit on a worker thread, but the queues live on the event loop. The broadcaster captures the loop at startup (via the app lifespan) and uses loop.call_soon_threadsafe(...) to hop the fan-out onto the loop thread. Because the subscriber set and the queues are only ever touched on that one thread, no locks are needed.
  • Project filtering. An event is only enqueued for a subscriber whose project_id matches (ADR 0022) — events never leak across projects.
  • Backpressure by disconnect. If a slow subscriber's queue fills, the broadcaster drops that subscriber (pushing a sentinel to close it cleanly) rather than blocking everyone. The browser's EventSource reconnects automatically and catch-up replays whatever it missed.
  • Mid-stream revocation. If a remove_member event targets a connected subscriber, the broadcaster sends a revocation sentinel so the stream can tell that client its access just ended.

The endpoint — events.py

GET /api/v1/events?project_id=… is the backend's only async def handler. On connect it authenticates, checks project membership (non-members get 404), then runs in two phases:

  1. Catch-up. If the client sent a cursor (?since= or the Last-Event-ID header), the endpoint queries the audit log for that project's rows with id > since and a non-null payload, ordered by id, capped at 1000. It streams those stored payloads, then emits a batch_end sentinel — with a "there's more" flag if it hit the cap, so the client reconnects with a new cursor and continues.
  2. Live. It then awaits items from its subscription queue, emitting each as an SSE frame, and de-duplicates any live event whose sequence is at or below where catch-up ended.

The wire format and the cursor

Each event is framed as:

event: tasktree
id: <sequence>
data: <JSON>

The JSON envelope carries seq, project_id, actor_id, at, and a type discriminator (task.created, task.updated, task.deleted, dependency.created, dependency.deleted, project.updated, and the membership events) plus the type-specific body (ADR 0014).

The sequence cursor (seq) equals the audit_log.id that produced the event. It's monotonically increasing but not contiguous — a PostgreSQL sequence advances even on rollback, so gaps are normal. A client reconnects with the last seq it saw; it never needs to reason about which numbers it skipped.

The single-worker constraint

The broadcaster's subscriber set lives in one Python process. A second worker would have its own set, and a write handled by worker A would never reach a client connected to worker B (until that client reconnected and caught up from the audit log). To keep live delivery correct, production runs one gunicorn worker (-w 1) (ADR 0036). At ten-users-per-workspace scale a single event loop handles the load comfortably.

The documented upgrade path, if horizontal scale is ever needed, is PostgreSQL LISTEN/NOTIFY: each worker subscribes to one channel, the commit hook emits NOTIFY with the event JSON, and each worker pushes received events through its existing local broadcaster. That reuses everything here and adds no new infrastructure. It's deferred until load justifies it.