Skip to content

Flows

Six behaviours that are hard to read off the source because the interesting part is an ordering — which call happens before which, and what breaks if they swap. Everything here is drawn from the code; where a diagram and the prose beside it disagree, the code is at applications/… and the code wins.

Structure lives elsewhere: the ADR has the component map, and the per-service pages have the module layouts. This page is only about time.


1. Ingest, happy path

What question this answers: between the producer's POST and its 200, what does wolfram decide, in what order, and at what point does the trace context get attached to the record?

sequenceDiagram
    autonumber
    participant P as Producer
    participant A as IngestApi<br/>Vert.x event loop
    participant V as JwtVerifier
    participant S as IngestionService
    participant K as KafkaEventPublisher
    participant T as sender thread<br/>1 thread, bounded queue
    participant B as Kafka<br/>events.cloudevents.v1

    P->>A: POST /v1/events<br/>Authorization Bearer, ce-* headers or JSON body
    A->>V: verify token, require scope events:write
    V-->>A: Principal
    Note over A,V: applied once on the shared `base` endpoint,<br/>so no route can exist without it

    A->>S: ingest(headers, body)
    Note over S: in this order, and the order matters:<br/>1. size vs max-event-bytes, refused before anything is decoded<br/>2. HttpBinding.modeOf — ce-specversion first, media type second<br/>3. Envelope.decoder — kernel's codec, identical for both modes<br/>4. TimeClamp.check — reject an implausible or absent time, never invent one

    S->>K: publish(envelope)
    Note over K: KafkaCodecs.producerRecord — binary mode, key = Envelope.partitionKey.<br/>Context.current() is read HERE, on the calling thread, and carried<br/>across the hand-off: OTel context is ThreadLocal, so reading it on<br/>the sender thread would return root and orphan the produce span.
    K->>T: sender.execute — enqueue

    alt queue full
        T--)K: RejectedExecutionException
        K-->>S: BrokerUnavailable
        S-->>P: 503 UNAVAILABLE, retryable, nothing published
    else accepted
        Note over T: start PRODUCER span, parented to the captured context
        T->>T: KafkaTrace.inject — traceparent into the record headers
        T->>B: producer.send, acks=all, enable.idempotence=true
        B-->>T: RecordMetadata, partition and offset
        Note over T: span ends in the broker callback, not when send returns —<br/>so the span and kafka.produce.latency measure acknowledgement
        T-->>S: PublishAck
        Note over S: metrics.accepted and metrics.observed fire AFTER the ack,<br/>so a refused event never reports a producer problem
        S-->>P: 200 with the created event, name = events/{event}
    end

send is the reason for the sender thread. KafkaProducer.send blocks while topic metadata is unknown or the record accumulator is full — up to max.block.ms — and on a Vert.x event loop that is not a latency problem but an availability one, because the loop serves every other connection too. One thread keeps per-key ordering; the bounded queue is where backpressure becomes a 503 instead of a heap.

200 and not 201. AIP-133 wants a Create to return the resource, and 201 would oblige a Location pointing at a GET /v1/events/{event} this service cannot serve — it owns no storage.


2. Consume and persist

What question this answers: when is an event durable, and when is its Kafka offset allowed to move past it?

The answer is the whole correctness story of the pipeline, so read the diagram bottom-up: the committer is the last stage, downstream of the write, and nothing else in the graph can reach it first.

sequenceDiagram
    autonumber
    participant B as Kafka
    participant C as committableSource
    participant D as RecordDecoder
    participant P as BatchProcessor
    participant R as PostgresEventRepository
    participant G as PostgreSQL
    participant M as Committer.flow

    B->>C: ConsumerRecord, ByteArrayDeserializer
    Note over C: never CloudEventDeserializer — a throwing deserializer throws<br/>inside poll(), before the connector sees the record,<br/>so every restart replays the same poison pill
    C->>D: CommittableMessage
    Note over D: KafkaTrace.withConsumerSpan — extract the traceparent<br/>wolfram injected, open a CONSUMER span under it
    D->>D: KafkaCodecs.decode then NewEvent.from
    D-->>C: DecodedRecord = record + committableOffset + Either

    C->>C: groupedWithin(500, 250 millis)
    C->>P: mapAsync(1) process(batch)
    Note over P: mapAsync(1), not (n): two batches from one partition in flight<br/>would let the younger one's offset commit past the older one's failure

    P->>R: insertAllCheckpointed(events, commit)
    R->>G: BEGIN
    R->>G: INSERT INTO events.cloud_event ... ON CONFLICT<br/>(occurred_at, ce_source, ce_id) DO NOTHING
    R->>G: INSERT INTO events.consumer_checkpoint ... ON CONFLICT DO UPDATE<br/>guarded so a late write cannot rewind next_offset
    R->>G: COMMIT
    Note over R,G: ONE transaction. The rows and the offset that accounts for them<br/>commit together or not at all — that is the entire reason<br/>events.consumer_checkpoint exists rather than a Redis key.
    G-->>R: rows written
    Note over P: batch.size minus written is consume.records.duplicate —<br/>the direct evidence that redelivery is being absorbed

    P-->>C: Vector[Committable]
    C->>M: mapConcat, then the committer
    M->>B: commit offsets
    Note over M,B: strictly downstream of the write. An offset cannot physically<br/>reach here until the transaction above committed, so an offset<br/>is a receipt for a durable effect.

