Skip to content

Tolerate a stale-generation fence instead of failing the flow [DEFERRED] - #935

Closed
tobiajo wants to merge 15 commits into
evolution-gaming:masterfrom
tobiajo:tj/tolerate-generation-fence
Closed

tobiajo wants to merge 15 commits into
evolution-gaming:masterfrom
tobiajo:tj/tolerate-generation-fence

Conversation

@tobiajo

@tobiajo tobiajo commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

In transactional snapshot mode (cachingTransactional) a stale-generation rejection of the offset commit (ILLEGAL_GENERATION, surfaced as CommitFailedException) failed the flow, on both the persist path and the periodic commit path. The rejection already is the fence: the transaction aborted, nothing landed, the producer stays usable after the abort, and the consumer refreshes its generation once it completes the rebalance. Failing the flow only adds a leave and a rejoin, each of which bumps the generation and fences the peers' in-flight transactions; under CooperativeStickyAssignor retained partitions keep committing while a rebalance completes, so a rolling deploy becomes a restart cascade.

  • KafkaSnapshotWriteDatabase.GroupCommit.commitBatch classifies the rejection once, as the new GenerationFencedError (a core type; core still imports nothing from kafka-clients).
  • Persist path (persistPeriodically, persistPeriodicallyAndUnloadOrphaned, AdditionalStatePersist): warn, the key stays dirty and keeps holding its offset, regardless of ignorePersistErrors. The timer flow retries on its next tick; an additional persist leaves the state to the next persist. unloadOrphaned and the revoke-time flush are unchanged. persistPeriodicallyAndUnloadOrphaned must not unload a key whose persist was tolerated, so this PR also gates the unload on the persist outcome; that is the same change as Unload a key only after its state was persisted #934, which fixes it for ignorePersistErrors on its own. Whichever merges second rebases trivially.
  • Periodic commit path (PartitionFlow): committedOffset advances only after a successful schedule; a fenced commit is warned and the offset is scheduled again on the next tick, or committed sooner by the next snapshot write.
  • Every other error is handled as before. Eviction (UNKNOWN_MEMBER_ID) surfaces as the same exception and is tolerated too: nothing lands, and the consumer's next rejoin reports it as onPartitionsLost, which tears the flows down.
  • The docs and the integration test that described the crash as the fence now describe the retry; the integration test also shows the retried flush landing on the same producer once the generation is current again.

Fixes #938.

`persistPeriodicallyAndUnloadOrphaned` removed a key whenever the unload
threshold was crossed, whether or not the persist that preceded it went
through. With `ignorePersistErrors` on, a failed persist is logged and
the key is unloaded anyway; `KeyContext.remove` also drops the offset it
held, so the partition can commit past state that never reached the
store and the next recovery skips those events.

Gate the unload on the persist outcome: an unpersisted key stays loaded
and keeps holding its offset until a later tick persists it.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tobiajo
tobiajo force-pushed the tj/tolerate-generation-fence branch from 75d566a to c40f8a8 Compare September 8, 2026 08:17
@tobiajo tobiajo changed the title Tolerate a stale-generation fence instead of failing the flow Tolerate a stale-generation fence instead of failing the flow [NOT READY] Sep 8, 2026
@tobiajo
tobiajo marked this pull request as draft September 8, 2026 09:45
@tobiajo

tobiajo commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Discovered an issue with this change. Will iterate on it with better testing before undrafting.

@tobiajo

tobiajo commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

An alternative to this change is to simply discourage CooperativeStickyAssignor, as it is the only assignor with this issue: eager assignors tear every flow down before the generation bumps, at the price of a full re-recovery of every partition on every rebalance. The consumer rebalance protocol (KIP-848) with the upcoming KIP-1251 is expected to not have issues with spurious fencing.

In transactional snapshot mode the broker rejects a transactional offset
commit whose consumer generation is stale (ILLEGAL_GENERATION, raised by
kafka-clients as CommitFailedException), which aborts the transaction
carrying the snapshot write. The flow failed on it, on both the persist
path and the periodic commit path, and the code called that failure the
fence.

