Runtime
Start
Parsley.start performs the following for each declared process, and returns once each
process's Kafka Streams application has been started — that is, into its first rebalance.
A refusal the bootstrap can see is thrown from the call; one raised inside task
initialisation on the host's threads surfaces through Parsley.status().
- Resolves topic identities through the admin client, and fails where any declared topic, received or sent, is missing.
- Reads the group's committed positions and establishes, for each received partition, the
position Kafka Streams will feed first, which every task takes as its start position at
initialisation. Where prior state exists and a partition's committed position is missing
— expired during a long stop — it pre-commits the ordering state's covered position plus
one, the next position the previous execution would have read. Whether retention still
holds it is decided at the first fetch:
auto.offset.reset=nonerefuses a position below the log start, and the process stops withPOSITIONS_DISCARDED_UNREAD(D115). Without prior state it pre-commits the declared initial position through onelistOffsets, taking earliest as the log start and latest as the end. The commit goes through a generation-fenced group membership rather than an admin alteration, so a stale paused bootstrap cannot overwrite a newer lifetime's positions. This givesauto.offset.reset=nonea defined starting point and establishes the first-receipt baseline. On later starts, existing commits win. - Builds the topology and starts Kafka Streams with
processing.guarantee=exactly_once_v2, from whichisolation.level=read_committedfollows. Before starting, it waits for any other instance's bootstrap member to leave the group, so two instances cold-starting together do not refuse each other's join; a join that still meets one replaces its stream thread and joins again.
Prior state pins the task width. The ordering store's changelog cannot change partition count, so a width-changing declaration refuses with its remedy rather than failing inside the host's internal-topic validation.
A channel that rejoins the received set resumes rather than re-entering delivered past. This
is backed by the fedUpTo record even where group positions have expired. A missing position
alongside prior state re-establishes coverage plus one rather than the declared latest, and
earliest where the ordering state covers nothing on the channel.
Observing a process
Parsley.status() reports each process's lifecycle state and, when it has stopped to
preserve the guarantee, the refusal and its diagnosis. For a running process it also reports
each task's delivery state: what is held, which cause each head is waiting for, and the size
of the causal frontier every emission carries (Failing closed).
The snapshot is taken on the task's thread once per status interval
(ParsleyConfig.statusInterval) and asks nothing of a broker, so the call itself never
touches a stream thread or a broker.
Configuration lockdown
Configuration carrying the guarantee is set by the runtime and cannot be overridden.
ParsleyConfig.Builder.streamsProperty rejects the owned keys, among them
processing.guarantee, isolation.level, auto.offset.reset, group.id and
transactional.id. Neither the Topology nor raw configuration is exposed, so no documented
operation runs the topology without exactly-once semantics.
Identity at task initialisation
Nothing is asked of the broker between deliveries; the periodic round that once gathered
committed positions, log starts and topic identity is gone (D115). A cause names the offset
of a committed record, so receiving that record is what satisfies it, and the positions
between records that yield no message are settled by receipt of the next record on the
channel. The one question a task puts to the substrate is asked at its initialisation —
ParsleyProcessor.init, which Kafka Streams runs on the stream thread inside the rebalance,
at every creation and re-creation of the task.
There, ParsleyProcessor.init asks a TopicIdentitySource — in production
AdminTopicIdentitySource, backed by the admin client — about every topic id the task's state
names: the received topics at the identities resolved at start, and every topic in the
restored frontier. Each id is described by id. One that resolves is alive, and its name is
learned for later initialisations of the process's tasks. One the broker does not know is not
yet dead — a describe denial masks a live topic as unknown by id, and a broker's metadata view
can lag — so it is judged by its last-known name over three answers half a second apart. The
name gone in all three confirms deletion; the name resolving to another id in all three
confirms recreation; a denial, an unavailable answer, or the name resolving to the very id
asked about keeps it alive. A denial is denial, never death. An id whose name was never
learned is never confirmed dead: it lingers in the frontier, costing expression size and
never safety. A describe failure that is not the substrate's unknown-topic answer — a timeout,
an outage, whether by id or by name mid-corroboration — is not evidence about any id: the
initialisation proceeds with a warning, every cause and every hold stays, and the question
stays pending — the status punctuation asks it again until it is answered, backing off from
one status interval to a minute since each attempt can hold the stream thread for the
describes' shared ten-second deadline, and the answer is applied as the initialisation's
would have been. The check is event-driven and eventual, never periodic.
The engine takes the verdicts through ProcessEngine.onIdentityReport. A received channel
whose topic was recreated under its name refuses CHANNEL_IDENTITY_CHANGED: records fed
under the old identity can no longer be trusted. A received channel whose topic was deleted
while messages from it remain held refuses CHANNEL_DELETED_WITH_UNDELIVERED_MESSAGES, since
their place in causal order can no longer be preserved (D46); with nothing held it is settled
to the end of the channel (D21), and a hold waiting on it goes on the next punctuation or
record — nothing is delivered from initialisation itself (D34). Dead and recreated channels
are pruned from the frontier and the delivered past; that is the only pruning, and retention
never prunes. The runtime keeps no verdict windows, no eviction and no rescission.
A received topic deleted and recreated under its name while a process that receives it runs
is SPEC Assumption 17's territory: the recreation is detected at the next task
initialisation, and what was delivered in between is outside the guarantee. As observed on
Kafka Streams 4.3, deleting a received topic does not stop the process at once: the next
transactional commit times out, the host re-creates the task, and its initialisation runs the
identity check, which refuses. A rebalance that finds a source topic missing instead stops the
stream thread with the host's MissingSourceTopicException, which the runtime names
SOURCE_TOPIC_MISSING — a transient, with no refusalReason — and a recreation the first
fetch meets refuses under auto.offset.reset=none. A recreation that lands entirely between
two polls, the new log already past the old position, is met by neither: it is detected at
the next task initialisation and not before, which is what the assumption signs away. Its log line says to restart;
the start then refuses CHANNEL_IDENTITY_CHANGED for a recreated topic, refuses a
still-missing one at resolution, and resumes where a broker's metadata merely lagged.
The status punctuation
Each task schedules one wall-clock punctuation, every ParsleyConfig.statusInterval — one
second by default; the builder refuses a null, non-positive or sub-millisecond value. It
drains what receipt, or the initialisation's identity report, already released; flushes holds
to the ordering store, so a message held at the moment of a crash is still held after the
restart (D102); observes the frontier for the once-only warning at 80% of the metadata budget
(D53); and publishes the task's TaskStatus for status() (D103). It touches no broker and
ingests nothing.
The punctuation delivers nothing that was not received from a channel, and the decision it triggers remains the pure function described in Delivery. Time never appears among that function's inputs. The public API offers no timers and no scheduled callbacks.
Zombie safety
Any delivery a superseded execution performs on stale state sits in a transaction the host fences and aborts, so it has not occurred.
The seam
Delivery carries the delivered message. StateReader is a read-only typed view over the
process's declared stores. Effects return through the return value: typed sends, statically
typed per channel, and state writes.
The logic receives no other capability. Sending never blocks on the deliverability of the message sent. Emissions are stamped with the current frontier and forwarded within the step. An emission carries the delivered message's timestamp unless the handler gives it one of its own; time-based retention and downstream event-time windows read that timestamp, so a message emitted long after the one it answers — the release at the end of a compensation chain — may want its own, derived from delivered data rather than a clock, since a handler may run again for the same message and must return the same effects.
Keys, partitions and state
A task is partition p of every topic its process receives, and it owns partition p of every store the process declares. A read or write through the seam reaches that shard and no other, so a key is found only if the delivering topic was keyed so that the same partitioner put it on p. Two topics received by one process must therefore be partitioned alike where their keys are meant to meet, and producers outside Parsley must partition by the same rule — Kafka's default, unless every writer agrees on another. To keep state about a different attribute than the delivered key, emit a message keyed by that attribute to a topic this or another process receives; a channel a process both sends to and receives from is a repartition. Received topics may have unequal partition counts; a task beyond a topic's width receives nothing from it.
A handler that throws fails its step. The process stops, and on restart is fed the same message and fails again: Parsley never skips a message. To continue past an application failure, catch it and return effects that record it deterministically — an emission to a declared dead-letter channel, or a state write — rather than throwing.