Swap the last two stages — commit first, or commit inside the write stage's andThen — and a crash in the window between them loses every event in flight, silently, with the consumer group reporting zero lag.

The write is idempotent, so at-least-once redelivery plus CloudEvents' own (source, id) uniqueness is observationally exactly-once at the database and nowhere else. See the dedup contract.

The checkpoint statement is drawn unconditionally because production wires it unconditionally — Main builds the processor with a BatchProcessor.Checkpointing carrying the group id and the container's hostname as owner. Without one, BatchProcessor falls back to a plain insertAll and is silent about offsets rather than writing them in a second transaction, which would reintroduce exactly the window events.consumer_checkpoint removes while looking like it worked. That fallback exists for the suites that have no store.


3. A poison record

What question this answers: a record that cannot be decoded blocks nothing — so what actually happens to it, and why does its offset move even though it was never persisted?

sequenceDiagram
    autonumber
    participant B as Kafka<br/>events.cloudevents.v1
    participant D as RecordDecoder
    participant P as BatchProcessor
    participant Q as Kafka<br/>events.cloudevents.v1.dlq
    participant G as PostgreSQL
    participant M as Committer.flow

    B->>D: one batch, one record undecodable
    Note over D: total by construction — unknown content mode, malformed JSON,<br/>a missing attribute, no time, or any NonFatal throw<br/>all become Left(DeadLetter)
    D-->>P: DecodedRecord with Left(DeadLetter) beside the good ones

    P->>P: partitionMap: poison, pending
    Note over P: dead letters go FIRST, one at a time.<br/>A fan-out of produces at a broker that may itself be the problem<br/>is how a poison batch becomes an outage.
    P->>Q: publish, structured mode,<br/>key = Topics.dlqKey(topic, partition, offset)
    Note over Q: the dead letter is itself a CloudEvent, carrying reason, detail,<br/>the origin coordinates and the original value bytes and headers verbatim
    Q-->>P: ack

    P->>G: the rest of the batch + the checkpoint for the WHOLE batch, one transaction
    G-->>P: rows written
    P-->>M: committables for the WHOLE batch, poison record included
    M->>B: commit
    Note over M,B: the one case where an offset moves past an event that was<br/>never persisted. Defensible only because the DLQ record is durable<br/>and carries why, where and what.

The checkpoint in step 9 covers the poison record's offset too, and that is not incidental: process hands the committer a Committable for every record in the batch, so anything the checkpoint leaves out becomes permanent consume_checkpoint_divergence and a restart?target=stored that rewinds onto records already on the DLQ. A batch that dead-lettered everything therefore still writes a checkpoint, with no rows beside it — BatchProcessor.Accounted is the type that keeps the two sets from drifting apart.

A record the database rejects follows the same road by a longer route. BatchProcessor retries the whole batch first — a database blip is not a data problem — then bisects, halving until the failure is attributed to one record, and dead-letters that one. log₂(500) ≈ 9 extra round trips buys never losing a good event and never stalling. The bisection is only allowed to run on SQLSTATE class 22 or 23; anything else is rethrown, because a database that is merely down would otherwise bisect to singletons and shovel the whole batch into the DLQ.

The replay

What question this answers: an operator has a DLQ full of records from an incident that is now fixed. What do the two requests look like, and why is running the second one twice safe?