The rejection already is the fence: the transaction aborted, nothing
landed, the producer stays usable after the abort, and the consumer
refreshes its generation once it completes the rebalance. Failing the
flow adds a group leave and a rejoin, each bumping the generation and
fencing every peer with a transaction in flight. Under the cooperative
assignor a still-valid owner is fenced routinely, because retained
partitions keep committing while a rebalance completes, so a rolling
deploy turns into a restart cascade across the group.

`GroupCommit.commitBatch` classifies the rejection once, as
`GenerationFencedError`. The persist path warns and keeps the key dirty
and holding its offset; the periodic commit path warns and leaves the
offset uncommitted, advancing `committedOffset` only after a successful
schedule so the same offset is retried on the next tick. Every other
error is handled as before. The docs and the integration test that
described the crash as the fence now describe the retry.
evolution-gaming#935 tolerates the stale-generation fence on the two waiters that write
state - the periodic persist and the periodic offset commit - but not on
the third: the tombstone `TickToState` and `FoldToState` write when a tick
or a fold empties a key's state. In preprod that waiter took 3661 of the
14945 fences tolerated across six rollouts on 2026-09-08, 30-57% of each
rollout's; every one of them would otherwise have failed the flow and
fenced the peers in turn.

`persistence.delete` is a transaction like any other write here: the
rejection aborted it, so no tombstone landed and the key has to stay.
Removing it would drop its held offset too, and let the partition commit
past a snapshot that is still in the store. The key is kept, with the
empty state the tick or fold gave it, and deleted again on the next tick.
Every other delete error still fails the flow.

Reaching the error channel needs `MonadThrow`, so the tolerant path comes
as new overloads taking `remove` - the effect that takes the key out of
the partition once the tombstone lands - and the existing constructors
keep their `Monad` bound and their behaviour. `KeyFlow.of` and
`KeyFlowOf.apply` do widen, since they build that path: source-compatible
(every effect they are used with satisfies `MonadThrow` already), binary-
incompatible for a caller that does not recompile, hence the two MiMa
filters. Drop those if this ships as a major instead.

A key left with an empty state by a tolerated fence must keep its timers,
or the next tick never comes to retry the delete; that is the next commit.

Issue evolution-gaming#938.
A key whose tombstone the previous commit keeps through a fence is left
with an empty state, and `KeyFlow` cancelled a key's timers on exactly
that. The key then held its offset with no timer to ever retry the
delete: in preprod on 2026-09-08 the committed offset froze on 21 of 32
partitions of one service, precisely those whose delete had been fenced,
and lag grew at the input rate until rollback.

The rule the cancellation wanted is "the key is gone", so track that:
`KeyFlow` owns a `removed` flag, set by the remove effect it hands to
`FoldToState` and `TickToState`, and cancels on it. The protection is
intact - once removed, the timer flow is skipped, so a dropped key is
never flushed - and an emptied key keeps ticking until its tombstone
lands.

Two orderings the retry rests on, now written down:

- a `TickOption` maps an empty state to an empty state, so the next tick
  of a key waiting for its tombstone asks for the delete again instead of
  resurrecting it;
- within one `onTimer` the tick runs before the timer flow, so the
  re-attempted delete marks the key persisted before the periodic persist
  could flush the emptied buffer and hold an offset ahead of the tombstone.

`PartitionFlowSpec` drives the real chain for this - `PartitionFlow`, the
periodic timer flow, memory timers, an eviction tick, a snapshot database
that fences the first tombstone - because the unit tests cannot see the
pin: `Timers.trigger` runs `KeyFlow.onTimer` only for a registered timer,
and the timer flow is what registers the next one, which is the link the
old rule cut. It asserts the tombstone did not land, that the next tick
deletes again, and that the partition then commits past the key; under
the old rule the second delete never comes.

