Skip to content

Architecture

Parsley is one package, io.github.tobyjamesclements.parsley, with two visibility tiers.

The public tier is the entire supported surface, shaped as a functional core with imperative edges. The core is the user's logic vocabulary, free of Kafka types: Topic and Codec (typed topics with pure byte codecs; Kafka serdes bridge in at the edge), Message (a delivery with its own coordinate), Emission (an output as a value, minted by Topic.send), Handler (message to emissions), and Fold with Step (pure per-key state step). The edges run it: Stage wires logic to topics, Parsley composes stages and is the front door (testTopology() for broker-less TopologyTestDriver tests, streams(props) for production), CausalStreams is the running application (a curated allowlist over Kafka Streams — lifecycle, state, listeners, metrics, lag — with no accessor to the underlying instance), and the plain-client CausalClock (constructed from an Admin; observe, record own sends, stamp — fully opaque) with CausalHeaders' names and sender/seq readers for observability. No protocol vocabulary escapes: the vector clock and channel types are internal. The package-private tier is the protocol implementation, the adapter's plumbing, and every seam — the offset queries carry soundness obligations (strict end-offset resolution, definitive absence) that make them contracts users must not substitute, so tests get a pre-wired broker-less topology rather than an injection point. The protocol is hidden deliberately: it is only sound under a host contract no API can enforce — per-channel offset order in, atomic commit of store, offsets, and sends, position advances from the real consumer, partitioning before stamping — and the one host that upholds that contract ships in the same package.

Kafka's semantics are load-bearing throughout the protocol: offsets are broker-assigned (hence sequence claims), visibility is transactional (hence the density adaptation and step-atomic recovery), and liveness rides the consumer's position (hence position-advance bridging). The implementation's freedom from a compile-time Kafka dependency is a simulator seam, not transport abstraction — the deterministic simulator, the protocol's primary verifier (verification), hosts it from the test tree of the same package.

The protocol surface

Type Role
Channel, VectorClock (topicId, partition) identity; vector clocks with max-merge, dominance, truncation, and a sorted, versioned wire form
DeliveryProtocol The host-facing surface: onRecord, positionAdvance, prepareSend, resumePositions, truncate
CausalNode The implementation: gate, hold queues, density adaptation, stamp, rescope, restore
StateStore (SPI) Keyed bytes, transactional with delivery — the host commits it atomically with consumed offsets and sends
BrokerOffsets (SPI) Broker offset facts: sink end offsets (init seed) and log starts (truncation stability)
InboundRecord, Delivery The envelope in (bytes, coordinate, clock), the release out (in causal order)

The host contract, in one paragraph: feed records per channel in offset order through onRecord; report consumer position advances through positionAdvance; stamp and tag every outbound send with prepareSend (against its concrete destination channel — the host partitions before stamping); process the returned Delivery list in order; commit the store, the consumed offsets, and the sends atomically (EOS). Indications are pulled — deliveries come back as ordered return values — because both Kafka Streams processing and the simulator are synchronous.

Receive path

onRecord seeds or bridges density (below), folds the record's clock into the channel's advertised clock, enqueues, and cascades. The gate itself is specified in the delivery gate: heads only, normalise, restrict, dominate. The cascade loops until no head is deliverable and no drained queue has a known clean prefix left to fold.

The stamp

Outbound records carry stamp = frontier ∪ channelClocks ∪ carriedAncestry ∪ ownOutputs (pointwise max), computed at one site. Each term closes one route by which a real cause could escape the claim:

Term Covers Without it
frontier Everything delivered here, contiguously Direct causes escape
channelClocks Per consumed channel, the folded clocks of received records: ancestry that arrived through channels this node does not consume The gate's ignore branch becomes a hole
carriedAncestry Delivered past on channels no longer consumed (scope changes) A redeploy silently un-claims history
ownOutputs The node's committed prior-incarnation sends (the init end-offset seed) Own outputs carry no order across partitions and sink topics

The broker performs the sender's clock increment (offset assignment), which the sender never observes — the stamping path never waits on it. Clocks carry a second claim kind, sequence claims: (channel, sender, seq) claims every record the sender sent to that channel up to its per-channel send sequence, assigned synchronously at prepareSend. Every outbound record carries its sender tag, and a receiver resolves a sequence claim the moment it has delivered that sender's record at or past the claimed sequence (per-partition send order equals offset order under EOS, so FIFO delivery decides it with one delivered-sequence mark per channel and sender). A stamp carries at most one self-claim per sink channel — this incarnation's send counter — so the sequence surface is bounded by the sink set; resolvable sequence claims in folded custody normalise to offset claims at the stamping site. A failed send cannot orphan a claim: the claiming record and the claimed send share a transaction, so they abort together. Same-channel sends need no claim at all — per-channel FIFO delivers them in order everywhere.

ownOutputs lives in memory only and is constant after init: every declared sink's end offset, an over-claim on real appended offsets (delay-only, therefore sound) that covers every prior incarnation's committed sends. The same seed heals every restart-shaped gap, including former sinks (scope changes); prior-incarnation sequence claims echoed back through custody upgrade to this offset space at the stamping site.