sequenceDiagram
    autonumber
    participant O as Operator
    participant L as cobalt /admin<br/>DeadLetterAdmin
    participant S as DeadLetterStore
    participant Q as Kafka DLQ
    participant B as Kafka main topic
    participant C as cobalt consumer
    participant G as PostgreSQL

    O->>L: POST /admin/dlq:replay?limit=50<br/>Bearer with the write scope
    Note over L: dryRun defaults to TRUE at the route, so the shape that<br/>publishes takes one extra deliberate keystroke
    L->>S: recent(request.fetchLimit) — the CEILING when a reason filter is in play,<br/>because a filter applied after the bound cannot match past it
    S->>Q: poll newest-first
    Q-->>S: DlqRecords
    S-->>L: Vector[DlqRecord]
    Note over L: DeadLetterReplay.select filters and bounds ONCE, for both endpoints,<br/>and reports scanned + truncated so a short answer says which kind it is
    Note over L: DeadLetterReplay.plan classifies every candidate:<br/>Undecodable, ForeignTopic, BudgetExhausted, or Replay(attempt = n+1)
    L-->>O: 200 with the full plan. Nothing was published.

    O->>L: POST /admin/dlq:replay?limit=50&dryRun=false
    L->>S: recent, plan again
    alt named refs, and one is missing or skipped
        L-->>O: 422. Nothing published — a stated set is all-or-nothing.
    else
        loop oldest-first, stopping at the first refusal
            Note over L: producerRecord copies the ORIGINAL bytes, headers and key.<br/>Nothing is re-encoded, so the CloudEvents id and source survive.<br/>Two plain transport headers are added: replay-attempt and replay-of.
            L->>S: publish onto the dead letter's own origin topic
            S->>B: produce
        end
        L-->>O: 200 with published count, skipped, and where it stopped
    end

    B->>C: the replayed record, indistinguishable from the original
    C->>G: INSERT ... ON CONFLICT (occurred_at, ce_source, ce_id) DO NOTHING
    Note over C,G: a record that DID land the first time is absorbed here.<br/>Replay is not idempotent at the broker — two copies on the log —<br/>and is idempotent at the database, which is what makes<br/>retrying a half-finished replay safe.

Three bounds are worth naming because they are what keep this endpoint from being a general-purpose producer: ForeignTopic refuses a dead letter whose origin topic is not the one this consumer owns (the destination is read out of the record's own payload) — ReplaySkip.foreign is that check, and ConsumerSupervisor.resolve applies the same one to an explicit seek coordinate, which is the other place a topic name arrives from input cobalt does not control; max-records caps one operation; and ReplayHeaders.Attempt caps the poison loop at max-attempts generations, because a record that fails again lands back on the DLQ under a new key and compaction does nothing to stop it accumulating.


4. A search request

What question this answers: how many database round trips does one search page cost, and are they serial?

Four queries, and they are started before any of them is awaited. A for comprehension over Futures would sequence them and turn one 40 ms page into four serial round trips — the classic way to make a fast page slow without anyone noticing. SearchService starts them as vals and only then comprehends.

sequenceDiagram
    autonumber
    participant U as Browser<br/>htmx
    participant E as EventsController
    participant S as SearchService
    participant R as PostgresEventRepository<br/>read pool, 2s statement_timeout
    participant V as Presenter and Twirl

    U->>E: GET /events?type=...&from=...<br/>HX-Request when it is a swap
    E->>E: SearchQuery.parse(rawQueryString)
    alt the query string does not parse
        E->>V: filter bar rebuilt from the rejected permalink, plus the errors
        V-->>U: 4xx fragment or page, every bad value still in its input
    else a fragment request carrying a cursor
        Note over E: paging. No facets, no histogram, no count —<br/>the filter has not changed, so neither have they.
        E->>S: page(query)
        S->>R: one keyset SELECT
        R-->>S: rows plus nextCursor
        V-->>U: rows fragment plus a fresh sentinel
    else
        E->>S: search(query)
        par all four issued, none awaited
            S->>R: search — keyset page, list projection, no data or raw
        and
            S->>R: facets — one statement, one MATERIALIZED candidate CTE
        and
            S->>R: histogram — generate_series skeleton, LEFT JOIN date_bin
        and
            S->>R: countAtMost — SELECT 1 inside a LIMIT 10001 subquery
        end
        R-->>S: four results
        Note over S: only NOW a for-comprehension joins them, plus<br/>andThen so the timer records the failed search too
        S-->>E: SearchOutcome
        E->>V: Presenter.filterBar and Presenter.results
        V-->>U: full page, or the results fragment plus HX-Push-Url
    end
    Note over E,U: every response carries Vary HX-Request, error responses included

The pool matters to reading this. The repository was constructed with a SearchExecutionContext whose fixed pool size equals the read pool's maximumPoolSize, so "four concurrent" means four of eight connections for the duration of one page — which is also why the live tail below has a hard concurrency cap.


5. The live tail

What question this answers: the browser opens one EventSource and rows appear. What is on the server for that hour, and what stops a slow client from growing a buffer in the heap?

sequenceDiagram
    autonumber
    participant U as Browser EventSource
    participant T as TailController
    participant V as TailService
    participant R as PostgresEventRepository
    participant W as Twirl fragment

    U->>T: GET /live?filter...&after=...&afterUid=...
    Note over T: after and afterUid are peeled off BEFORE the filter is parsed.<br/>FilterQuery owns the grammar and reports anything else as unknown,<br/>so leaving them in would 400 every stream.
    alt no capacity
        T-->>U: 503 naming the reason. A capacity limit on this replica, not a quota.
    else admitted
        T-->>U: 200 chunked, Cache-Control no-store, X-Accel-Buffering no

        Note over T,V: everything below runs inside lazyFutureSource, at body materialisation —<br/>which is also the only thing that can terminate and release the slot
        T->>V: opened()
        opt no client cursor
            V->>R: newest row matching the filter, LIMIT 1
            R-->>V: seed cursor
            Note over V: starting at the wall clock instead would silently skip<br/>every event whose occurred_at lags ingest by a few seconds
        end

        loop Source.tick every 2 seconds, threaded by scanAsync
            V->>R: keyset seek: (occurred_at, event_uid) greater than the cursor,<br/>ORDER BY occurred_at ASC, LIMIT 50
            R-->>V: rows, oldest first
            Note over V: ASCENDING even though the page renders newest-first.<br/>A descending query capped at 50 would skip everything between<br/>the newest 50 and the cursor — silent loss during the burst<br/>somebody opened the tail to watch.
            V->>W: Presenter.row, the same template search uses
            W-->>T: one tr per row
            alt rows
                T-->>U: one SSE frame per row, event row, id = event_uid
            else nothing new
                T-->>U: heartbeat frame
            end
        end

        U--)T: tab closed
        T->>V: closed() from watchTermination
    end