Issue evolution-gaming#938.
evolution-gaming#935 matched `CommitFailedException` anywhere in the cause chain, which
also catches `KafkaException("Cannot execute transactional method because
we are in an error state", cause = CommitFailedException)`. That is not
this transaction being fenced. Read against kafka-clients 4.3.1: the
fence is raised unwrapped by `sendOffsetsToTransaction`, from the single
site mapping ILLEGAL_GENERATION and UNKNOWN_MEMBER_ID, and as an
abortable error, so `commitBatch`'s abort clears it and the next
transaction opens on the same producer - exactly what tolerating it
assumes. The wrapped shape instead says an earlier fence is still
recorded because nothing aborted it, and one reachable variant of that -
a `commitTransaction` that timed out, leaving an unacked pending
transition the abort can no longer get past - fails every future
transaction identically. Tolerated without bound, that is a stall logged
as a fence; failing the flow re-creates the producer, which is the only
recovery.

So the classification is now the top-level type, and the chain walk goes.
All 47 fences traced in preprod arrived bare, and the broker-level tests
in `TransactionalKafkaPersistenceSpec` see the same shape.
`GroupCommitSpec` pins both: the bare fence classified, the wrapped one
surfacing as it is.

Issue evolution-gaming#938.
Tolerating the fence removes the only signal there was: before, a fenced
flow failed and restarted, which every monitor sees. Now it warns and
retries, and the WARN lines - about 580 per rolling deploy of one preprod
service - are not something to alert on. So count them:
`snapshot_write_fenced_total{topic,partition}`, incremented in
`commitBatch`, where the fence is the shared outcome of the transaction.
Counting at the write instead would multiply each fence by the batch size,
which is why this is not a `SnapshotDatabase` wrapper like the other
snapshot metrics but a small `SnapshotWriteMetrics` the writer holds.

`FlowMetrics.snapshotWriteMetrics` comes with a default body returning the
empty instance, and `transactional` takes the metrics through a new
overload, so neither addition breaks an implementation or a caller. The
partition label is the input partition, as in the other snapshot metrics.

A rate above zero outside a rebalance window, or one that does not
subside, is the alerting signal: the fence is only harmless while the
consumer keeps completing rebalances.

Issue evolution-gaming#938.
evolution-gaming#935 tolerates a fence for as long as it keeps arriving, which invites the
question of a bound - the variant this PR did not take carried a
one-minute `fenceTolerance`. Measured instead of guessed: six rolling
deploys of two services against one preprod cluster (classic protocol,
cooperative-sticky) tolerated 14945 fences, arriving in 42 per-member
bursts across 28 members (a gap over 10 s starting a new burst) with a
median of 1.1 s, a p90 of 3.9 s and a maximum of 20.6 s; none reached
30 s. A local harness with a third member joining and leaving in a loop
put 793 of its 825 fences inside one 3.5 s window.

Nothing there wants a bound. The consumer's own rejoin ends every run -
a lagging member on completing its in-flight round, an evicted one via
`onPartitionsLost` - so a bound could only fire on runs the consumer is
about to end anyway; and a plausible-looking one (a minute) would fire
during the longest rebalances, where a peer is in eager recovery, which
is exactly when restarting the storm is worst.

The case a bound is imagined for - polls succeeding while the generation
never becomes valid - is better detected by the committed offset not
advancing while the input moves, an alert that also covers a pinned key,
a stalled fold and a wedged producer, with
`snapshot_write_fenced_total` to attribute it. Written up under
Tolerating the fence, with the bound listed among the rejected
alternatives.

Issue evolution-gaming#938.
Tolerating the fenced delete opened a hole the crash used to close.
`Snapshots.delete` emptied the buffer before calling the database, so
with the tombstone still owed any `flush` of that key succeeded on an
empty buffer - and `attemptToPersist` reads that as persisted and holds
the key's offset. From there `flushOnRevoke = true`, `unloadOrphaned` or
a `canUnload` tick removes the key outright, releasing the held offset,
and the partition commits past a snapshot that is still in the store: on
the tick path the eviction is merely deferred, but on the fold path the
recovered state is one the fold had purged.

So the buffer is emptied only once the delete lands, and while it is owed
the key is marked tombstone-pending: `flush` then retries the delete
instead of reporting success. A fenced retry surfaces as the same
`GenerationFencedError` the persist path already tolerates, so the key
stays loaded, holds its offset and is not unloaded. A state appended
before the tombstone lands drops the mark - the key is not going away
after all, and the next flush writes the new snapshot over the one the
delete did not remove; without that the retry would delete a key that had
come back. Nothing else changes: the mark is only ever set by a delete
that did not go through, and every non-fence delete error still fails the
flow.

