Skip to content

Events on JetStream

docs/events.md defines what an event is. This is the adapter that puts it on the message bus most deployments already run, and the reason it is one story rather than two: a publisher that retries without a dedupe header and a consumer that acks before it has finished each look correct on their own, and together they lose or double every event the broker ever hiccups on.

flowchart TD
    Open[open] --> Info{stream as documented?}
    Info -- no --> Refuse[ConfigurationError naming the mismatch]
    Info -- yes --> Publish[publish with Nats-Msg-Id = event id]
    Publish -- ack --> Stored[one message on the stream]
    Publish -- duplicate --> Stored
    Publish -- no ack --> Retry{attempts left?}
    Retry -- yes --> Publish
    Retry -- no --> Mode{delivery}
    Mode -- guaranteed --> Fail[EventPublishError]
    Mode -- best effort --> Buffer[bounded buffer, oldest dropped and counted]

Publishing

from tesserix_adk.adapters import JetStreamEventPublisher, StreamRequirement
from tesserix_adk.core import Delivery, Eventing

publisher = await JetStreamEventPublisher.open(
    jetstream,
    clock=clock,
    requirement=StreamRequirement(name="ADK_EVENTS", min_age_seconds=86_400.0),
    delivery=Delivery.BEST_EFFORT,
)
eventing = Eventing(publisher, clock=clock)

The subject is adk.events.<tenant>.<kind>. The tenant is a token in the subject so a consumer can be authorised for its own events and no other — which the stream can enforce and the payload cannot. subject_for refuses anything that is not a plain token, so a wildcard smuggled in as a tenant name cannot widen who hears the event.

What is checked before the first event

open describes the stream and refuses to start where publishing would not mean what it appears to mean:

Refusal Why it is not a warning
the stream does not exist, or the broker is unreachable every publish would report success into a void
retention is not limits one consumer's acks would decide what another can still read
max_age is shorter than min_age_seconds a consumer offline for a weekend silently misses events
the subjects do not cover the root nothing on the stream would store what is published
max_msg_size is under the payload ceiling the broker rejects events at some size nobody predicted

Retries and duplicates

Every publish carries Nats-Msg-Id: <event_id>. When an ack is lost and the publish is retried, the stream recognises the id and stores one message. The publisher counts what happened — published, duplicates, ambiguous, attempted — so an ack that never arrived is visible rather than inferred from a downstream count that disagrees.

Under Delivery.BEST_EFFORT an unreachable broker fills a bounded buffer; once it is full the oldest is dropped and dropped counts it. flush() drains what is buffered when the broker returns. Under Delivery.GUARANTEED nothing is buffered and the caller is told.

Consuming

from tesserix_adk.adapters import DurableConsumer

consumer = DurableConsumer(
    subscription, handler=record, max_deliver=5, dead_letter=letters
)
while running:
    await consumer.consume()

The ack happens after the handler returns. A handler that raises leaves the message for redelivery; the delivery the consumer's max_deliver calls the last one is buried through the dead letter and terminated, as is anything that is not an envelope. Without a dead letter it is still terminated — replaying a message nothing can parse helps nobody.

Consumer-side idempotency bookkeeping is a separate concern; the dedupe header makes the publish exactly-once on the stream, not the handling.

Stream configuration

The stream is the platform's to provision, not this adapter's to create. What the adapter needs of it is the table above; expressed as JetStream configuration that is a limits stream over adk.events.> with max_age at least the documented retention and max_msg_size at least the event ceiling, with durable pull consumers filtered per tenant subject and an explicit ack_wait and max_deliver.