Hold queues are unbounded

There is deliberately no backpressure surface. Per-channel fetch pausing cannot be honoured from inside a Kafka Streams task — the poll loop's own pause/resume bookkeeping, the task's internal buffers, and lag-aware record scheduling all sit between a processor and the fetch layer, and all belong to Streams. Rather than ship a signal the primary host cannot act on, the hold queue is unbounded: records are never dropped, and a wedge is a loud stall, not a silent loss. The operational response is monitoring hold depth and sizing retention — the same economics that already bound the causal history a deployment keeps.

A held record is resident twice: in the state store (so a restart restores it, and the changelog carries it) and in the queue the gate walks, which holds its key and value bytes in memory. Hold depth therefore bounds heap as well as disk, and a lagging cause channel costs both. records-held and the protocol store's size are the two signals, and they move together; there is no depth at which one takes over from the other. Paging the tail of a queue out of memory would be possible — head-of-line blocking means only the head is ever gated — but it is not what this cut does.

Truncation: log-start stability

Stamp-side clocks (carried ancestry and the advertised channel clocks) are the two whose width grows with the node's transitive upstream, and truncation is what bounds them. The required bound must be globally stable — no present or future consumer's gate may still need a dropped entry, because a missing claim at a gate is a causal violation, not a delay. A membership-based protocol (registered nodes publishing frontiers) cannot deliver that bound in Kafka's anonymous-consumer world: a from-earliest late joiner's baseline sits below any frontier minimum, and any gating consumer outside the registry breaks it silently.

The coordination-free source that does qualify is the log-start offset. Records deleted by retention are below every reachable baseline, present or future — the same "below first sighting is out of scope" rule that makes seeding sound — so logStart − 1 per channel is unconditionally stable, and a channel whose topic no longer exists truncates entirely (a recreated topic is a different channel, so an absent topic's claims are unclaimable forever). The driver is truncateToLogStarts: query earliest offsets for stampChannels(), truncate. The Streams adapter runs it on a punctuator (truncationInterval, default ten minutes); a failed sweep skips a cycle rather than failing the task. Truncation therefore advances exactly as fast as retention does — clock width is bounded by the causal history your retention actually keeps.

The Kafka Streams adapter

Stage assembles its sub-topology: sources fetched as raw bytes, one adapter processor, sinks written as raw bytes. Between them, user logic runs as pure functions: a delivery is decoded with the source topic's codecs into a Message carrying its own coordinate (for a record released from the hold queue, its own, not the release trigger's), the handler or fold is applied, and the returned emissions go through the stamping door — encoded with the sink topic's codecs, partitioned deterministically before stamping, undeclared sinks failing loudly. A stateful stage's fold state lives in a second RocksDB store keyed by the message key's encoded bytes, read before and written after each application, in the same transaction as the delivery — causal order plus a pure fold makes the state a deterministic function of the delivered history. Serialization happens exactly once on each side, inside the stage, so the stamp travels with the exact bytes it claims. Stages are immutable: wiring is captured by the topology's node suppliers at assembly, never stored on the stage.

A stage with declared ticks closes a loop through the broker: a wall-clock punctuator emits one stamped record per interval to the stage's own tick topic, routed back to the emitting task's partition, consumed as one of the stage's sources, and gated like any other record — the protocol's own-channel handling makes a self-claim structurally satisfied, so the loop cannot deadlock. Time thereby enters user logic only as data on a delivered record, and the tick's stamp claims the task's consumed history at emission.

Parsley.streams supplies the production wiring: it enforces exactly_once_v2 and read_committed, installs a client supplier whose main consumers record a post-poll view into a thread-local — the consumer position paired with the highest record offset the polls have returned, the positionAdvance feed (position() read on the polling thread is its only sound source; see liveness), and resolves topic identity and broker offset facts through an admin client. The adapter reports a captured position only once every returned record at or below it has been fed through the protocol: a post-poll position runs ahead of records still buffered between poll and process, and reported early it would jump the frontier past them and drop them as replays. The adapter self-checks both at runtime — EOS from the task's configuration at init, captured positions at the first delivered record — so a topology run outside Parsley.streams fails closed instead of wedging. TopologyTestDriver runs without a broker through testTopology(), which assembles a fresh broker-less topology per call.

Stages compose: Parsley.named(applicationId, stageA, stageB, ...) assembles several named stages into one Streams topology, connected through ordinary topics — one stage's sink is another's source, and the connecting topic is a causal channel like any other, stamped on write and gated on read. Hops cost only the broker round trip (stamping is synchronous under sequence claims), custody carries transitively across them, and each stage keeps its own state store keyed by its name. Stage-connecting topics are explicit and must pre-exist — there are no automatic repartition topics, which keeps the co-partitioning precondition visible at each hop. Sender identity derives from the application id, the stage name, and the partition, so it survives topology evolution (task ids embed the sub-topology index, which renumbers when stages are added).

Current adapter limits: non-Parsley headers on held records are not carried through delivery, and own outputs are claimed in sequence space rather than folded from producer acknowledgements (Streams attributes acknowledgements per thread, not per task — see the stamp section).