This also makes the tick-before-timer-flow ordering belt-and-braces
rather than load-bearing: a periodic persist that now runs on an emptied
buffer retries the tombstone instead of vacuously advancing the offset.

Issue evolution-gaming#938.
The transactional suites so far inject a stale generation into a single
flow. That covers the coordinator's validation, not what the tolerance is
for: several members alive at once, made stale by rebalances they did not
choose. Two suites now do that, and one gap in the injected set is closed.

`FenceStormSpec` runs two instances in one group under cooperative-sticky
with transactional snapshots, continuous input and a third member joining
and leaving in a loop. It asserts no flow failure, no give-up, no restart,
at least one fence actually provoked (a run that provoked none is
inconclusive, not a pass), a tombstone written, every partition's
committed offset still advancing after the churn, and the flows draining
to the end offsets - the oracle the pinned partitions of 2026-09-08 would
have tripped. Correctness is then checked against the store, not the
flows: the input is replayed from the committed offsets on top of the
`read_committed` snapshots and must fold to the same state per key, with
keys that never close so a lost record cannot hide behind a tombstone.
Locally: 183 s, 747 fences across the persist, delete and offset-commit
waiters, 3918 tombstones, zero retries.

`EvictionFenceSpec` covers the other rejection, `UNKNOWN_MEMBER_ID`. A
member stalls inside its fold, which runs on the poll loop's thread of
control, so the coordinator drops it past `max.poll.interval.ms` while it
keeps its flows, its state and its producer; a second member takes the
partition over for real and persists. The evicted member then tolerates
the rejection rather than failing, nothing of its write lands, and its
next poll completes the rejoin that reports the partition lost and tears
its flows down - it ends up in the group owning nothing while the new
owner keeps committing. The two use separate transactional ids, or the
takeover's `initTransactions` would epoch-fence the stale producer before
its write ever reached the offset commit.

`TransactionalKafkaPersistenceSpec` gains the fenced **tombstone** the
injected set was missing: the key keeps its snapshot through the fence and
the next tick deletes it for real.

Both new suites are paced by real coordinator timeouts and each brings up
its own broker, so the module now runs its tests one at a time: beside
four peers the churn suite took 38 minutes, alone it takes 3, and the
module as a whole 6.
Two gaps the tolerance leaves in the docs.

Which assignor pays the spurious fence at all: classic **eager** never
sees one - it revokes the whole assignment in `onJoinPrepare`, before the
generation bumps, so no flow is alive to carry a stale token - and pays
for that with a full re-recovery on every rebalance. Classic
**cooperative** keeps its retained partitions folding, flushing and
committing straight through the round, which is where the fence comes
from, and the classic protocol has no broker-side absorption of it:
KIP-1251 covers the consumer protocol only, so `group.protocol=consumer`
on brokers that carry it is the protocol-level exit and tolerance is what
makes cooperative-sticky stable until then.

And what tolerance costs, next to what it buys: an aborted transaction
per fence, a WARN per fenced waiter (some 580 per rolling deploy of one
preprod service - log volume, not an alerting signal, which is what
`snapshot_write_fenced_total` is for), a key held loaded with its offset
until its tombstone lands, and a committed offset that lags by the ticks
the generation takes to become current. The alert to keep is the
committed offset not advancing while the input moves - the one symptom a
fence that never clears shares with a pinned key and a stalled fold.
The third timer flow was left out of the tolerance. `unloadOrphaned`
called `persistence.flush` directly and removed the key straight after,
so any error - the stale-generation fence included - failed the flow,
and the fence was reachable there: the previous commit turned a `flush`
that used to succeed vacuously on an already-emptied buffer into a real
delete retry, which is exactly the write the broker rejects.

It now goes through the same `attemptToPersist` as the other two flows,
with `ignorePersistErrors = false` so every non-fence error still fails
the flow as before. A fenced persist keeps the key loaded and holding
its offset, and re-registers the timer so the next tick retries the
delete; without that re-registration the key is pinned with no timer to
ever come back to it, which is the freeze the previous commit fixed on
the `KeyFlow` side.