Three bounds, and the third is the one people miss. scanAsync runs one future at a time, so ticks cannot overlap; Source.tick drops a tick the downstream is not ready for rather than queueing it, so a slow client falls behind in time and never in the server's heap; and MaxConcurrent refuses the seventeenth tail, because sixteen forgotten tabs against a pool of eight connections make search slow for everyone, which is a failure whose cause is invisible from the page that is failing.

The honest limitation: occurred_at is the producer's clock, so an event ingested now but stamped behind the cursor sorts behind it and never appears. Ordering on ingested_at has only a BRIN index, which cannot serve an ordered seek.


6. A consumer restart with custom offsets

What question this answers: why does the supervisor insist on draining before it moves the group's offsets, when draining costs a rebalance?

Because Kafka refuses alterConsumerGroupOffsets while the group has live members — and it is right to. An offset moved under a running consumer would be overwritten by that consumer's next commit, so the call would appear to succeed and change nothing.

sequenceDiagram
    autonumber
    participant O as Operator
    participant A as SupervisorAdmin
    participant S as ConsumerSupervisor
    participant H as the running stream
    participant K as Kafka AdminClient
    participant P as PostgreSQL<br/>consumer_checkpoint

    O->>A: POST /admin/consumer:restart?target=stored
    Note over A: dryRun defaults to TRUE, matching the DLQ replay —<br/>one convention for both irreversible operations
    A->>S: status
    S->>P: load(groupId)
    P-->>S: stored next_offsets
    A-->>O: 200 wouldSeek. Nothing stopped, nothing moved.

    O->>A: POST /admin/consumer:restart?target=stored&dryRun=false
    A->>S: restart(target, explicit)
    Note over S: everything below is inside one synchronized transition.<br/>status never takes that lock, so an operator can still ask<br/>what is happening WHILE a slow drain happens.

    S->>P: resolve target=stored
    P-->>S: concrete offsets per partition
    Note over S: resolve runs BEFORE the drain, so a target that cannot be<br/>resolved refuses without having stopped anything

    S->>H: drain, bounded by DrainTimeout
    Note over H,K: drain commits everything in flight and LEAVES THE GROUP.<br/>Pause is implemented the same way — Consumer.Control has no pause,<br/>and holding partitions assigned while not committing<br/>only delays the rebalance unpredictably.
    H-->>S: done, state Stopped

    S->>K: alterConsumerGroupOffsets(groupId, offsets)
    alt the group still had members
        K--)S: refused
        Note over S: state stays Stopped and the response is 409.<br/>Starting against offsets nobody chose cannot be undone.
        S-->>O: 409 with the cause
    else moved
        K-->>S: ok
        S->>H: factory.start — a fresh stream, generation + 1
        Note over H: a drained stream is not restartable —<br/>resume always materialises a new one
        S-->>O: 200 with the offsets it actually set
    end

target=stored reads events.consumer_checkpoint, not Kafka. That table exists because offsets.retention.minutes defaults to seven days: a group that stops committing for longer has its offsets deleted, and the next start resolves auto.offset.reset and replays the retained log, silently. :clearCheckpoints forgets that table and deliberately does not touch __consumer_offsets — conflating the two would make one word mean two irreversible things.


See also the event model for what travels on the wire, and the schema for what the writes land in.