Architecture
Vulkan has no broker process. The “server” is your Postgres database plus a schema; the “clients” are your own application processes running the Vulkan library. Everything below is rows.
┌──────────────────────────────────────────────┐ │ POSTGRES │ producers │ │ consumers │ vulkan.events (immutable log) │ app ──publish──────▶ │ ─ offset, topic, routing_key, │ app ──publish-in-tx▶ │ partition_key, headers, payload │ │ │ │ │ │ fan-out via bindings │ │ ▼ │ │ vulkan.bindings (routing rules) │ │ ─ consumer_group, pattern │ │ │ │ │ ▼ │ │ vulkan.deliveries (per-group lifecycle) │ ◀─claim/ack── worker │ ─ (group, offset) → status, attempts, │ ◀─claim/ack── worker │ run_at, locked_at, last_error │ │ │ │ vulkan.consumers (bare cursors) │ ◀─read at cursor─ replayer │ ─ name → position │ └──────────────────────────────────────────────┘The four tables
Section titled “The four tables”events — the log. Append-only, never mutated by consumption. The
offset (a bigserial) is the global position. Retention is enforced by
time-partitioning, so expiring old history is a partition DROP, not a
million-row DELETE.
deliveries — the lifecycle. One row per (consumer_group, event) for
groups that opt into full lifecycle. The status state machine
(ready → processing → done | dead, with retry loops) lives here. Workers
claim rows with FOR UPDATE SKIP LOCKED, so any number of workers compete
safely with zero coordination. A partial index on ready rows keeps the claim
query fast no matter how much history accumulates.
consumers — the cursors. For groups that don’t need per-message
lifecycle (analytics readers, replicators, replays), consumption is just
“read events past my position, advance my position.” One integer per group.
Fan-out costs nothing because the log is shared and positions are private.
bindings — the routing. Which groups receive which events, decided at
fan-out time by matching routing_key patterns (orders.*.created) or
header predicates (JSONB containment). Producers never address consumers.
The flow of one message
Section titled “The flow of one message”- Publish.
INSERT INTO vulkan.events ...— optionally inside your transaction, next to your business writes. Commit makes the message durable and visible, atomically. - Fan-out. Delivery rows materialize for each lifecycle group whose binding matches. Cursor groups need nothing materialized at all.
- Claim. A worker grabs ready deliveries with
SKIP LOCKED, marks themprocessing, commits the claim instantly, and runs your handler unlocked — a ten-minute job never holds a database lock. - Resolve. Success →
done. Failure → re-readywith exponential backoff, until attempts exhaust →dead. Worker crashed? A reaper returns leases that expired, automatically.
Write amplification, honestly
Section titled “Write amplification, honestly”Lifecycle costs writes: 1,000 events fanned out to 5 lifecycle groups means 5,000 delivery rows (cursor groups: zero). That’s the same trade Pulsar makes with per-subscription cursors and Kafka refuses to make (hence its retry-topic contortions). Vulkan makes it per-stream optional: pay for lifecycle where you need acks and dead letters, use free cursors where you don’t.
Performance posture
Section titled “Performance posture”- Claim path: one indexed
UPDATE … SKIP LOCKEDround-trip per batch. LISTEN/NOTIFYwakes idle workers instantly; a fallback poll catches delayed messages and missed notifies.- Sustained tens of thousands of messages/second on ordinary Postgres hardware — with batching, well beyond most products’ lifetime peak. When you genuinely outgrow that, you’ll know.