`TimerFlowOfSpec` covers both halves against a flush fenced once: the
first tick does not fail and does not remove the key, and the second
`trigger` fires at all - `Timers.trigger` runs `onTimer` only for a
registered timer - so the retry lands and only then is the key unloaded
and its offset released.

The revoke-time flush is now the only path that does not tolerate the
fence, which the docs said of `unloadOrphaned` too.

Issue evolution-gaming#938.
`persistence-kafka-it-tests` is in the root aggregate and the shared
Evolution CI workflow runs `sbt test` at the root, so the churn suite
would run on every pull request, on a hosted runner. It is not shaped
for that: it spends minutes racing real rebalances against a broker it
brings up itself, and its `assert(fences > 0)` fails a run that provoked
none - correct when the point of the run is to provoke them, a flake
when it is one suite among many on a machine that may not go fast
enough. Locally four runs provoked 747, 26, 23 and 16.

So it takes the gate `TransactionalWriteThroughputSpec` already uses,
`munitIgnore` behind an env var, with the command in the comment. The
assertions are unchanged: run it and it still asserts everything it did.

`EvictionFenceSpec` stays in the default run. It also asserts a fence
was provoked, but it does not race for one: it stalls the fold until the
coordinator drops the member, and every step waits on the broker's own
verdict through `describeConsumerGroups`, so the fence is forced rather
than hoped for and a run that cannot get there fails on a named timeout
instead of a bare count.

Issue evolution-gaming#938.
The suite carried a downstream application's retry policy - exponential
with jitter, a backoff budget, an attempt limit, and a `resetOnQuiet`
wrapper reimplementing the budget reset - to run the flows under. None
of it was exercised: the spec asserts the retry list is empty, so with
the tolerance in place no attempt ever fires, and the whole thing was
40 lines of untested reimplementation standing between the fence and
the assertion.

It now runs on `Retry.empty`, as `EvictionFenceSpec` already does, and
the supervisor that was there to restart a flow that gave up records the
failure that killed it. The assertion that no fence failed a flow is
unchanged in what it catches - a failure now reaches the supervisor
directly instead of a retry's `onError` first, and it still carries the
cause chain into the clue - and one assertion covers what three did.

Nothing is lost with it: no retry, no give-up and no restart were three
readings of the same fact, that the flow failed at all.

Issue evolution-gaming#938.
The "Monitoring the fence" bullet went in in the middle of the previous
one, so the recovery deadline's sizing advice ended up under it, where
"the value" has no referent - the fence bullet names no value to size.
Move it back to the `recoveryStallTimeout` bullet it was written for;
the fence bullet ends at the symptom to alert on.
`versionPolicyIntention` still said `BinaryCompatible` while two MiMa
filters suppressed genuine public breaks, which is the check asserting
the opposite of what the release does. `KeyFlow.of` and `KeyFlowOf.apply`
did change signature: 3bd12ad widened `Monad` to `MonadThrow` on all of
them, and 1bc9fc6 added `Ref.Make` to the two `storage`-taking `of`
overloads, which had neither before - so the build.sbt comment's "the
constraint is the only change" understated it by a whole constraint on
two of the five methods. Source-compatible either way, since every
effect these are used with satisfies both, but not binary-compatible,
and that is what the check is for.

So the intention is `Compatibility.None` for this release and the two
filters go. The repo's precedent is to set it in build.sbt for the
release and revert right after (8081ea0, then ea43339 nine minutes
later), which the comment now records.

The two remaining filters stay: `Snapshots.apply` and
`GroupCommit.this` are package-private and a nested class' constructor,
members no caller can reach, and neither is a break to declare - they
are noise MiMa emits from the bytecode, not from the API.
@tobiajo
tobiajo force-pushed the tj/tolerate-generation-fence branch from c40f8a8 to 79ab793 Compare September 9, 2026 19:40
@tobiajo tobiajo changed the title Tolerate a stale-generation fence instead of failing the flow [NOT READY] Tolerate a stale-generation fence instead of failing the flow [DEFERRED] Sep 9, 2026
@tobiajo tobiajo closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transactional snapshot mode fails the flow on a spurious generation fence under CooperativeStickyAssignor

1 participant