Skip to content

fix(core) confluentinc#803: a revocation declines the transaction lock instead of waiting on it - #408

Draft
astubbs wants to merge 349 commits into
docs/225-producer-fencing-brainstormfrom
fix/803-bound-transactional-revoke-wait
Draft

astubbs wants to merge 349 commits into
docs/225-producer-fencing-brainstormfrom
fix/803-bound-transactional-revoke-wait

Conversation

@astubbs

@astubbs astubbs commented Sep 1, 2026 •

Copy link
Copy Markdown
Owner

Closes #44 (confluentinc#803).

depends on #262
depends on #410

Stacked on #410 (recoverable producer fencing, #225), decided 2026-09-02. That PR is the plan and the implementation, and it settled the one design question this PR had left open. Its branch is merged in here and is this PR's base; the reconciliation its plan asked of this branch is done and listed below. When #410 merges and its branch is deleted, GitHub retargets this PR to master on its own.

The design question, and how #410 settled it

Decline was chosen here over bounding the lock holder because deadlining the holder meant aborting a transaction while ProducerFencedException was fatal. #410 removes that objection - and removes the reason to want a wait at all. Its plan's KTD11 makes the revoke commit a third detection site for a recoverable condition, recorded and declined on the poll thread, and states that once recovery bounds the write-locked region to abort, close, drain and replay, neither a bounded wait nor deadlining the holder is needed for liveness. Both stay viable and both are deliberately not taken. What is left is a trade - a bounded wait buys fewer redeliveries on a transactional rebalance, at the price of an ArchUnit exemption with a stated bound, with the starvation measurement below still standing against it - and decline stays.

RebalanceEoSDeadlockTest is amended accordingly. It asserted that the revoked partitions' committed offsets had advanced by the time the callback returned, which held on master because of the unbounded spin: the callback waited the control commit out. It now asserts what the confluentinc#541 guard needs plus the new contract - the callback returns inside a quarter of the dwell it was timed into, and the window was resolved rather than skipped (declined, counted with the probe's instrument now shared in DeclineCountingProducerManager, or committed inline if the dwell had already ended). The forced overlap and its vacuity guard are unchanged, as are survival and liveness. Evidence in the table below, including the negative control.

Reconciliation with #410, done here (its plan's KTD11)

  • The ProducerFencedException | InvalidProducerEpochException rethrow in tryCommitOffsetsOnRevoke is deleted: on the PC-built path the commit path converts both into ProducerInvalidatedException, which the generic catch logs and the control thread recovers from on its next pass, and the deprecated producer-instance path keeps master's behaviour, which that PR defers by design (R19). A correction to this PR's own earlier account: commit afd7874 added that rethrow on the claim that master failed the instance loudly at this site. It did not. Master's revoke catch was already the generic WARN and master's commitTransaction threw raw into it, so deleting the rethrow restores master exactly. Found by the review's validator; recorded in docs/inflight/bug-857-transactional-revoke-wait.md.
  • The reconciliation was half true until a test was written for it. Record-and-decline held; "the control thread recovers on its next pass" did not, because the revoke path produces no mailbox event and nothing woke the control loop - it sat out the rest of its commit-interval wait with every worker parked on the produce lock. Invisible at the 100ms transactional default; an hour at an hour. The revoke path now calls notifySomethingToDo() after releasing both locks when the manager reports it is replacing. Pinned by a new unit test in ProducerRecoveryTest that drives onPartitionsRevoked against a PC-built producer fenced for that commit only, and that failed on exactly that wait before the call existed. Negative control: with the deleted rethrow restored, the fence escapes the callback and the test errors on it.
  • ProducerManager merged clean; the three lock helpers coexist with recovery's beginReplacement, as that PR's own reconciliation note predicted. The two test managers that subclass it now build with the replacement source, the way PCModule does.
  • On the merged tree: ArchitectureTest and the test-convention rules green, ProducerFencingRecoveryIT 1/1, the amended RebalanceEoSDeadlockTest 5/5 at 32-34ms with one decline each, the probe's defect arm 5/5.

A trap for whoever ever re-argues the bounded wait: the ArchUnit widening here adds Lock.tryLock(long, TimeUnit) and its siblings to BLOCKING_CALLS, so a bounded-wait revoke would trip the rule. The rule cannot distinguish a 2s bound from the 5-minute one that caused confluentinc#803, so that site would need an explicit exemption with a "bounded well under max.poll.interval.ms" justification, or the rule a way to see the bound.

STATE AFTER #466, 2026-09-08. Master merged in (merge commit on this branch). #466 moved the transactional revocation commit off the poll thread: onPartitionsRevoked now posts a request, the control thread runs the commit, and the callback waits bounded by commitLockAcquisitionTimeout (5 min default); the confluentinc#548 spin is gone; and it refuted this PR's decline by experiment (a revoke that commits nothing defers a duplicate). Master's re-premising of docs/inflight/bug-857-transactional-revoke-wait.md states what is left for this PR: the bound is five minutes, and confluentinc#803's complaint is precisely that such a bound exceeds max.poll.interval.ms.

What this PR now is, and what it is not:

  • Still open and this PR's: bound the delegated revoke wait to something under max.poll.interval.ms, with the timeout fallback fix(core): the revoke-path commit drains first, by handing itself to the control thread #466 already has (decline at WARN, the deferred-duplicate cost named). PC cannot read the consumer's max.poll.interval.ms, so the bound needs an option or a derivation - the owner's call. Revoke857TransactionalWaitProbeIT is the acceptance test and is red on this head, 5/5: the callback waits out a 20s in-flight commit (19.2s) against a 10s poll-interval budget - and never the 79s starvation the spin produced.
  • Superseded, resolved to master: the poll-thread decline, the RebalanceEoSDeadlockTest amendment, the fencing-rethrow deletion and its wake (the served commit runs on the control thread, which recovers itself), and the ArchUnit widening (test(core): the rebalance-callback rule sees method references and a declared contract #465 rewrote the rule; the interface-hop blind spot is recorded as still open in docs/inflight/static-archunit-walk-was-blind-through-interface-hops.md).
  • Dead main code held for the owner's call: the three ProducerManager revocation lock helpers, their five unit tests, and the DeclineCountingProducerManager test instrument - they count a decline the transactional path no longer makes.
  • Survives unchanged: RemovedPartitionState.onOffsetCommitSuccess, the docs corrections, the probe (re-purposed as above), the retargeted fence-on-served-revoke test in ProducerRecoveryTest.

RECONCILED AND CURRENT, 2026-09-08. Master merged again (#469, #481, #482, #485). Nothing about the design moved: #466's delegated commit stands, and this branch carries it. What changed is that the branch now builds, passes, and is green on every gate it can be green on.

The title is stale and should be changed before merge. It still says "a revocation declines the transaction lock instead of waiting on it", which is the design #466 refuted and this branch no longer implements. On squash-merge that title becomes the commit subject and feeds the release notes. Something like fix(core) confluentinc#803: measure the delegated revoke wait against max.poll.interval.ms describes what is actually here.

What the second master merge cost, and it is worth reading before the next one. PartitionState conflicted where #469 replaced the two booleans with a version counter and #410's uncommitted-completions ledger snapshots at the same point; resolved by taking master's counter whole and moving snapshotForCommit() inside its branch, still ahead of the offer. Then a silent artefact the conflict markers never showed: restoreCompletedButUncommittedWork() still called setDirty(), which #469 deleted, and git merged both sides clean because neither had touched the other's lines. Only the compiler found it. It is recordCompletion() now.

Reds on this head, and what each one is:

Where the open question is written down. docs/inflight/bug-857-transactional-revoke-wait.md gains "The measurement, and what it does not settle": the 19.2s result; what #466 genuinely did fix (starvation across successive transactions - the old spin took 79s out of the same 20s dwell, because a 1s commit interval let the control thread re-take the lock as fast as it dropped it); why declining unconditionally is not the answer; and that choosing a bound needs an option or a derivation PC cannot read off the consumer, which is the owner's call.

The description below is the history of the decline design and is kept as the record of what was tried and why; it no longer describes the code on this branch.

Description

The revoke path declines the producer transaction lock instead of waiting on it.

onPartitionsRevoked held the poll thread in two waits with no deadline, and every tracking note named only the first:

  1. while (isTransactionCommittingInProgress()) Thread.sleep(100), arriving with Fix deadlock between pc-control and pc-broker-poll threads where partitions are revoked  confluentinc/parallel-consumer#548, which spins for as long as any thread holds the producer transaction lock; and
  2. the commit beneath it, whose acquisition waits up to commitLockAcquisitionTimeout — five minutes by default.

The second is the one the user's report actually threw from. Its stack trace is TimeoutException: Timeout getting commit lock (which was set to PT5M) raised inside onPartitionsRevoked; that message has exactly one throw site; and the reported timeline matches it exactly — rebalance at 0, eviction at max.poll.interval.ms (180s), the throw at commitLockAcquisitionTimeout (300s). Fixing only the spin would have left the report reproducible, so the plan of record — "delete the ArchUnit exemption and the rule goes green" — was not a sufficient acceptance criterion on its own.

Why decline, and not deadline the holder

The standing candidate for weeks was to bound the control thread that owns the transaction. Two independent reasons killed it, and the first is measured rather than argued:

  • A per-transaction bound cannot work. The probe's defect arm held the poll thread 79s from a dwell of only 20s — with a 1s commit interval the control thread re-takes the lock as fast as it drops it, so the waiter is starved across successive transactions.
  • It trades a stall for a crash. Deadlining the holder means aborting a transaction it owns, and ProducerFencedException is still fatal (Producer fencing kills the instance; it should abort and rejoin, like Kafka Streams' TaskMigratedException #225). Declining performs no transaction operation at all, so that issue is not on its critical path.

Declining costs a replay: offsets stay dirty and travel to the new assignee. That is at-least-once doing its ordinary job — already the published contract for the consumer lane, and what Kafka Streams falls back to (closeDirtyAndRevive) when its own inline revoke commit exhausts max.block.ms. Streams can commit inline because one StreamThread owns consumer and producer; PC cannot without confluentinc#200 (#142), which stays open as the successor.

Stacked on #29, and why the two had to be unified

Both branches independently wrote a tryCommitOffsetsOnRevoke, for different locks, and they are complementary rather than alternatives:

Neither subsumes the other. The control thread takes the producer write lock in maybeAcquireCommitLock() before it takes commitLock, so there is a window where commitLock.tryLock() succeeds and the transaction lock is already gone — and proceeding into it lands on exactly the five-minute wait this removes. Order is load-bearing: commitLock first because this method releases it itself; the transaction lock second because it is handed to the commit path, whose postCommit() releases it in a finally.

The ArchUnit rule is repaired, and only then does removing the exemption mean anything

The Thread.sleep entry in KNOWN_BLOCKING_VIOLATIONS — the acceptance criterion #29 wrote for this issue — is deleted. On its own that would have been a false pass: BLOCKING_CALLS listed only unbounded primitives, while the call confluentinc#803 threw from is a timed acquire, and the rule's own message ("Decline (tryLock) rather than wait") reads as if tryLock were the cure. Timed overloads are now denied; the no-arg tryLock(), which is the cure, deliberately is not.

One ArchUnit defect remains open and is recorded rather than fixed: the transitive walk resolves the declared member, so a call through an interface-typed field never descends into the implementation (committer is declared OffsetCommitter). That did not hide this defect — the revoke path reaches ProducerManager through a concrete reference — but the blind spot is real and unmeasured.

Evidence

Both arms, five repetitions each, shared broker, -Dpc.log.level=info:

arm before after
defect (20s dwell) 5/5 fail, callback 79,394ms vs 10,000ms budget 5/5 pass, callback 10–11ms, 2 revocations declined
control (2s dwell) 5/5 pass 5/5 pass
RebalanceEoSDeadlockTest, amended (4s forced dwell) 5/5 pass because of the spin, callback ~4,000ms 5/5 pass, callback 43ms, 1 decline each; negative control: spin restored in main, 5/5 fail on the new assertion at 4,028ms
ProducerRecoveryTest, fence on the revoke-path commit (PC-built producer) n/a - no such test; the trace was asserted in prose recorded, declined inside 1s, no stranded lock, replacement built once the control thread was woken; 3/3 in a row. Before the wake call: timed out waiting for the replacement, control thread asleep on its interval

The decline count is what makes the green mean anything: a fixed callback returns in milliseconds because it declined, otherwise indistinguishable from a run where no commit was ever in flight — the trap that left the sibling fix looking unproven for four months.

Also on the merged tree (#410's branch, which carries master as of #417): core unit suite 662 run / 2 failures, both the pre-existing AmbientProbeExtensionTest headroom flake tracked in docs/inflight/test-ambient-probe-headroom-red-on-master.md and not in any file this PR touches; ArchitectureTest green with the exemption removed; bin/check-all.sh clean. Three unit tests pin the seam in seconds rather than a three-minute broker run, and each was verified by negative control — breaking the decline makes two of them fail on their own messages; restoring the five-minute acquire turns the ArchUnit rule red.

Housekeeping for the merger

  • origin/fix/857-bound-transactional-revoke-wait should be deleted when this merges. It is the docs-only predecessor of this branch, superseded entirely; this branch was renamed to fix/803-... because Paused consumption across multiple consumers confluentinc/parallel-consumer#857 is the other defect, in the mutually exclusive commit mode, and the branch name was the last place still conflating them.
  • fix/bound-revoke-transaction-wait (the empty branch the notes mention) no longer exists locally or on the remote — nothing to do.

A factual correction carried in here

#44 is not "the only issue upstream ever labelled a verified bug" — a couple of dozen carry that label. The claim had propagated into several notes, a probe javadoc and one of this branch's own commit messages; it is corrected everywhere except that commit message, which cannot be changed in place.

Checklist

  • Docs updated
  • User-facing feature documentation data added under docs/features/ — rebalance-behaviour.yaml's transactional boundary no longer says the wait is unbounded
  • Tests added/updated — Revoke857TransactionalWaitProbeIT is the acceptance test for the bound and is red on purpose, with its javadoc saying so at the point of use; the unit tests for the superseded decline seam are kept and named as dead-but-held below
  • N/A - a pr- note would duplicate what already exists: docs/inflight/bug-857-transactional-revoke-wait.md owns the defect, the measurement and the open decision, and is updated in this PR rather than copied
  • Title & body reflect the final content of this PR — the body does; the title does not. Left unchecked deliberately rather than waived: see the title note above, and change it before squash-merge
  • Ran ce-simplify and ce-code-review locally, and acted on both

Second pass, 2026-09-02, after the master and #410 merges (ce-simplify three reviewers, ce-code-review six reviewers plus an independent validator): the probe's module now extends the shared counting module instead of re-wiring it; a P1 claiming the deleted rethrow regressed the instance path was invalidated by the validator against master's code (see the correction above); the surviving P1, that nothing tested the revoke-path fence trace, produced the test above and the wake-up fix it found. Residual risks the review recorded and this PR does not act on: the deadlock test's clock starts after a baseline admin read inside the dwell, so a read slower than ~3s would let a restored spin pass the bound (measured reads are far faster); the decline counter is cumulative per manager; and no test exercises the revoke path's acquire-and-commit-inline arm on a broker.

What the local passes changed, since a tick alone says nothing. ce-simplify (three reviewers) found the duplicated commit body and the unused Truth subject, and its efficiency pass cleared the hot path. ce-code-review (eight reviewers) found two real defects — fatal producer fencing being downgraded to a WARN, and javadoc documenting the wrong method in two files — plus the finding that matters most: ArchitectureTest's blocking-call walk stopped at the declared member, so it could not see through the interface-typed committer field into the commit implementation at all. Widening it surfaced three pre-existing blocking reaches from onPartitionsRevoked, one of them the confluentinc#857 AB-BA edge itself; all three are now tracked exemptions with an owner note.

A P0 was raised (declining orphans produced records, breaking exactly-once) and disproved — declining performs no transactional action, and the in-flight transaction commits its own offsets atomically. But investigating it found a real, pre-existing gap one step over: the revoke path commits without draining the work mailbox, while the control loop drains under the write lock first. Recorded in docs/inflight/core-revoke-commit-skips-the-work-mailbox-drain.md rather than fixed blind.

Known outstanding, 2026-09-08: the reds above, and nothing else the gates can see. Historical, and superseded by two master merges since - the dups: similarity check failed at +11.4 against a max_increase of 10, because the new ProducerManagerTest cases reuse the latch-and-assert shape of ProduceLockReleaseTest. Left red by decision on 2026-09-02: at 47.6% it is under the tool's own warn_above of 50 and jscpd finds no clones. Master has since deleted ProduceLockReleaseTest (#262 folded it into ProducerManagerTest), so the comparison changed with the master merge; re-read the check on the current head before acting on it.

astubbs and others added 30 commits August 18, 2026 21:29
…duplicating it

The duplicate-code check had been reporting this on every push since 2026-08-13 and
nobody acted, including me - I noticed the reports early and carried on. Both files are
this branch's own (April), so it is duplication this PR introduced, which AGENTS.md
says to remove before merge.

ShardManagerStaleContainerTest and EpochAndRecordsMapRaceTest had each built the same
fixture independently: identical fields and an identical setup() differing only in
whether partitions were assigned. Now extracted to
BrokerlessWorkManagerTestBase, in the parent package so both subpackages can reach it.

The one difference is preserved as an explicit opt-in rather than smoothed away,
because it is load-bearing in BOTH directions and a shared setup that guessed would
break one of them:

  ShardManagerStaleContainerTest  needs the epoch-0 assignment - it has to have work to
                                  make stale against.
  EpochAndRecordsMapRaceTest      must NOT assign, because the race it reproduces is
                                  precisely a poll arriving BEFORE onPartitionsAssigned.

So the base does nothing by default and a subclass overriding assignPartitionsIfWanted()
says so, rather than every subclass inheriting an assignment it may be trying to avoid.
Both intents are stated in the base's javadoc and at each override, so the next reader
does not have to infer which way round it goes.

Also what the repo asks for generally: extend the shared helpers rather than growing a
parallel copy - a drifted copy of setup logic has already been a flaky-CI source here.

7 tests across the two classes pass unchanged; full unit suite 386, 0 failures.
…ed claim as unmeasured

Capture from a planning conversation, so the reasoning survives the session rather than
being re-derived.

test-vertx-non-200-handling-untested.md - HIGH

VertxTest.handleHttpResponseCodes is a disabled stub whose body is
assertThat(true).isFalse(), and the 2026-08-08 audit judged it a "deletion candidate -
there is nothing here to restore". That reads the body and misses the purpose. An empty
or trivially-false test behind @disabled is being used as a TODO that carries a name and
a location: it names the missing coverage and puts the reminder where the test belongs.
Deleting it removes the reminder and leaves the gap.

Stated by the repo owner: "the whole point of those empty tests was for me to remind
myself that something needs testing that I haven't had time to do yet." So the audit's
verdict is right about the body and wrong as an instruction, and the sweep that skipped
it is the actual miss. Non-200 handling is untested in the vertx module while that
module has live defects, so this is filed high.

test-progressbar-width-needs-a-machine-assertion.md

ProgressBarTest.width is the one disabled test that documents itself - a human eyeball
check. Rather than delete it, split it: render to a string at a known width and assert
the geometry (total width, truncation, narrow-width behaviour), and keep the visual demo
as something a human runs deliberately, tagged out of the gating lanes. The assertion
answers "is the rendering correct"; the demo answers "does it look right". Today neither
runs.

test-largenumberofinstances-residual-failures-unmeasured.md, plus the javadoc corollary

That test's javadoc attributes its residual ~10% failure rate to the Kafka group
coordinator rather than to PC. That is asserted and never measured: no experiment
separates "the coordinator cannot converge at this churn" from "PC has a defect that
only appears at this churn", and both present identically as instances alive with an
empty assignment.

It matters because the obvious response to a flaky stress test is to reduce the churn
until it passes - and if any of the residual is ours, that hides a defect instead of
removing a confound. Exactly what #68 did to confluentinc#857: uncontended
brokers made the suite green and left the deadlock untouched for four months.

The javadoc now carries that corollary and a TODO(refactor) pointing at the note, so the
next person to reach for the parameters meets the caveat first. A control arm - the same
churn against a plain KafkaConsumer group with no PC in the path - is what would settle
it.
…i-consumers-bug' into bugs/857-paused-consumption-multi-consumers-bug-rename
…issing

Corrects the durable record after e31b3e5 established that the fourth
ChaosRevokeUnderWork sighting - listed here as sighting 7 - did have a replay seed all
along: 4709156528562690268.

The consolidated write-up carried the original claim, that no seed existed because the
console log truncated, and used it as the closing example of why seeds must move into
the AMBIENT PROBE AUTOPSY block. The example survives and gets sharper, because the
claim itself was wrong in the same way one level up: "the log is truncated" was a
statement about the RETRIEVAL ROUTE, not about the run. The job-log route returned 2207
lines ending at 05:47 for a job that ran to 05:52; the archived attempt returns 6266
lines with both autopsy blocks, BUILD FAILURE and all three scenario seeds.

Also records the second trap that sits with it, which is new: after a re-run, the same
job id resolves to the LATEST attempt's window - so re-running a job to "get a better
log" silently changes what the query returns. Name the attempt explicitly:

  gh api repos/.../actions/runs/<run-id>/attempts/1/logs

That makes four distinct ways a CI log has misled this investigation in two days: the
job-log route truncating, GitHub truncating the stream server-side, a console that
parsed as a clean run while the artifact carried the violation, and now a re-run
silently redirecting the query. Each was read as a fact about the system rather than
about the tooling.

All twelve seeds - failing and control-arm - are now in the durable record. The claim
that none has ever been replayed is unchanged and still accurate: the seventh now has
one to replay, which it did not appear to before.
…e first seed that arrived with it

A fifth sighting, from #296's CI: the same eager CLASS2_STALL signature,
same 154s against a 150s bound, in PERIODIC_CONSUMER_SYNC - the only mode where
#29's AB-BA cycle can close. What is new is the width, 37 violations and
52 frozen partitions against the handful the earlier entries record.

It is the first sighting whose replay seed arrived with the violation rather
than needing a second retrieval route. #296 moved the seed inside the
AMBIENT PROBE AUTOPSY block, which is captured into the failsafe XML and
uploaded as an artifact, so console truncation can no longer take it. The
fourth sighting's correction records the incident that motivated that change.

Two things worth more than the sighting itself.

The eager arm has now failed on FIRST ATTEMPT at two of this branch's three
heads, with no main-source file differing between them - the range touches
docs, three chaos test classes, AmbientProbeExtension and its unit test only.
That is a higher rate than "intermittent" implies.

And re-running hides it. Both failing runs now report success at the run level,
because both were re-run into green; the failures survive only in attempt 1. A
re-run also draws a NEW seed, so a green re-run is a different experiment, not a
retraction of the failure - which is exactly the inference this file exists to
stop people making. The preamble's seed tally is corrected to match: five
sightings, four with a reproducer, none replayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… arguments

An A/B measurement attributed a test flake to this branch and then to this specific
cost, and the attribution is mine: replacing the pausedForThrottling volatile boolean
with a derived consumer.paused() call made three log statements expensive, because
their argument is now a live consumer call rather than a field read. SLF4J does not
evaluate the format string when the level is off, but it does evaluate the ARGUMENTS,
so all three ran on every loop iteration regardless of level.

In production that is a synchronized consumer call per poll-loop pass, for logging
usually switched off. In tests it is worse: each goes ThreadConfinedConsumer check ->
Mockito spy interception -> synchronized MockConsumer, several times per iteration, on
the poll thread specifically.

That last part is why it showed up as a flake.
ParallelEoSStreamProcessorTest.processInKeyOrder fills its input from a doAnswer on the
POLL thread, then waits on control-thread loop cycles - nothing orders the two, so under
CPU oversubscription the control loop can turn before the poll thread has been scheduled
at all, and the input sanity check sees 0 records. Slowing the poll thread widened that
window.

Measured, matched-pair with both arms running simultaneously to control for load:
5 failures in 31 runs on this branch, 0 in 31 on master. All three commit modes hit,
always the same [sanity check input data] signature.

I had already guarded exactly this pattern one level down, in
ThreadConfinedConsumer.checkThread, and missed these three one level up.

Evidence for the fix itself is weaker than the evidence for the cause, and worth stating
plainly: 0 failures in 8 runs under amplified parallelism, which at a 16% rate is not
significant on its own. The guards are correct independently - an unguarded expensive
log argument is a defect whatever it does to this test - so they land on that basis, not
on 8 runs.

The underlying harness weakness is master-side and NOT fixed here: asserting on input
after a fixed number of control-loop cycles, when the input arrives on another thread.
The same pattern appears in inFlightMessagesCommittedIfProcessedDuringShutdown,
executorThreadsInterruptedOnShutdownTimeout and the JStream tests, and wants an
event-based wait rather than a cycle count. Recorded in
docs/inflight/test-untracked-ci-flakes.md.
…ed offsets, not the method that once carried them

RebalanceEoSDeadlockTest is upstream confluentinc#548's regression guard for
the confluentinc#541 transactional-mode rebalance deadlock. It proved "the
revoke path committed" by overriding commitOffsetsThatAreReady() and counting
a latch when the override ran on pc-broker-poll. This branch's revoke path
commits via the private tryCommitOffsetsOnRevoke() instead, so the commit
still happened but the latch never counted: measured, the test passed 5/5 on
the defective build and failed 5/5 on the fixed one - a textbook inverted
instrument (see docs/solutions/workflow-issues/
prove-the-problem-exists-before-writing-the-fix.md). It is why the
Integration Tests lane was red on this PR.

What the defect actually was, from the record (upstream 2738fb3): in
PERIODIC_TRANSACTIONAL_PRODUCER, pc-control takes the producer transaction
write lock (A, via maybeAcquireCommitLock -> preAcquireOffsetsToCommit) and
then the commit mutex (B, in commitOffsetsThatAreReady); the pre-fix revoke
callback on pc-broker-poll entered the commit path B-first, then A inside
retrieveOffsetsAndCommit. AB-BA: the poll thread wedged in the rebalance
callback (or died on ProducerManager's cross-thread write-lock
ConcurrentModificationException) and the revoked partitions' completed work
was never committed. The confluentinc#548 fence: the revoke callback waits
out any in-flight transactional commit (the isTransactionCommittingInProgress
spin - its predicate is "lock A held"), and isRebalanceInProgress stops
control starting a new commit cycle mid-rebalance.

The repaired detector observes behaviour instead of an internal method name:

- Deterministic overlap: each pc-control commit cycle dwells 4s between
  acquiring A and touching B; the revoke callback waits (bounded, asserted
  via overlapForced so the test cannot silently go vacuous) for a fresh dwell
  before proceeding - the exact pre-fix fatal window, every run.
- The outcome: the group's committed offsets for the revoked partitions,
  read via AdminClient at callback entry and again at callback return, must
  advance strictly during the revoke window - by the waited-out control
  commit or by the revoke path's own commit. Post-revoke processing is slowed
  500x so redelivery cannot re-advance the offsets before the read.
- Bounded revoke (30s), no recorded failure cause, and post-rebalance
  progress complete the guard.

Mode stays PERIODIC_TRANSACTIONAL_PRODUCER deliberately: confluentinc#541 is
a transactional-mode defect - lock A only exists there. That also bounds the
scope: this test does NOT exercise the confluentinc#857 AB-BA cycle, whose
second edge lives in ConsumerOffsetCommitter and only exists in the
consumer-commit modes; Rebalance857CommitSyncDeadlockProbeIT covers that.

Both arms measured, byte-identical test on each. Defect arm = this head with
the confluentinc#548 fence disabled behind a temporary hardcoded boolean
(spin and gate short-circuited; flag deleted before this commit), per the
red-black-switch technique: fixed arm 10/10 pass, defect arm 10/10 fail,
each failure naming the frozen committed offsets and each run logging the
predicted "write lock already held by another thread" abandonment.

Also fixes an accidental aliasing the upstream test always had:
setupTopic("output-topic") overwrites the inherited topic field, so PC
consumed from and produced back into the "output" topic in a feedback loop.
The output topic is now genuinely separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiZYbruWmTFGkfepmZn2er
…data not loop cycles

Two changes, one cause: an A/B measurement attributed a test flake to this branch
(5 failures in 31 runs, 0 in 31 on master, matched-pair with both arms running
simultaneously so load was controlled rather than assumed).

1. THE COST I INTRODUCED

Deriving pause state from consumer.paused() instead of mirroring it in a volatile
boolean is what makes the eager/cooperative distinction irrelevant - but it turned a
field read into a live consumer call, and managePauseOfSubscription plus the three
methods below it were making three to five of them per loop iteration on the poll
thread. Three were in unguarded log ARGUMENTS, which Java evaluates before SLF4J ever
checks the level, and two more were functional.

The paused set is now read ONCE per pass and threaded down as a parameter. A parameter
rather than a field on purpose: a stack local cannot outlive the pass, so there is no
invalidation to get wrong and nothing that can go stale between iterations - which was
precisely the failure mode of the mirror this replaced. It is also strictly fewer calls
than before in the resume path, which used to fetch the set a second time to act on it.
Drain and close keep a no-arg overload; they run once per lifecycle, not per iteration.

2. THE ACTUAL RACE, WHICH IS OLDER AND NOT MINE

Reducing the cost did NOT fix the flake - it still fired once in four full suites - and
that is the useful half of the result. ParallelEoSStreamProcessorTest.processInKeyOrder
fills `polled` from a doAnswer on the POLL thread, then waits via awaitForOneLoopCycle(),
which counts CONTROL thread cycles. Nothing orders the two: the control loop turns about
every 100ms whether or not the poll thread has ever completed a poll, so under CPU
oversubscription the assertion can run before the poll thread has been scheduled at all
and sees exactly 0 records - all-or-nothing, because the MockConsumer hands over the
whole batch at once.

Now waits for the data instead. 4 consecutive full suites green afterwards, against 1
failure in 4 before; n is small, so the fix stands on being correct rather than on the
count.

The same assert-after-N-cycles pattern appears in
inFlightMessagesCommittedIfProcessedDuringShutdown,
executorThreadsInterruptedOnShutdownTimeout,
queuedMessagesNotProcessedOrCommittedIfSubmittedDuringShutdown and the JStream tests -
master-side hardening, tracked in docs/inflight/test-untracked-ci-flakes.md rather than
swept into this PR.
…ministic correctness gate and a capacity measurement

MultiInstanceRebalanceTest.largeNumberOfInstances answered two questions at
once, badly: "does PC handle rebalance churn correctly?" (a correctness
question - must pass 100%) and "how much churn can PC survive?" (a capacity
question - a rate is a legitimate output). One test cannot answer both, which
is how it ended up documenting "80%+ pass rate" as an acceptance criterion - a
measurement wearing a test's clothes, in a repo whose rule is that a flake
fails the build with no retry.

What changed, all in the one test class:

- Parameterised the scenario into a Scenario builder (instance count, message
  count, pre-produce fraction, poll delay, assignor, churn style, stall
  window, ceiling) so one implementation - runScenario() - drives every
  profile. The four pre-existing profiles map onto it with identical
  parameters and identical behaviour, including the legacy ProgressTracker
  stall detector (11 consecutive progress-free 1s checks), so the capacity
  profiles' measured baseline is undisturbed.

- NEW gating correctness profile scriptedChurnRoundsCompleteWithoutStall (no
  performance tag): 3 instances / 8 partitions / 30k records, cooperative
  assignor, same code paths as the capacity profile. Deterministic by
  construction rather than by dilution - a scaled-down copy of a probabilistic
  test would just fail at 1% instead of 20%, rarely enough to be dismissed as
  noise. Every probabilistic ingredient is removed, not reduced:
    * churn is a scripted, event-anchored schedule (4 rounds of synchronous
      stop -> assert survivors progress -> restart -> assert progress again,
      round-robin victims, no Math.random anywhere);
    * all records pre-produced and acked before churn starts (no producer
      race);
    * the assertion is PROGRESS, not completion-within-T: the consumed count
      must advance within ProgressProbe.NO_PROGRESS_WINDOW (the chaos suite's
      NO_PROGRESS model, reused not reinvented, as a sliding watermark - the
      5-minute ceiling exists only to stop a runaway build). A slow machine
      keeps passing; a genuine stall fails inside the window with a full
      instance-state dump;
    * uncontended broker via resetKafkaContainer() - contention is a confound
      for the correctness arm. The #68 caveat is written into the
      javadoc: uncontended hid the confluentinc#857 deadlock when applied to
      everything, so it is right for this arm and deliberately wrong for the
      capacity arm, which stays on the shared container.

  Determinism evidence: 17/17 consecutive local passes (16 solo runs, ~45-53s
  each, plus one inside a full-class run; 2026-08-18, Docker/TestContainers).

- largeNumberOfInstances keeps today's parameters and churn EXACTLY (verified:
  a run under -Dincluded.groups=performance started all 12 instances on 80
  partitions, fired 204 chaos rounds, and reproduced the documented residual
  signature - instances alive, assignedPartitions=0, no PC failure cause).
  Its javadoc now frames the pass rate as the profile's measured output
  rather than an acceptance criterion, names the correctness twin, and keeps
  the corollary that the "it's Kafka's fault" attribution is asserted, never
  measured. Churn is deliberately untouched:
  docs/inflight/test-largenumberofinstances-residual-failures-unmeasured.md
  owns that investigation and its baseline, and gets a dated update noting
  the split.

Defect-class sweep: "pass rate" as an acceptance criterion appears in no
other test across the modules (grep over parallel-consumer-*/src) - this was
the only instance.

Verification: full class green in gating mode (7/7 executions: both vanilla
parameterized profiles across all three orders, plus the new scripted
profile); TestConventionsArchTest green; bin/check-issue-refs.sh and
bin/check-copyright-headers.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiZYbruWmTFGkfepmZn2er
…p in its ledger

docs/features/seeded-chaos-replay.yaml

The suite's most distinctive property had no user-facing record: every chaos run is
driven by a recorded seed, so a failure seen once can be re-run as the same scenario.
docs/testing.md documents how to use it and testing-evidence.yaml mentions seeds in
passing, but nothing states the capability itself - and it is the strongest available
answer to "are the reliability claims tested or asserted".

The boundary that matters most is written into the record rather than left to be
learned: a seed fixes the SCENARIO - the sequence of stops and starts, and the workload
- not the thread interleaving. So a reproduction is strong evidence and a
non-reproduction is weak. Without that, "replayed it, did not reproduce, therefore
fixed" is the obvious and wrong conclusion.

docs/inflight/test-chaos-ledger-does-not-check-ordering.md

The ledger already checks no-loss and bounded-duplicates, and ProgressProbe checks
liveness - already recorded in docs/testing.md and testing-evidence.yaml, so not
restated here. What is missing is ORDERING: "key concurrency without losing per-key
order" is why this library exists, and the suite that hammers it hardest never asserts
it.

Recorded as small, with the reason it is not trivial. The recording exists - the ledger
already receives allConsumed - but a naive "offsets per key must increase" would
false-positive, because under at-least-once with rebalances an earlier offset legitimately
reappears after a later one when uncommitted work is redelivered. The check has to bound
ordering per key, per instance, per assignment epoch, and getting that boundary wrong
gives a detector that fires on correct behaviour - worse than none, in a suite whose value
depends on a RED meaning something.
…i-consumers-bug' into bugs/857-paused-consumption-multi-consumers-bug-rename
… the fix does not close the eager stall

Two recorded seeds replayed for the first time, 16 local runs with the arms
interleaved and the chaos sources byte-identical across them. Both seeds
reproduce on BOTH arms: 11 of 12 seeded attempts failed, including six of six
on the branch carrying #29's deadlock fix.

The documented asymmetry makes this the strong direction - reproduction is
strong evidence, non-reproduction weak - so this is not a result that a further
run can soften. The one seeded pass is a seed fixing the scenario and not the
interleaving, which is the behaviour the asymmetry predicts.

What lifts it above a replay artefact is the control. Fresh random seeds passed
three of four in the same window, so the recorded seeds are genuinely enriched
rather than the box being unable to run the scenario - and the fourth control
FAILED on the fixed arm from a fresh seed, reaching CLASS2_STALL with no replay
machinery involved.

It does NOT establish that the residual stall is the AB-BA cycle, and the file
says so. The cleanest failing run has partition 22 committed-frozen for 100s of
the QUIET phase - group STABLE, heartbeats flowing - against the scenario's own
~40s worst-case legitimate recovery. An anomaly by the test's own calibration,
mechanism unattributed; the named next experiment is a fixed-arm failing run
with periodic thread dumps.

Two traps recorded so they are not re-learned: the ~154s figure is bound plus
probe cadence, an artefact of the detector rather than a defect fingerprint;
and the recurring per-partition fingerprints are workload-determined, appearing
under both seeds, both arms and the control, so fingerprint identity must not
be read as seed identity.

The preamble's "no seed here has ever been replayed" is corrected rather than
left to read as current. Confounds are recorded rather than resolved, including
that the invocation ran the eager scenario alone where CI runs the whole chaos
group in one JVM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ttribution, and retire the 154s fingerprint

The replay spends the best evidence this family had for tying the eager chaos
sightings to #29's AB-BA cycle, so three records that leaned on it are
corrected where the claim lives rather than left to read as current.

Mode-compatibility said the cycle COULD close in PERIODIC_CONSUMER_SYNC, and
that was carrying weight it cannot support now the stall reproduces six times
out of six with the fix applied. The mode table itself is untouched and still
correct: it narrows the candidates, which is a different job from naming one.

The second correction is the more useful one, because it was wrong before the
replay and nobody had noticed. Two sightings were argued to be the same failure
partly because both reported ~154s - but that is the 150s bound plus the probe's
poll cadence, so every stall of this class reports it. The agreement was the
detector's, not the defect's. The sameness claim now rests on same-test,
same-arm, same-violation-class plus the arithmetic control arm, which is weaker
than what was written and still sufficient.

The family ledger gains the residual as a fourth open item, and its opening
count is marked a floor rather than a total. This does not weaken the case for
#29's fix, which is proven on its own instrument at 60/60 to 0/60; it
removes a symptom that fix was never shown to own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s-bug-rename

Both sides are fully renamed (254 and 244 bz/stub files, zero io/confluent), so
this is the ordinary case rather than the pairing hazard AGENTS.md warns about.

One conflict, in the log-truncation write-up, and it needed a side from each.
Master's wording is right and is the fix this branch had just tripped over
independently: the autopsy banner is cited as AMBIENT PROBE AUTOPSY rather than
wrapped in the === decorations, because the real emitted line carries the test
name between them, so the decorated form greps to nothing. This branch's
citation path is right, because the split moved those two truncation incidents
out of the family ledger into test-857-churn-storm-async-stalls.md - confirmed
by date, the churn-storm file's fourth and fifth are 2026-08-12 and 2026-08-18
against the ledger's 2026-08-13.

AbstractParallelEoSStreamProcessor auto-merged with both sides having changed
its close and interrupt handling, which is exactly the case where a clean merge
is not evidence, so the result was read rather than trusted. The two changes are
in different methods and compose: #209 hardens work submission against a
dead pool, this branch's awaitTermination loop records the interrupt in a local
and restores it once after the loop. Re-ran this branch's own guards against the
merged class - CloseInterruptLivelockTest, ThreadConfinedConsumerTest,
BrokerPollSystemCooperativeRebalancePauseTest, ArchitectureTest and
OutForProcessingCounterDriftProbeTest, 16 tests, all green.

Also drops a forward-reference that the merge revealed had already been
satisfied: tryCommitOffsetsOnRevoke told a future reader to interpolate
describeWithRootCause "when it lands with #267", on the line above the
one already doing it, since this branch cherry-picked ThrowableUtils.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… killed at the moment of detection

Adds an off-by-default diagnostic mode to the revoke-under-work scenario,
-Dchaos.diagnoseStallRecovery=true, to settle a question the gating
configuration structurally cannot ask.

The scenario's arithmetic asserts that "a REAL Class 2 stall is unbounded" and
the probe's javadoc records its RED calibration as still open. Neither claim has
ever been tested, and the reason is the instrument rather than the opportunity:
failFast aborts the quiet phase on the first violation and QUIET_CAP gives up at
five minutes, so every observation ends the instant the stall is confirmed. From
that data a wedged partition and a merely slow one are indistinguishable - which
is precisely the discrimination now needed, since the first seed replays showed
the stall reproducing on both arms and left its mechanism unattributed.

In this mode the quiet phase keeps waiting after a violation, up to twenty
minutes, logging the consumption count each poll. The recovery curve is the
measurement: flat to the cap means unbounded, so lost state - a partition paused
and never resumed, or a lost wakeup - while a creeping count means bounded, so
starvation or fairness, a different defect class needing a different hunt.

It cannot turn a red run green, and the javadoc says so where someone reaching
for it will read it: assertScenarioSlos still asserts the probe's violations are
empty whichever way the wait ends, so a run that trips the probe still fails. It
fails having recorded what happened next. With the property absent the gating
configuration is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er never checked

The correctness ledger checked that no record is lost and that duplicates stay
bounded. It never checked ordering - the guarantee the library exists for.

Adds KeyOrderLedger and W5, ChaosKeyOrderIT, the first chaos scenario to run
ProcessingOrder.KEY. The other two run UNORDERED over a unique key per record,
so a per-key ordering assertion over them is doubly vacuous: no guarantee to
keep, and no key with two records in it.

The window is the whole difficulty. A naive "offsets per key must increase"
fires on correct behaviour, because at-least-once redelivery after a revoke is
the contract working. The guarantee asserted is: within one PC INCARNATION, one
PARTITION, one assignment EPOCH and one KEY, records are executed one at a time
in ascending offset order. Each component excludes one class of legitimate
re-processing, and the epoch is read off the record's own WorkContainer through
public API (PollContext#streamInternal), not from a counter at execution time -
so a heavy record still in flight when its partition is revoked keeps the old
epoch and cannot be mistaken for the new window's work.

Two violations follow: LEDGER_KEY_ORDER (an offset regression) and
LEDGER_KEY_CONCURRENCY (two deliveries of one key in flight at once). The
second is not decoration - the UNORDERED control arm produced 857 overlaps and
one order regression, so an offset-order-only detector would have called that
arm green.

Non-vacuity is enforced, not hoped for. A window holding one delivery asserts
nothing, so check() reports LEDGER_ORDER_VACUOUS when no window held two, and
logs comparedDeliveries - the count actually compared against a predecessor
(measured 85,433 of 90,401 on a live run). KeyOrderLedgerIT pins both
directions against constructed histories: a genuine regression is caught, and
redelivery after revoke, redelivery to another instance, a restart of the same
instance, and a heavy straggler overlapping the next epoch are all passed.

Calibration cost two real workload artifacts, both now checked rather than
commented. HEAVY_EVERY being a multiple of KEY_SPACE puts the entire heavy tail
on key k-0, serialising every dwell onto one shard and pinning that partition's
commit watermark for the run - measured as a 154s lag stagnation that reads as
a Class 2 stall; heavyRecordsMustNotAllShareOneKey is the check. And a dwell
longer than the gap between rebalances chains indefinitely under at-least-once,
which is why HEAVY_SLEEP is 3s here and not W1's 45s. The conductor ticks
slower than W1 for a stated reason: at 500-1500ms membership changes arrive
faster than an EAGER rebalance completes, so the group never reaches Stable and
the dwell probe's continuous clock passes its bound with no member misbehaving.
No probe threshold was touched.

Test-side only. ManagedPCInstance gains a RecordListener carrying the PC
incarnation id, because a restart resets the epoch counter and a window scoped
to the instance id would span two unrelated lifetimes.

Live verification: the full chaos suite is green (5 tests), W5 at 90k records
reporting orderRegressions=0 overlaps=0 with probe peaks well inside every
bound (dwell 4.4s/15s, lag stagnation 22s/150s).
…ing gap's docs to their new owner

Merges the branch head, where resolveSeed() now returns a ChaosSeed carrying its
own replay command, and the seed is lifted into the autopsy block.

Also records the prior art the ordering ledger did NOT reuse, in both
directions. KafkaTestUtils#checkExactOrdering is the repo's only existing
per-key ordering assertion, and it has no ordering window because it cannot need
one - a MockConsumer, one instance, no rebalance, drained before checking, so no
record can be redelivered. In that world it asserts something strictly stronger
than the chaos ledger does (a gapless +1 value sequence of exactly the produced
size, which doubles as a loss/duplicate check), and scoping it to a window would
weaken it for its own caller. It also fails on the first duplicate under churn,
so its javadoc now names its validity domain and points at KeyOrderLedger,
rather than leaving a rebalance test to reach for it and read the false RED as a
product bug.

docs/inflight/test-chaos-ledger-does-not-check-ordering.md is deleted - its work
landed - and the seeded-chaos-replay feature doc, which cited it as the open
gap, now states what the ledger checks and who owns the window definition.
docs/testing.md gains the ledger's two halves and the workload-artifact lesson;
the Phase 2+ roster records that KEY ordering, its top-ranked unexplored Class 2
lever, has now been tried and is spent as a trigger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the hardware, and stop the gate from following

The capacity profiles measured at whatever size someone once had a box for -
500k records, 80 partitions, 12 instances, welded into builder literals. That is
too big for a laptop and too small for the 32-core runner, and in both cases it
is silently the wrong experiment rather than a slow or fast one.

-Dperf.scale=<n> now multiplies each capacity profile's own baseline, so their
relative proportions hold and one flag moves all three. Absent, it is 1.0 and
every historical number is reproduced exactly.

The point is not convenience. A capacity profile is a MEASUREMENT, and a
measurement that can only be taken at one size cannot answer the question
test-largenumberofinstances-residual-failures-unmeasured.md is about: whether
the residual ~10% is the group coordinator failing to converge or a PC defect. A
rate that does not move with scale is hard to explain as "Kafka cannot converge
at this size". That is weaker than the bare-consumer control arm that note asks
for and does not replace it, but it costs a flag instead of a harness.

The correctness profiles deliberately cannot read the factor, and that is
enforced rather than asked for. A scale factor is exactly the probabilistic
ingredient the deterministic gate was split out to remove, so scaling it would
reintroduce by the back door the property the split exists to guarantee - and
the failure would be silent, since the gating lane never sets the property. A
run-time guard fails any correctness profile that reads the scale, deciding on
the calling TEST method's own annotations so an intermediate helper cannot
launder it.

onlyCapacityProfilesMayScale proves the guard in both directions on real method
names, including that a non-test helper is walked past rather than judged, and
ends by reading the scale from itself - an untagged test - to confirm the throw
happens end to end rather than only in the predicate. It is untagged on purpose:
the invariant is a property of the GATING profiles, so checking it only in the
performance lane would leave it unchecked exactly where it matters. It needs no
broker work of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the defect the probe hunts

Experiment 1, run under the diagnostic mode added for it. Seed A replayed with
the quiet phase watching instead of aborting at the first violation: the run
drained completely, 252421 of 250000 expected records with every key covered and
no timeout. Legitimate recovery took 281s from storm end.

The probe fires at 150s of per-partition stagnation and the gating run's
failFast destroys the wait at the first violation, around 154s. So every
observation of this signature to date has been taken at 154s of a drain that
needed 281s. Fifty-three partitions eventually tripped the bound - four of them
while already recovering - and all fifty-three finished.

The probe's own javadoc says what it hunts: "a REAL Class 2 stall is unbounded,
so the bound still catches it with 50s margin". A bounded stall is by that
definition not that defect, so these sightings are the detector firing on slow
legitimate recovery.

That explains the replay grid without a surviving bug. A false positive does not
care which arm it runs on, which is why both reproduced; the universal ~154s is
bound plus poll cadence; and a green re-run is one whose interleaving happened
to finish under 150s.

The calibration arithmetic under-counts redelivery chaining. It reasons worst
legit freeze = storm + one HEAVY_SLEEP + slack = 100s, which assumes a heavy
record is redelivered once, but a 60s storm at 300-1000ms ticks can revoke it
mid-dwell repeatedly and each link adds another dwell. The file had already
named this exact failure mode at 90s/45s and retuned to 60s/20s to escape it;
the retune fixed the single-chain case and left the multi-chain case.

NOT a licence to retune the bound, and the record says so. One seed, one run,
on a box that caps every JVM to 8 of 32 processors - which slows the drain and
inflates the 281s, so an uncontended runner may fit under 150s and make this
contention rather than calibration. The next step is seed B and an uncontended
repeat, measuring the legitimate recovery time, not adjusting until green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he CPU arm settled

Experiment 1 extended with the two arms that discriminate its result, plus the
control arm they point at.

Seed B under the diagnostic mode drained in 104s with ZERO violations, against
seed A's 281s and 53. Seed A re-run with the JVM's processor cap lifted, 32
cores instead of 8, took 290s with 54 violations - indistinguishable from the
capped run. So the contention hypothesis is dead: seed A's schedule intrinsically
needs ~285s of quiet drain against a 150s bound, and seed B's needs 104s and
never trips it. The scenario's arithmetic predicted 100s worst case and is out
by roughly three times, for seed A's draw.

The mechanism is now visible in the log rather than inferred. During the flat
stretches the poll thread reports "Subscriptions are paused: true" and "Got 0
records" - back-pressure, working - while the in-flight batch is the deliberately
heavy records sleeping HEAVY_SLEEP each. A partition's commit watermark cannot
advance past an incomplete record, and stops are closeDontDrainFirst, so a storm
tick catching a heavy in flight abandons it and at-least-once redelivers it for
another dwell. Chain enough links and the watermark is legitimately pinned past
the bound.

ChaosRevokeUnderWorkDrainIT tests exactly that chain. Drain-only stops let the
in-flight heavy finish and commit before the member leaves, so the chain cannot
form; RESTART and JOIN_NEW keep their W4 weights so the churn RATE is comparable
and the manner of leaving is the only variable. Duplicates measure the same thing
from the other side - the no-drain arm produced 2421 of 250000, and a draining
close should produce few or none.

Either outcome is informative, and the javadoc says so: a clean drain arm makes
the stagnation an artifact of abandoned work against the bound, while a red one
kills the leading explanation and would be the first real Class 2 lead in this
family.

The weights hook it needs carries the warning with it. W4 excludes drains
deliberately, because a drain opens the Class 1 zombie window that can mask the
Class 2 mechanism, so an override changes which failure class the scenario can
see. A variant that overrides it is answering a different question, not running
the same test more gently, and its GREEN says nothing about the no-drain arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd put the prior art where it gets read

Two fixes for two ways the last experiment misled its own author.

The diagnostic mode logged only totalConsumed, which is incremented at the END
of the user function. A fleet sitting inside HEAVY_SLEEP therefore reads as a
flat line while it is fully busy, and that is indistinguishable from a fleet
that is genuinely stuck - so the flat stretches in the seed A run were reported
as possible stalls when the poll thread was concurrently logging "Subscriptions
are paused: true" and "Got 0 records", which is back-pressure working exactly as
designed. A counter is now incremented at ENTRY as well, and the mode logs
started, consumed and the difference. In-flight work is the quantity that makes
a flat completion count interpretable.

The second is the prior-art miss. The scenario's own javadoc records that a
recovery diagnostic was already run at the 90s/45s shape and already established
that the freeze resolves and the backlog completes. That was re-derived in
August 2026 by an agent that had run all six of AGENTS.md's prior-art checks
correctly - because those six search plans, solutions, inflight notes, PRs and
issues, and not one of them reaches a class javadoc.

So the checklist gains a seventh row naming the javadoc, with the grep that
finds them, and a bullet saying plainly that a test's javadoc is prior art the
six commands cannot find. That helps someone who reads the checklist. The
stronger fix is that the diagnostic mode now prints the pointer itself, when it
starts, naming what the earlier run established and what the remaining question
actually is - so the prior art arrives at the moment it is about to be repeated
rather than waiting in a file to be opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rules

The harness doc argued that a rule which must not be missed cannot live only in
a document someone chooses to open. The same layer carries KNOWLEDGE, and that
was never written down, so it kept being solved case by case or not at all.

The distinction worth stating: a rule protects the repo from the agent and you
can test whether it fires. Knowledge protects the agent from wasted work, and
nothing goes red when it fails to arrive - the cost is paid silently, as an
experiment repeated or a symptom misread. There is no feedback telling you the
delivery was needed, so it has to be decided when the code is written.

Two incidents a day apart are the worked examples, both recorded there. An agent
ran all six of the prior-art checks correctly and still repeated an experiment
whose result was recorded in a class javadoc, because not one of those six
reaches a javadoc. And /tmp on the dev box hit 99%, which surfaced as a chaos log
truncated mid-run - read as a test ending early, at real diagnostic cost - and as
another session's watcher dying on ENOSPC, with neither agent able to see the
cause.

So the pre-commit hook gains a disk advisory: silent below 75%, and otherwise
naming the filesystem and saying that tidying an agent's own /tmp scratch files
is part of finishing a task rather than something only the repo gets. It blocks
nothing - a full disk is not a defect in the change being committed, and a gate
that blocks on it would be disabled within a day. Verified in both directions:
silent at 32%, fires when the threshold is lowered.

Silence below the threshold is the design rather than a detail. Advisory output
competes for attention with every rule in AGENTS.md, which already names
attention as the scarce resource, and a notice that prints every time is one an
agent learns to skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… name the timeout that silently caps a diagnostic watch

Two things learned while running the recovery experiment, recorded where the
next person meets them.

The suite cannot model a CRASH. Every stop the conductor performs is an orderly
close - stop() and stopAsync() call closeDontDrainFirst(), STOP_DRAIN calls
closeDrainFirst() - so the member always leaves the group and a rebalance starts
at once. A crashed process stops heartbeating with its assignment still held and
nothing happens until the session timeout, which is the shape most confluentinc#857
reports describe. The family's most-reported condition is the one no scenario in
the suite can generate.

It is parked rather than queued because the fleet shares one JVM by design: the
consumed and started counters, allConsumed and the ordering ledger's history are
in-memory structures every instance writes to, so killing a process loses exactly
the evidence a crash scenario exists to examine. The note records Antony's route
through that - separate JVMs appending to files that a central chaos admin tails,
so kill -9 is exact and the killed instance's evidence survives it - plus the two
traps that route has: not on the dev box's /tmp, which is a shared 32GB tmpfs
that chaos logs filled to 99% this week, and flush per record or the ledger
under-reports precisely the records the crash is about.

It also says why now is not the time. The open question is a Class 2 stall no
scenario has reproduced in nine seeds, and the evidence points at detector
calibration rather than a defect, so a multi-JVM harness is a large build aimed
at a hypothesis that is currently losing.

Separately, DIAGNOSTIC_QUIET_CAP's javadoc now names the ceiling above it. The
scenario classes carry @timeout(600), so a quiet cap beyond about six minutes
cannot be reached - JUnit kills the test mid-observation and the run then reads
as one that stopped for its own reasons rather than one that was cut off. The
default is twenty minutes, so the trap is armed out of the box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s the detector meeting the workload

Seed A held constant and run through four arms, each killing a different
explanation. Unbounded stall: dead, the run drains fully in 281s. Machine
contention: dead, 32 CPUs instead of 8 gave 54 violations and 290s. Abandoned
in-flight work: dead, drain-only stops gave 45 violations against 53 and left
2007 duplicates against 2421.

The cooperative arm names the cause. Same seed, same workload, same churn rate,
and its storm phase ended having consumed 239808 records against the eager arm's
193715 - about 46000 more in the same sixty seconds - with ZERO violations, and
it passes. Cooperative rebalancing keeps assignments; eager revokes every
partition from every member on any membership change and restarts whatever heavy
work was in flight.

So each storm tick restarts in-flight heavies, each restart costs another
HEAVY_SLEEP, a partition's commit watermark cannot advance past the incomplete
record, and enough links pin it past the 150s bound legitimately. That also
explains why the drain arm barely moved: draining the DEPARTING member's work
cannot prevent a revocation that takes everyone's.

This was root-caused once before, in July, at the 90s/45s shape - the scenario
javadoc says so - and the response was to retune to 60s/20s so that 60+20+20
sat under the bound. That arithmetic assumes ONE restart. What is measured here
is what several cost, and 281s is not near 100s, so the retune fixed the case it
was derived from and left the rest.

The eager sightings in this family should no longer be read as confluentinc#857
evidence. #29 was never implicated by more than mode-compatibility and the
replay already spent that.

Deliberately NOT concluded: that the bound should be raised. The honest options
are to raise it with the multi-restart arithmetic written down, to shorten
HEAVY_SLEEP so chains cost less, or to accept that the eager arm cannot host a
Class 2 hunt and let the cooperative arm own it. That is the suite owner's
calibration call, and the one way it must not be made is by nudging a threshold
until a run goes green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x, and name the probe's category error

The fourth cell, cooperative with draining stops, closes the matrix on seed
4734674029169027864. Eager/no-drain 53 violations and 2421 duplicates,
eager/drain 45 and 2007, cooperative/no-drain 0 and 405, cooperative/drain 0 and
369. The assignor accounts for essentially all of it and the stop mode is
second-order in both rows, which is what duplicates being a product of REVOCATION
rather than departure predicts: eager takes every partition from every member on
any membership change, so the abandoned work was never the leaver's to drain.

The clearest number is the last row's storm-end count. Cooperative with draining
stops had consumed 249788 of 250000 before the quiet phase even began, against
the eager arm's 193715 under the identical disturbance schedule.

The matrix also makes a design fault plain, and Antony named it: the suite exists
to establish CORRECTNESS, and CLASS2_STALL asserts a TIMING bound as a proxy for
liveness. Every cell completed with nothing lost, so all four are correct; three
fail only the 150s clock. A committed offset is silent while a long record runs,
so a busy fleet and a wedged one are indistinguishable to a watermark-stagnation
detector - and the diagnostic mode's new inFlight counter shows the eager arm
holding 20-29 records in flight with consumed advancing every poll throughout the
window it was called stalled.

test-class2-probe-asserts-timing-not-correctness.md records the critique and the
replacement: gate on completion, loss, bounded duplicates, ordering and a real
liveness check such as in-flight never sitting at zero while a backlog exists -
which detects a wedge and cannot fire on slowness - and REPORT recovery time and
stagnation peaks instead of gating on them. It also says why raising the bound is
not the fix: the July recalibration already did that once, from an arithmetic
that assumed one restart of an in-flight heavy, and measured reality with several
is 281s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emo app an experiment to run

Three records, one measurement. The assignor x stop-mode matrix over 250,000
records produced duplicate counts of 2421 eager with close(), 2007 eager
draining, 405 cooperative with close(), 369 cooperative draining - a 6x spread
that users currently have no way to learn except by hitting it.

The README template gains a "reducing duplicate replay" section under the
offset-map discussion, because that is where a reader already asking about
replay will be. It says plainly that neither setting is specific to this library
- both are ordinary consumer behaviour - and orders them by effect: the assignor
governs work abandoned on EVERY member, since eager revokes every partition from
everyone on any membership change, while the close mode only governs the
instance actually shutting down. close() is closeDontDrainFirst(), which is the
part people do not expect.

The table is captioned as what it is. These come from the chaos suite, which
stops and restarts members every few hundred milliseconds for a minute with
deliberately slow records in flight, built to be far more hostile than
production so effects like this are visible at all. The caption says not to read
them as expected rates, and that the relative ordering is what carries over. It
also repeats that replay cannot be prevented across failure, so the settings
reduce how often the cost is paid, not whether it must be handled.

The rebalance-behaviour feature record gets the same facts as boundaries, and
the README rendered from the template.

The demo-app ideation note gets the owner's addition: the demo should RUN these
experiments and show results live, which is direction 3 - the bring-your-own-topic
what-if machine - given an engine. The chaos harness becomes a UI toggle rather
than a fork, since chaos-on is the dramatic demo and chaos-off against a user's
own topic is the honest one; the diagnostic mode's per-poll consumed/started/
inFlight series is already the right shape to render, and inFlight is what makes
work-in-progress visible rather than only throughput. Runs may be parallel or
sequential, with the caveat recorded that sequential is the fair comparison
because concurrent cells contend - a confound already measured here - so the
choice must be explicit in the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… produced

Seven candidates, all from an instrument being wrong rather than the product,
which is why they generalise past this investigation. The headline one: the
chaos suite judged PC from OUTSIDE, through committed offsets read by an admin
client, while WorkManager and ShardManager answer the same questions directly
and pc.getWm() is public. A test that infers internal state from an external
signal will eventually infer it wrongly.

Also recorded: measure both ends of any counter used to judge liveness, since a
completion count alone cannot separate "nothing is finishing" from "nothing is
happening"; assert the property and report the timing, rather than gating a
correctness suite on a duration; and granularity is part of a liveness check's
correctness, because the existing NO_PROGRESS probe has exactly the right shape
but is fleet-wide, so one wedged shard hides behind seventy-nine healthy ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch has carried its knowledge in TOPIC-scoped files - bug-857-family,
the per-mode sighting ledgers, the solutions write-up - and never had a
PR-scoped one. The April investigation log was retired in 69a670d into exactly
those files, which kept the settled knowledge and left the live threads with
nowhere to live. So the compounding ideas from today's measurement work were
briefly written into next-candidates.md, which is the ranked READY PICKS list
and the wrong home for un-triaged observations; they move here and the pointer
is removed rather than left to erode what makes that list useful.

The note records what the PR is - the AB-BA deadlock fix, 60/60 failures before
and 0/60 after - and, at more length, what it is NOT. The eager
ChaosRevokeUnderWorkIT sightings were its leading candidates on the strength of
PERIODIC_CONSUMER_SYNC being the one mode where the cycle can close, and they
are neither this defect nor probably any defect.

It also carries the open threads, which is the part that will earn its keep:
what is in flight, the three honest options for the 150s bound with a note that
nudging it until green is not among them, the ordering ledger gating only in the
chaos lane, the fact that the four-cell matrix now quoted in the README is one
seed and one run per cell, and the merge mechanics nobody has done - no review
requested, no merge strategy recommended, duplication reports unread.

Append to it as the work goes rather than reconstructing at the end. Several
things today survived only because the owner asked at the right moment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the control thread (#466)

In PERIODIC_TRANSACTIONAL_PRODUCER mode the revocation-time commit ran inline on the broker-poll
thread and never drained the controller's work mailbox, so it could publish a transaction whose
committed offsets omitted records that transaction contained: output committed, input offset not,
and the partition's next owner reprocessed the input and produced the output again.
#436 diagnosed it, refuted claims C9 and C4 of the transactional claim
register, and quarantined the proof with a passing control arm. This is the fix.

What changed for a user: exactly-once holds across a rebalance in transactional mode.
RebalanceEoSDeadlockTest reads the output topic with a read_committed consumer after the revoked
partitions return and fails on a repeated result; against the old code it found three to five
duplicated results in about a hundred and ten, five runs of five, and reads zero five of five with
the fix. C9 and C4 read PROVED again with their one documented exception, the proof left quarantine
and carries both claims, and the README caution is gone.

The fix. In transactional mode onPartitionsRevoked no longer commits on the poll thread. It posts
a request, wakes the control loop with a mailbox message (never an interrupt, which a periodic
commit's timed lock wait would read as a shutdown), and waits, bounded by
commitLockAcquisitionTimeout. The control loop takes the request at the top of a pass and runs its
ordinary sequence - write lock, flush, drain, fence, commit - then completes the request; a pass
that throws fails it; a request the control thread has already taken is waited through to
completion so truncation strictly follows the commit; the close serves whatever is pending at its
own commit and the callback declines at once while the instance is closing. Waiting on the control
thread is safe in this mode and only this mode: the deadlock behind confluentinc#857 is the control
thread blocking on the poll thread, and a transactional commit needs nothing from it. The
consumer-commit modes keep the inline tryLock commit.

The fence. With the drain alone the broker-level check still read two or three duplicates per
run: once the served commit released the write lock, a worker parked on the produce lock resumed
with a record of a partition about to be truncated. PartitionState.fenceForRevocation is set by the
served pass inside the write lock, for the assignment epoch the request was posted with, so a late
pass cannot fence a re-assignment; the produce wrapper checks it right after the produce lock in
both transactional modes and refuses with PCRetriableException.

Rejected before writing code, by experiment: the position recorded at #436's merge prep was to
decline the commit unconditionally. Two arms in ProducerManagerTest showed that a revoke which
commits nothing leaves the output in the open transaction for the next commit to publish without
its offset - the same defect through a different door. Declining is the deadline fallback, logged
at WARN with that cost named, never the fix.

Also: processWorkCompleteMailBox declares @ThreadConfined to the control thread with a runtime
assertion; the confluentinc#548 sleep spin and its ArchUnit exemption are gone; the revoke-drain
inflight note is retired into docs/solutions/logic-errors/the-revoke-path-commit-did-not-drain-the-mailbox-2026-09-07.md;
STRATEGY.md's account of the register is brought current. Collides with
#408 on tryCommitOffsetsOnRevoke; whichever lands second resolves it.

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
…on to #408

#466 merged while the burn-down was being written: the revoke-path
commit now drains the work mailbox first in transactional mode, its proof
left quarantine with it, and the README caution is gone. The burn-down
ticks it and moves it from "fixed in the queue" to "fixed on master".

Its commit body records a collision with #408 on
tryCommitOffsetsOnRevoke, to be resolved by whichever landed second. That
is now #408, so its tier 2 line says so - it was already the one PR
in the queue needing work rather than a merge.

The #473 line no longer orders itself after #466; it clears
the two quarantine entries that remain, which the release guard blocks on.

Claude-Session: 460f7df9-dcc2-4b00-a9f9-62f3a2c6d5e4
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
Each of these notes carried a PROPOSED vetting marker from the 2026-09-07/08
sweep. The owner accepted each proposal as stated; this applies them, and every
claim was re-checked against the tree at this HEAD before the edit, not against
the marker's own reading. Each surviving note's marker now records what was
applied and what was verified.

bug-shared-collections-across-the-poll-boundary.md - closed. Both named defects
are fixed on master: PCMetrics.registeredMeters is a final LinkedHashSet with
every add, walk and remove under @synchronized("metersLock") and close() walking
it through the never-throwing removeQuietly (758744b, #120 /
#57), and RemovedPartitionState.READ_ONLY_EMPTY_SET is
Collections.emptySortedSet() (6aae299, #267). The file is kept rather
than removed because static-infer-findings.md and
core-139-public-api-thread-safety-contract.md both cite it; its "Live on master"
headline is corrected and the branch section reduced to what is still true of
fix/concurrent-collection-sweep, which still exists on origin with no PR.

bug-torn-read-family.md - shrunk to the racing-double unification, the next hunt
iteration and the out-of-family pointers. All three candidates are fixed and
verified in the tree, so their dossiers were a FIXED narrative; they are
replaced by a summary plus a history pointer, with candidate 1's
bootstrapPhase-versus-dirty disjointness argument kept because it is the safety
argument for the encoder's single-sample read and is recorded nowhere else.

bug-177-commit-response-timeout-unreproduced.md and
bug-857-mirror-attributions-unconfirmed.md - each shrunk to its #175
half. #177 was closed as completed on 2026-09-01, one second after
#399 merged, with no comment naming either candidate mechanism - the
outcome both notes existed to prevent. That is recorded once, in the
attributions note, which owns the attribution question; the other
cross-references it. Candidate 3 is closed: #29 and #57 have
merged and PCMetrics.removeQuietly now swallows everything meterRegistry.remove
throws. The filename keeps #177 so citations of it survive.

bug-stale-sweep-iterator-evicts-fresh-replacement.md - shrunk to the
remove-by-key defect, which is live: removeStaleWorkContainersFromShard still
removes by key, so a fresh container the controller put at that offset is what
leaves. The mechanism sentence is re-anchored on removeWorkAtOffset, and the
#373 counter/stall block is removed - #336 (3e668a4) closed it
and the note's own last paragraph already contradicted its middle.

bug-857-transactional-revoke-wait.md - re-premised on #466. The unbounded
spin is gone: in transactional mode the revoke callback posts to the control
thread and waits bounded by commitLockAcquisitionTimeout
(commitOnRevokeViaTheControlThread). What survives is whether that bound is
right against confluentinc#803's complaint that it burns max.poll.interval.ms,
which #408 holds along with its RebalanceEoSDeadlockTest amendment. Four
stale citations repaired: the "no open PR addresses it" claim, the dead
fix/bound-revoke-transaction-wait branch, the renamed
core-recoverable-producer-fencing.md, and the two file:line citations, replaced
with greppable anchors.

bug-retry-queue-write-lock-on-the-rebalance-path.md - the defect is intact and
stays the subject; "not started, and no design agreed" is replaced with
#431, the open fix, which RetryQueue.remove's own javadoc already names
as the tryLock() path being added.

bug-wedged-after-poisoned-transaction.md - merged into
bug-poisoned-transaction-not-aborted-while-running.md, the survivor the owner
chose, and removed. Folded in: the answered recovery question and its answer
(isTransactionOpen() is producerState.equals(BEGIN), so lazyMaybeBeginTransaction
never opens a replacement and nothing in band aborts the poisoned one), the
observed wedge, the two mitigations, and the freeze-behaviour test suggestion.
Two citations repointed at the survivor; the third, in
process-candidate-ranking.md, names the merge itself and is correct as written.

bug-857-family.md - shrunk and retyped as a register, which is what its own
closing sections describe it as; the impact stays stall. The revoke-path
deadlock's mechanism section is retired per the file's own criterion now that
#29 has merged; the "reproducer cannot settle anything" section is
replaced with what the rewritten RebalanceEoSDeadlockTest and its purpose-built
sibling Rebalance857CommitSyncDeadlockProbeIT establish; and the fifth item's
claim that ProgressTracker.withDiagnostic has never been wired is corrected -
MultiInstanceRebalanceTest now supplies describeFleet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W511R2TZFAqEiqgS5yogTv
…e first sweep of the notes for v6 (#476)

The road to v6 needs the in-flight notes vetted - which are still real,
and in what order to read them - and nothing recorded when a note was
last confirmed against the tree. git log could not say: the package
rename of 2026-08-26/27 rewrote every file, so last-touched was the same
day for nearly all of them, and the only way to know which of the open
notes were still true was to read all of them.

Tooling (bin/inflight.mjs):
- `vet`: every open note on the baseline (or --ref a branch), partitioned
  on a new `<!-- inflight-vetted: YYYY-MM-DD - what was checked -->`
  marker, ordered by the index's group order and then oldest first by
  first-added date, each annotated with the cheap staleness signals a
  script can see - every cited fork number settled, the filename's number
  settled, a cited path or symbol that no longer resolves, a stated
  delete-when condition. A signal is a reason to open the note, never a
  verdict; the tool closes nothing. --area splits a sweep between agents.
- `rank` orders the rows inside each impact bucket oldest first rather
  than alphabetically (operator ruling on finding the buckets were in
  filename order). The session-start index keeps its path order.
- firstAddedDates in bin/lib/git.mjs, one git log for every note's
  first-added date; symbolsPresent tokenises the source in-process (112ms
  against 4.8s for the git grep it replaced); numbersByValue caches issue
  and PR states for a day, with the kind, since the two share a counter.
- bin/check-inflight-tags.sh validates the marker's shape and names a `>`
  inside it as the cause. Self-tests for the gate and eleven
  negative-controlled checks in bin/test-inflight.mjs.

Procedure: docs/grooming.md owns the sweep - when to run one, how it is
split by area, the dispatch prompt, what comes back and where it is
consolidated, and the traps the first sweep met. Routed from the root
AGENTS.md table, the "Vetting a note" section of docs/inflight/AGENTS.md
(the per-note contract: the marker, the five outcomes, the owner gate),
the release procedure, and vet's own output.

The sweep: six agents, one per area. Every open note on master now
carries a stamp or a state. Owner-gated notes - a bug at stall or worse,
the release record, a public-API contract - were not changed by the
sweep; each carried a PROPOSED marker with the evidence, consolidated and
ranked in process-candidate-ranking.md. The owner's pass on 2026-09-08
accepted every proposal: three closes, one merge
(bug-wedged-after-poisoned-transaction into its sibling), one retype
(bug-857-family is a register), and the shrinks and corrections, applied
by three further agents; #139 was ruled out of v6 scope and its
note deferred with its false volatility premise corrected. The register
keeps the agents' reading of what gates v6, stated with their confidence.

Overtaken by master mid-sweep: #466 landed the revoke-path drain
fix, deleting one note the sweep had read as a v6 gate and removing the
spin another described; both are re-premised here. Two out-of-area
passages the sweep proved stale are corrected in place - the ruleset
removals in docs/ci.md and the Jackson comment in .github/dependabot.yml
- and docs/refactoring.md drops an entry describing a defect the tree no
longer has.

Rejected: a numeric priority field. Impact is the coarse order, a second
axis would compete with it and rot the way the next- prefix did, and the
v6 cut is the deferred state.

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
…hecklist

#476 vetted every open note and touched five this branch also
edits. Three conflicted and each is resolved on the side that reflects
the later decision:

- release-0600-blockers.md: deleted here, modified there. Kept deleted;
  the two corrections the sweep made to it are folded into the
  burn-down's tag-day checks. One of them is only half right and is
  recorded as such: release.yml does build a notes file from the
  changelog (#72), but its exact heading match fails on the
  "(unreleased)" suffix and falls back to generated notes, which is what
  #199 fixes. The sweep's "the MDC gap is real" is refuted on the
  tree - MdcPropagation captures and restores the caller's context since
  #205 - so the burn-down keeps that correction.
- release-experimental-module-records.md: the 2026-09-07 deferral wins
  over the sweep's vetted marker, per the per-note contract (a deferred
  note carries the state, not the marker).
- release-when-is-v6-good-enough.md: the sweep's dated "the date passed"
  paragraph and its stale vetted marker ("the question is still
  unanswered") are dropped; the rewritten note records the decisions
  and names the failure mode in its own words.

Also carried from the sweep: the citation to the poisoned-transaction
wedge note is repointed to the sibling #476 merged it into; the
burn-down now points at the sweep's own "what gates v6" list in
process-candidate-ranking.md and records where the two disagree (the
sweep reads the poisoned pair as not gating, and the batchSize
validate() bound as the cheapest real fix, which could ride in tier 1);
and the #44 exception is narrowed - #466 bounded the
revoke wait, so what #408 still owns is declining rather than
waiting.

Claude-Session: 460f7df9-dcc2-4b00-a9f9-62f3a2c6d5e4
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
…s, and narrow the named exception

Three edits from the owner's 2026-09-08 pass over the burn-down:

- The vetting sweep's "what gates v6" reading moves out of
  process-candidate-ranking.md and into the burn-down, attributed as the
  agents' reading with their stated confidence, so the release has one
  note to burn down. Where it disagrees with the tiers the tiers now say
  so in the same place: the poisoned-transaction pair, the bound on the
  revoke wait, and the batchSize validation bound. The ranking note keeps
  a pointer and the ranking of everything else.

- Every known unknown is now being pursued, each by its own agent on its
  own branch. The note records the one scheduling decision that matters:
  code-shaped questions run in parallel, replay-shaped ones one at a
  time, because several chaos or soak replays on one machine produce the
  starvation artefacts they exist to rule out. The replay order is
  written down so it survives the session.

- The release claim's named exception narrows. #466 replaced the
  unbounded transactional revoke wait with one bounded by
  commitLockAcquisitionTimeout; what #408 still adds is declining
  rather than waiting. The release note says "bounded, not yet declined",
  which is what a user on that mode will actually experience.

Claude-Session: 460f7df9-dcc2-4b00-a9f9-62f3a2c6d5e4
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
astubbs and others added 7 commits September 8, 2026 14:29
…moves only the container it inspected (#468)

BREAKING CHANGE: RecordContext equality changes. Its Lombok @EqualsAndHashCode covers the
WorkContainer it wraps, so two contexts built from different containers for one record no longer
compare equal - a Set, a Map key or an equals check over record contexts that used to collapse them
now keeps both. Narrow in practice: ConsumerRecord does not override equals either, so two contexts
from two different polls were never equal; what changes is two contexts over the same ConsumerRecord
instance. WorkContainer itself is public in modifier only and is not part of the surface a user is
expected to hold.

THE DEFECT. The poller's stale sweep asked each occupant whether it was stale and then removed it by
KEY, with removeWorkAtOffset(entry.getKey()). The staleness answer is about one container; the
removal is about one offset. Between the two statements the occupant can change, and it does: the
sweep runs on the broker-poll thread inside a rebalance callback, while addWorkContainer's
stale-replacement branch runs on the controller, and nothing orders them.

The harm is a LOST RECORD. The fresh container is evicted while PartitionState still carries its
offset as incomplete, so nothing selects it and nothing completes it - the commit high-water mark for
that partition cannot advance past it until the partition is re-polled. The accounting half was
already closed (#336, #373), so the counters settled correct while the record was being
lost: correct counters are not evidence that the right object left. It is the same
fresh-replacement-at-one-offset class as the confluentinc#909 write-up, reached from the other side.

THE FIX IS IN THE VALUE TYPE, NOT THE REMOVAL. Map.remove(key, value) is the JDK's
compare-and-remove, and it decides "still mapped to the value I inspected" with equals - so what
that call MEANS is a property of the value type and not of the map. WorkContainer.equals was topic,
partition and offset only, so a stale container and its fresh replacement compared equal and no map
API could express which of the two was meant. The equals/hashCode pair is deleted, equality is
reference identity, and workMap.remove(offset, inspected) is a true compare-and-remove: it evicts
the container the sweep inspected or nothing.

Nothing evicted is a correct outcome, not a failure: the replacement won the offset, so the call
retires nothing and REPORTS nothing. ShardManager.removeStaleContainers feeds the returned list to
the retry queue, and #437 pinned that the queue removal is reached only through a real shard
removal; reporting a container this call did not remove would take the retry entry at coordinates
the fresh container now owns. getWorkIfAvailable's last-resort stale removal has the same shape and
is written the same way, with a dated cleared-suspicion note saying why the race is not reachable
there today and what would reopen it.

REJECTED, and why each would have shipped looking correct:

- computeIfPresent with an identity check in the remapping function. ConcurrentSkipListMap commits
  the removal through doRemove(key, v), which RE-READS the node value and gates on
  value.equals(reRead) before its compare-and-set, so with offset-only equality the identity test
  does not survive to the commit. Reproduced: a put landing inside the remapping function left the
  map EMPTY under offset-only equals and kept the replacement under identity equals. It narrowed the
  window to a few instructions inside the JDK and closed nothing. Under identity equality the same
  arm shows the gate declining directly: the remapping function runs TWICE, the first pass planting
  the replacement and asking for a removal that doRemove refuses, the second pass being handed the
  replacement. That is recorded at the site rather than asserted, since an implementation reaching
  the same gate without looping would be equally correct.
- A ProcessingShard.Residency token as the map value, overriding neither equals nor hashCode. An
  earlier revision of this branch shipped it. It bought exactly one correct removal and left the
  value type wrong, so the next value-conditional site would have had to remember to wrap. Replaced
  before merge on the maintainer's call: 0.6.0.0 is the breaking release and already carries a
  Breaking section.
- A remove-then-put-back repair, and a claim the sweep takes before removing which the writer must
  wait out. Both reintroduce a window, one in the map and one on the controller.

THE OBJECTIONS THAT HAD QUEUED IDENTITY EQUALITY AS FUTURE WORK, each checked and each fallen:
Comparable only RECOMMENDS consistency between compareTo and equals; SortedSet and SortedMap REQUIRE
it, and nothing in main code puts a raw container in either - RetryQueue sorts by its own
WorkContainerSortKey and de-duplicates by its own WorkContainerKey, so compareTo and its comparator
are unchanged with the inconsistency stated in the javadoc. The one collection keyed on containers
elsewhere, ExternalEngine.holdingDispatchPermit, has been an IdentityHashMap-backed set since
#342, so identity equality makes the container agree with that code rather than changing it.
The public break is RecordContext, stated above.

THE OTHER HALF OF THE CONTRACT IS ENFORCED, NOT WRITTEN DOWN. Nothing puts a raw container in a
sorted collection today, and both RetryQueue and ProcessingShard.workMap are one refactor away from
being written the other way round with nothing going red. WorkContainerIsNeverInASortedCollectionArchTest
reads every field, return type and parameter in main code whose declared type is a sorted set, sorted
map or priority queue and whose FIRST type argument - the element-or-key position for all of them -
is WorkContainer, so a container in a sorted map's value position passes. Proved red on main code
with a temporary TreeSet<WorkContainer> field on ProcessingShard, which reported that field and not
the workMap declared beside it. A standing positive control, in the RebalanceCallbackRuleControlTest
shape, hands a hand-imported fixture to the real rule object so the rule cannot go quietly blind.
Its limits are in the test javadoc: a body-local collection is invisible, the comparator is invisible
so a legitimate Comparator-taking construction is still reported, and it covers core's classpath -
measured, no main source outside core declares any of those types.

EVIDENCE. ShardStaleSweepReplacementEvictionTest drives the seam deterministically rather than by
racing: ShardSeamTestBase's spied PartitionStateManager lands the controller's replacement at an
exact instruction inside the sweep. The defect arm was red against master. Restoring the old
coordinate-based equals/hashCode and changing nothing else sends the defect arm and the premise arm
red - "expected specific instance ... but was: null" and "expected not to be" the same coordinates -
while the control arm stays green, as it must: a sweep with nothing racing it never has to say which
of two containers it meant. Restoring removeWorkAtOffset(entry.getKey()) in the sweep sends exactly
the defect arm red. The premise arm, twoContainersAtOneOffsetMustNotBeInterchangeable, asserts the
equality contract directly and is the tripwire for a reintroduced coordinate-based equals.

TREE-WIDE SWEEP for what identity equality breaks: one test assertion, ShardManagerTest's
isNotEqualTo between containers at different offsets, became vacuous and is restated against
RetryQueue.WorkContainerKey, which is what the queue keys on. PollContext.getByTopicPartitionMap's
Set de-duplication becomes a no-op with no observable change, said in an @implNote at the site.
Checked and found empty: no ArgumentCaptor or Mockito matcher over a container, no Truth containment
assertion over containers except on the same instance, no container as a Map key or Set element in
any test helper, and every Lincheck harness returns primitives from its operations in stress mode.

SAME-DEFECT-CLASS SWEEP over core main, with a correction to this branch's own earlier result: an
earlier commit reported holdingDispatchPermit as a real unfixed hit and opened an inflight note for
it. That defect does not exist - the sweep matched the declared type Set<WorkContainer> and stopped,
and a collection's semantics live in its initialiser, not its declaration. The note is deleted and
the lesson is in the solutions write-up. Dismissed with reasons: ProcessingShard.onSuccess,
removeWorkAtOffset from the revocation path, RetryQueue, the metrics and counter maps,
OffsetSimultaneousEncoder.activeEncoders, MDC.remove.

CI SIGHTING RECORDED, NOT DIAGNOSED. Chaos Pain Suite shard 4 went red once on
ChaosChurnStormIT's ZOMBIE_MEMBER/REBALANCE_BLOCKED arm with maxInstanceStall=0ms; that line has an
open ledger on master, and seed 1053013618367208111 is appended to
docs/inflight/test-857-churn-storm-async-stalls.md because the seed dies with the CI log. Nothing on
the rebalance path reads WorkContainer.equals, and the shard was green on every later push.

DOCS. docs/inflight/bug-stale-sweep-iterator-evicts-fresh-replacement.md is resolved and deleted,
its mechanism and rejected alternatives migrated to
docs/solutions/logic-errors/a-by-key-removal-cannot-say-which-container-it-meant-2026-09-07.md,
cross-linked with the confluentinc#909 write-up. docs/refactoring.md's queued-breaking-change
section now states that an entry is deleted in the PR that lands it, because the commit message
carries the release-note content and the section lists only what is still queued; the entries
marked DONE there are removed, and the two citations that pointed at this branch's own entry -
WorkContainer's class javadoc and the solutions write-up - name this commit as the record instead.
docs/inflight-tool.md used the resolved note as the worked example for the vet command; the example
now names a surviving note with the same data-loss signal, bug-run-length-plausibility-ceiling.md.

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
…he stall its first runs found (#471)

#175 (confluentinc#809) has reported `Timeout waiting for commit response` for months and
had never been reproduced, by anybody. docs/inflight/bug-177-commit-response-timeout-unreproduced.md
says so in its own title and names the experiment that would change that. This is that experiment,
the opt-in lane it runs in, and its first two runs - which found a different stall instead.

THE SCENARIO. CommitResponseTimeoutSoakIT transcribes the application code #177's reporter
posted in full - that report is closed and is kept only as the source of the shape, since it is the
cheapest route to the wedged-poller question #175 still poses: KEY ordering over a thousand
distinct keys, PERIODIC_CONSUMER_SYNC at a one-second commit interval, maxConcurrency 14, a ~100ms
user function, and half the records throwing. One instance, no churn - the report involves no
rebalance, which is the deliberate difference from every other scenario in the chaos package.

The load-bearing choice is that a poisoned record throws on EVERY attempt, not on about half of
them. The reporter's flag is a per-record header set on i % 2, so a marked record never succeeds;
under KEY ordering it head-of-line blocks its key's shard and pins that partition's committed
offset for the rest of the run. That is what makes "runs for a while and then exits" an
ACCUMULATION rather than a startup race; a per-attempt coin flip would let every record eventually
succeed and the thing being hunted would never build up. The per-attempt shape is a separate arm.

It asserts exactly one thing - that no commit-response timeout occurs - and wires no ProgressProbe,
because this workload pins its own commit watermark by design and the Class 2 lag detector would
observe continuously while saying nothing about the question. Classification is already in the
product: since #204 a message naming the poll thread's death is #100's class and a
bare timeout is the poller wedged-but-alive, carrying PollThreadStallDiagnosis's BLOCKED-vs-SLOW
verdict. The scenario adds the half the product cannot cover - a full JVM thread dump at the moment
of detection.

THE LANE. The soak tag is new and is in no suite at all: it is in the pom's excluded.groups and in
every gating wrapper. It could not join chaos, whose scenarios are calibrated detectors that gate
every PR and whose local recipe would pick a half-hour class up. docs/testing.md gains the lane,
what a soak result is (a rate under conditions, never a verdict), where it gets recorded, and that
this lane is not bin/soak-test.sh, which repeats a short test under CPU contention to measure a
flake rate.

THE RUNS, recorded as rates with conditions in the inflight note and the scenario's Calibration
status. Two thirty-minute runs, zero timeouts - and neither is a sighting-ledger entry, because in
both the assertion could not have failed after the first minute:

    failureFraction 0.5 (#177's reporter's)  seed 3747722682837130843
        90,000 produced, 451 succeeded, 237,006 failed, no findings
    failureFraction 0.03                            seed 5055695573431537469
        90,000 produced, 2,372 succeeded, 81,114 failed, no findings

WHAT THEY ACTUALLY MEASURED. Successes froze inside the first minute of each run and never moved
again, while the producer kept publishing and the failure count climbed at a rate that then held
exactly constant. A constant retry rate against a frozen success count means no new record is being
taken as work at all: the instance has stopped, not merely slowed. A stalled instance cannot reach
the exception being hunted - only PartitionState#onSuccess marks a partition dirty, onFailure is an
explicit no-op, and the control loop gates commits on isDirty, so with no success anywhere no commit
request is enqueued and ConsumerOffsetCommitter#commitAndWait, the sole thrower of the message, is
never entered. A green assertion here cannot tell "no timeout occurred" from "no commit was
attempted". This is the dirty asymmetry upstream-tell-809-833-the-hang-is-fixed.md names for this
very workload. The 0.03 arm is the control that makes the fraction the wrong knob rather than a
guess: it bought about 1.5 extra bursts and stalled identically. Duration is the wrong knob too.

ONE MECHANISM ELIMINATED, ONE NAMED. Offset-encoding back pressure is eliminated:
PartitionState#updateBlockFromEncodingResult logs every transition and neither message appears once
in either log. The named, untested candidate is the load gate - WorkManager#isSufficientlyLoaded
compares inShards minus parkedForRetry against the in-flight target, and inShards counts records
queued BEHIND a blocked shard head while only the failing head is parked. That is the silent-stall
shape the gate's own comment names against confluentinc#857. Stated as a hypothesis; the next arm
reads the gate's own DEBUG line and settles it in one run. The stall may be the better lead than the
timeout: confluentinc#833's reporter showed pc_processed_records_total FLAT across the window in
which their timeout fired, which is this state, not a busy one.

TWO DEFECTS IN THIS BRANCH'S OWN FIRST COMMIT, both silent. The soak tag had been added to the
comment above excluded.groups, to the javadoc and to docs/testing.md, and not to excluded.groups
itself, so a six-hour scenario sat in the GATING integration lane - measured, not inferred: this
PR's own Integration Tests job ran 35 minutes with the scenario's banner in its log.
QuarantinedAnnotationContractTest could not have caught it, since its list checks compare the pom
against the wrappers and a tag missing from both is consistent; it now also asserts membership of
the named opt-in lanes (pomExcludesEveryLaneTooLongToGateAMerge). And the documented run command
could not run: -am builds the parent module first and failsafe fails the reactor there before core
is reached, the trap bin/chaos-test.sh's header already owns. Both copies of the recipe now pass
-Dfailsafe.failIfNoSpecifiedTests=false and say to read the scenario's banner out of the log before
believing a green, since that flag lets a run selecting nothing exit 0.

WHAT MASTER SETTLED WHILE THIS WAS OPEN, reconciled in the merge rather than left contradictory.
The inflight vetting sweep shrank the note to the #175 half, closed the throwing-metrics
candidate against the tree now that #29 and #57 have merged, and recorded the AB-BA
wedge in upstream-175-sporadic-commit-timeouts.md as fixed by #29. So this branch's earlier
reading that candidate 2 was "owned by #29, not unowned" is withdrawn: candidate 2, the
poller wedged but alive, stays the open one with no named mechanism inside it - which is why the
intake stall these runs found is the more promising of the two leads. Arm 3 of what to run next no
longer nominates a "better wedge candidate"; it survives as the closest recorded configuration to
the live report. The note's opening sentence that no reproduction attempt existed is corrected to
name this one.

Static analysis on lines this PR wrote: StringCharset fixed with StandardCharsets.UTF_8; the
deprecated RandomUtils.nextInt() deliberately kept, because it is the topic-naming pattern in three
sibling chaos scenarios and changing one of four leaves the odd one out, so docs/refactoring.md
carries one item covering all four. Prior art recorded in the note: the unmerged branch
test/177-commit-response-timeout, which confirmed the #100 trigger reproduces when the catch
is removed and records one falsified experiment (moving brokerPollSubsystem.supervise() earlier in
controlLoop() does not help).

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
…nswers, not when it is sent (#470)

Under PERIODIC_CONSUMER_ASYNCHRONOUS - the shipped default commit mode - AbstractOffsetCommitter
marked the partition clean on the line after commitOffsets() returned. commitSync and the
transactional producer return only once the broker has answered, so that marking is a statement
about something that already happened. commitAsync does not: it returns as soon as the request is
handed to the client and answers later through an OffsetCommitCallback. So the offsets were recorded
as durable at the moment of SENDING, and the callback was logged and nothing else, carrying a
"todo keep work in limbo until async response is received?" from years ago.

WHAT THAT COST. Marking clean ends the story: nothing is dirty afterwards,
collectCommitDataForDirtyPartitions() returns empty, and no further commitAsync is ever issued for
those offsets. A callback arriving with a RetriableCommitFailedException had nothing left to retry,
and a callback that never arrived was indistinguishable from success. The broker's committed offset
stayed behind PC's belief, silently and permanently, and the pc.partition.latest.committed.offset
gauge read an offset the broker never had, so whoever owned the partition next resumed from a
position the broker never recorded. Silent loss, in the default mode.

THE FIX, inside the async branch. AbstractOffsetCommitter gained
commitOffsetsReturnsOnlyOnceAcknowledged(), true by default, and marks success inline only when it
holds; onOffsetCommitSuccess became protected so a committer answering false can call it on its own
acknowledgement. ConsumerOffsetCommitter returns isSync() from it, and its async branch registers
onAsyncCommitAnswered: success records the offsets, a failure logs and leaves them dirty for a later
request to carry, and a dropped callback does the same by doing nothing at all. A failure is a
DEFERRAL and lands where the synchronous path's rejections already land - logged, not fatal, offsets
still marked as needing a commit - which is the option commitDeferringOnRebalance()'s javadoc argues
for, reached from the other direction. Only an acknowledgement advances the state.

THE HAZARD THE FIX CREATES, AND THE ONE RULE FOR IT. Deferring the clean mark is what makes two
async commits able to be in flight at once, so an answer can arrive for a request a later one has
partly overtaken. The whole of the handling is one rule inside PartitionState: getCommitDataIfDirty
remembers the offer it makes, WHOLE, as the OffsetAndMetadata it handed out; onOffsetCommitSuccess
records every acknowledgement - the answer is true, the broker committed up to it, and
recordCommittedOffset keeps the higher of the two when answers arrive out of order so the gauge
cannot walk backwards - and marks the partition clean only when what was acknowledged equals that
offer. Whole rather than the offset alone, because completing a record above the lowest incomplete
one changes the encoded metadata and moves the offset not at all, so two requests can be in flight
at the same offset with different metadata; compared on offsets, the older answer would clean the
partition and the newer request's metadata - the only record that the higher record is done - would
never be re-sent if that request failed. Comparing the whole object is exact, since commitAsync hands
the callback the very map it was given and the transactional path calls onOffsetCommitSuccess inline
with the map it just collected. Every edge lands on the dirty side, which costs at most one extra
commit and cannot under-report; a partition state rebuilt by a rebalance has no offer recorded, so a
stale acknowledgement cannot clean it either.

The committer keeps no record of what it has in flight, and that is the point. Two earlier
revisions put the decision in the committer - a sequence number that ignored any answer that was not
the latest, throwing away a fact the broker had established, and then a per-partition in-flight map
with a second success method plumbed through four classes - each a copy of state the partition
already held. Both were replaced on the maintainer's call by the one rule above; the per-partition
outcome they implemented now falls out, because each partition made its own offer and each partition
decides. setClean's existing commit-window protocol is untouched, and the counter protocol landing
separately for the commit-window pair is deliberately not pulled in.

THE FAILURE LINE IS ONE LINE, and its level is the classification: can a later request fix this?
Transient - RebalanceInProgressException, CommitFailedException, RetriableException - is a WARN
saying the partitions stay dirty and are committed when a later request is acknowledged. Anything
else is an ERROR saying every later request fails identically, the committed offset stops advancing
and the retries will not succeed without intervention; logging that at WARN with the deferral promise
attached would tell the operator to wait for something that will not happen. The classification is
the synchronous path's own - commitDeferringOnRebalance() catches exactly those two exceptions and
lets the rest escape - so the two modes agree on which failures are routine. Neither level escalates:
the async mode has no commit budget and cannot reach the commit-failure seam (#317,
#352), whose options validation rejects a handler under this mode, and throwing from a
broker-poll-thread callback strands every waiting committer. The #168 (confluentinc#629)
bound applies to both lines: offsets summarised, the map one level down at DEBUG, each level with its
own text budget so neither can become the door the offset map comes back through.

THE FIELD'S THREAD MODEL. offerLastMadeForCommit is written in the commit path and read back in
onOffsetCommitSuccess by the same thread in every commit mode - the broker-poll thread under both
consumer modes, the control thread under the transactional mode, which #466 made uniform -
with Consumer#close() flushing a pending callback as the one hand-over, carrying a happens-before
edge. It is plain, for the reason the dirty field's javadoc gives from jcstress measurements, and
docs/refactoring.md records it with stateChangedSinceCommitStart as one lifecycle to be declared
together if anyone does the thread-confinement work. Only the latest offer is kept, assigned rather
than raised to a maximum, because maybeTruncateBelowOrAbove's downward reset on a bootstrap poll
would leave a maximum unmatchable and the partition dirty forever.

EVIDENCE. Two red tests landed first, one per way a sent request never becomes a durable commit:
MockConsumerAsyncCommitCallbackFailsTest and MockConsumerAsyncCommitCallbackDroppedTest, asserting
the broker's own committed offset because every weaker assertion passes on the defect. Both control
arms flip exactly the new predicate: forced true, both async tests fail with commit attempts stalling
at two of three withheld acknowledgements and the sync control passes; forced false, the sync control
fails because attempts never stand still and the async tests pass. That third arm is why
MockConsumerSyncCommitMarksCleanOnReturnTest exists. PartitionStateAcknowledgedCommitOffsetTest pins
the rule on a real PartitionState, including the same-offset-different-metadata case that was red
before the offer was held whole, and ConsumerOffsetCommitterOverlappingAsyncCommitTest drives the
three orderings through the committer over a real WorkManager with two captured callbacks - the
MockConsumer answers inline, so it can never reach the overlap. Reverting only the PartitionState
rule sends four of those seven red, each naming the partition marked clean at the older offset
while the higher offer was still outstanding.

OTHER INSTANCES of the class - state advanced on request rather than on acknowledgement - checked
and dismissed: the produce path blocks on the send futures before the batch is marked succeeded;
the transactional commit is blocking, so the base default is right for it; ConsumerManager.commitSync
is blocking and already refuses to return normally when close aborted its retry loop; Vert.x,
Reactor and Mutiny all mark success from the completion signal, with ExternalEngine suppressing the
dispatch-time marking for exactly this reason. None found.

DOCS. The inflight note recording the defect is retired to
docs/solutions/logic-errors/an-async-commit-was-recorded-on-send-not-on-acknowledgement-2026-09-07.md,
which records both committer-side designs this replaced so nobody re-proposes one. Three verbatim
await-and-read blocks are hoisted into MockConsumerTestBase with their deadlines as arguments,
narrowing that class's no-hoisted-assertions decision where it is made; the commit-attempt assertion
stays per file because the families assert opposite things with it. docs/inflight/issue-response-248.md
carries the draft response the directory requires and is deleted when it is posted. The upstream
manifest entry is updated at the lifecycle transition. This does not close #248: the
reporter's symptom was a throughput collapse with endless retries, and nothing here reproduced that;
the fix removes the silent-loss path the report exposed. The chaos ledger's three async-mode stalls
recorded as explained by no known defect now have a candidate, explicitly not an attribution.

Upstream-Issue: confluentinc#203
Upstream-PR: confluentinc#345
Forwarded: no
Applied-Upstream: no

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
…ut, not by waiving them (#473)

Clears the quarantine registry by fixing what its entries were about, not by waiving
them. With the third entry fixed on master by #466 in the
meantime, no @Quarantined annotation remains in the tree, and the release workflow's
"no release while tests are quarantined" block clears for 0.6.0.0.

RegistrationRaceStaleResidentIT was quarantined by #440 on a
sighting ledger with no mechanism: it failed for a fortnight across unrelated branches,
always on its own SETUP guard ("control thread must reach the mid-loop pause point
(offset 25)"), never on the confluentinc#909 assertion it exists to make. The mechanism
is the test's staging, not PC. The test saturates the pipeline by gating the user
function so nothing ever completes, which makes whatever stage 1 produced permanent;
stage 1 produced four records more than the out-for-processing target, so records in
shards sat permanently over WorkManager.isSufficientlyLoaded()'s threshold, the poller
paused the partition for back pressure, and neither resume path can fire while the gate
is closed. The stage-2 records carrying the pause point were never fetched. It passed
at all only by racing a consumer.poll() already in flight when the gate closed.

Control arms, predictions stated before running: waiting on the load gate did NOT
reproduce it (the gate closing is not the partition pausing - the refuted arm located
the window); waiting on the pause reproduced the exact CI signature every time;
producing exactly the target passes every time; and with stale replacement disabled
the repaired test still goes red on the 909 signature, naming the same 25 keys the
original write-up recorded. The fix derives the stage size from the buffer size so the
two cannot drift, asserts the intake precondition before the second produce instead of
leaving it mute, and reports the intake state after the wait so a future failure says
which of "slow broker" and "records never fetched" it was. Rejected: a bigger buffer
(same knife-edge), producing everything up front (saturation could never be
established), and loosening the guard (which would have hidden that the records could
not arrive at all).

MultiInstanceRebalanceTest.largeNumberOfInstances leaves the registry for a different
reason: nothing about it was repaired, because nothing is broken.
#444 measured its residual failure as the Kafka consumer group
protocol under the profile's churn rate, so its legitimate output is a pass rate, and a
required check that fails one run in fifteen for a reason no change to PC can move gates
nothing. All three capacity profiles now carry @tag("capacity"), excluded by the
required Performance Tests lane and by the pom's default exclusion and the CI wrappers'
copies of it, and still selected by the scheduled experiment runners - verified by what
each lane actually selected, not by the flags looking right. The two profiles that had no
scheduled runner get one on the same weekly cadence, sharing the iteration loop in
bin/lib/chaos-experiment-common.sh so the classifiers cannot drift, and the workflow's
collect step scans their tally directories. A latent bug the tag split would have
introduced silently is fixed on the way: getAnnotation(Tag.class) returns null once
@tag is repeated, which would have reclassified every capacity profile as a
correctness gate. The deterministic correctness twin,
scriptedChurnRoundsCompleteWithoutStall, is untouched and keeps those code paths gated.

The residual-failures note, which said its own subject had been acted on, is migrated to
docs/solutions/test-flakiness/ and deleted with every citation repointed; the solved
flake leaves the open flake register for its own write-up. Not done, and named: the
capacity rate is still a per-batch tally, not compared against a rolling median.

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
 wins the transactional revoke path

Master landed #466 (the revoke-path commit hands itself to the control thread), #468
(WorkContainer equality is identity) and #471, and this PR went CONFLICTING. #466 is a
different answer to the question this branch answered: in transactional mode onPartitionsRevoked no
longer commits on the poll thread at all - it posts a request, wakes the control loop through the
mailbox, and waits, bounded by commitLockAcquisitionTimeout; the confluentinc#548 spin is gone; and
it refutes this branch's decline by experiment (a revoke that commits nothing leaves its output in
the open transaction for the next commit to publish without the offset - the same duplicate through
a different door). Master's own re-premising of docs/inflight/bug-857-transactional-revoke-wait.md
says what is left for this PR: not the absence of a bound, but whether the bound is the right value
- five minutes, against a max.poll.interval.ms it can exceed.

So the resolution is master's on that path, per file:

- AbstractParallelEoSStreamProcessor: master's onPartitionsRevoked, commitOnRevokeViaTheControlThread
  and the no-argument consumer-commit tryCommitOffsetsOnRevoke replace this branch's parameterised
  decline, its performCommit extraction and its post-catch wake (moot: the served commit runs on the
  control thread, which recovers itself); commitOffsetsThatAreReady is master's again; the mailbox
  loop keeps master's wake-up message skip in front of #410's first-failure try; one of two
  identical assertOnControlThread helpers (#410's and master's) is kept - master's, which
  names the new design.
- RebalanceEoSDeadlockTest: master's whole file. Its unamended assertion that committed offsets
  advance inside the callback holds again by construction under #466, and it now reads the
  output topic at read_committed for duplicates; this branch's decline amendment is superseded.
- ArchitectureTest: master's whole file (#465). This branch's interface-hop widening does not
  merge onto it; its note now records that the blind spot is still open on master and the widening
  is to be re-applied on top of #465 as its own change.
- ProducerManagerTest: master's revoke-request tests and this branch's five revocation tests, both
  kept; PartitionState: master's onSuccess(long) javadoc with #410's ledger cross-reference
  folded in; TransactionalClaim: master's scope note plus #410's C15.
- config/infer-known-findings.txt, bug-857-family.md, core-recoverable-producer-fencing.md: master;
  bug-wedged-after-poisoned-transaction.md: master's deletion (the grooming sweep); the three vetted
  notes keep master's markers and this branch's concurrency label; test-untracked-ci-flakes keeps
  master's rows and this branch's three later sightings.
- ProducerRecoveryTest's revoke-path fence test is retargeted to the served-commit shape: the fence
  now fires on the control thread inside the served pass, the callback returns promptly on the failed
  pass, nothing is stranded, and the replacement is built. Its wake assertion is gone with the wake.

What is red, on purpose: Revoke857TransactionalWaitProbeIT, 5/5, with the callback at 19.2s of a 20s
in-flight dwell against its 10s poll-interval budget - and never the 79s starvation the spin
produced. That is the measurement of #466's bound, and it is this PR's remaining acceptance
test, not a broken instrument. What is now dead main code, held for the owner's call: the three
ProducerManager revocation lock helpers and the DeclineCountingProducerManager instrument, which
count a decline the transactional path no longer makes.

Verified: ProducerRecoveryTest, ProducerManagerTest, ProducerManagerDetectionTest, ArchitectureTest
and the convention rules, PartitionStateAbortedTransactionReplayTest, TransactionalClaimCoverageTest;
RebalanceEoSDeadlockTest 5/5, ProducerFencingRecoveryIT 2/2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVL2FEJ645T76PbybEBUZ6
…3-bound-transactional-revoke-wait

The base this PR is stacked on moved again - its own master merge and a re-cut into five rungs -
and GitHub reads the PR against that base. Four conflicts: the infer ratchet takes #410's
one new entry (the lane is the judge); the revoke-drain note takes master's retirement into
docs/solutions; the flake ledger's rows were identical on both sides; the transactional claim
register keeps master's newer wording of the same scope note ahead of #410's C15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVL2FEJ645T76PbybEBUZ6
 and #473

Two conflicts. PartitionState: #470 counts an async commit as committed only when the
broker answers the latest offer, and #410's ledger trims on commit success; joined so the
ledger snapshots before the offer is made and is trimmed only on the answer to that offer - a stale
answer to an older offer leaves the newer snapshot in place, so a replay can only over-replay, never
under. The flake ledger takes master's updated rows and #473's retirement of the section it
fixed, and keeps this branch's three later sightings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVL2FEJ645T76PbybEBUZ6
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

[superseded - the quarantine lane is now empty] 🧪🔒 Quarantine Lane Report

The superseded report, collapsed because it no longer applies
Quarantined test Outcome Owner Meaning
MultiInstanceRebalanceTest.largeNumberOfInstances ⚪ not run ⚠️ unowned report missing - check the lane job
RegistrationRaceStaleResidentIT.freshArrivalCollidingWithStaleShardResidentMustStillGetProcessed 🟡🎲 passed (flapper) ⚠️ unowned proves nothing - passes most runs by nature

🔴 expected while the owner PR is open · 🟡🎲 flapper, pass proves nothing · 🚨 a deterministic quarantined test passing means its fix landed: delete its @Quarantined annotation + docs/quarantined-tests.md entry (a merge-blocking review thread has been opened). Lane: non-gating; rules: see the Quarantine Audit check.

Since the previous push: MultiInstanceRebalanceTest.largeNumberOfInstances: 🟡🎲 passed (flapper) → ⚪ not run.

Updated for 4ceb4f3 · run 34183058976 · 2026-09-08 03:20 UTC

Superseded by a newer quarantine lane report.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

🧪🔒 Quarantine Lane Report

The quarantine lane is empty - no @Quarantined tests remain, so none ran.

Any earlier row on this PR asking for a @Quarantined annotation or a docs/quarantined-tests.md
entry to be deleted is withdrawn - there is nothing left to delete. A merge-blocking review
thread, if one was opened for that row, still has to be resolved by hand.

Lane: non-gating; rules: see the Quarantine Audit check.

No quarantined test changed outcome since the previous push.

Updated for f856852 · run 34185865261 · 2026-09-08 04:07 UTC

…ller collects what it leaves (#481)

WHAT CHANGED FOR A USER: a rebalance can no longer be delayed by however long the control loop's
retry-queue scan takes, in any ordering or commit mode. `RetryQueue.remove` takes an unbounded, FAIR
`writeLock().lock()`, and every Kafka rebalance callback reached it on the broker-poll thread - by
direct call through `ShardManager.removeWorkFromShardFor` on revoke and lost, and by the method
reference `.map(retryQueue::remove)` through `ShardManager.removeStaleContainers` on all three,
assigned included. The controller thread holds the matching READ lock for a whole scan
(`RetryQueue.iterator()` hands it out; `ShardManager.getLowestRetryTime` closes it only after a full
iteration), and a fair lock queues an arriving writer behind that scan rather than interleaving. The
wait sits inside `consumer.poll()` with the whole group waiting, spent out of `max.poll.interval.ms`.
No cycle was found: an unbounded wait, not a deadlock. No fork issue exists; found by the
confluentinc#857 defect-class sweep (#29).

THE FIX: THE POLL THREAD NEVER TOUCHES THE RETRY QUEUE. The callbacks remove from the SHARDS only - a
`ConcurrentHashMap` of `ConcurrentSkipListMap`s, no lock to wait for and so nothing to decline.
`ShardManager.purgeDepartedRetryEntries()` then collects, on the controller thread where waiting is
allowed, every retry-queue entry whose container `ProcessingShard.isResident` says has left: scan
under the read lock, close the iterator, remove the collected entries under the write lock in one
`removeAll`. The invariant, stated once at the purge: a retry-queue entry with no resident container
is garbage the controller collects; it may exist for at most one control-loop tick; the callbacks are
free to create it.

Where it runs: at the top of `ShardManager.getWorkIfAvailable`, the one point the control loop reaches
unconditionally every pass in both RUNNING and DRAINING, and before `drain()` reads
`getNumberOfWorkQueuedInShardsAwaitingSelection` in the same pass - the consequence that matters,
because #437 established that a queue-only orphan surviving that figure holds
a draining close open to its timeout. The bound is one tick in every ordering mode: the purge scans the
queue rather than the shards, so the ordered modes' shard-scan break cannot delay it.

#468 landed while this was open and settles by construction what this branch argued:
`WorkContainer` equality is now identity, so the controller-side stale branch's compare-and-remove evicts
only the container it inspected, and the purge's by-key removal no longer rests on the single-writer
argument alone.

Why scan and remove may be two steps: departure is monotonic (`isResident` is reference identity and
nothing re-inserts the same instance), and the queue has exactly one writer - `RetryQueue.add` is
`@ControllerThreadOnly`, its only production caller is `ShardManager.onFailure`, and the purge runs on
that same thread. A second writer is what would reopen this, and both sites now say so: a `false` from
a removal there can only mean one exists.

THE DESIGN IT SUPERSEDES, NAMED. #431 kept the poll thread on the queue and
made it DECLINE: `tryRemove`, abandon the paired shard removal on refusal, and after
#437 a second ask after the shard removal with a put-back on refusal. It was
correct and proven, and it lost on machinery: a second non-blocking entry point keyed differently from
the blocking one, a queue reference threaded into a second `ProcessingShard` method, two asks per
removal, and a retirement bound that varied by ordering mode. Also rejected: reusing
#466's control-thread hand-off, which has the poll thread WAIT on a future -
right for a revocation commit, wrong here, because the purge needs no hand-off at all.

COUNTERS DURING THE WINDOW, RE-MEASURED. Between a callback's shard removal and the purge,
`getWorkableRecords` reads one LOW - the safe direction: `isSufficientlyLoaded()` reads false and the
poller fetches sooner; high would have paused it with nothing to process. `WorkManagerTest`'s
revocation arm pins the direction. #437's post-add residency confirmation in
`ShardManager.onFailure` becomes belt-and-braces and is kept this round, its javadoc saying so.

THE STATIC GATE GOES GREEN ON MERIT. `ArchitectureTest.rebalanceCallbacksMustNotBlock` (widened in
#465 to follow method references and `@ControllerThreadOnly`) carried
eighteen exemptions for exactly these reaches. With the set emptied and the callbacks still reaching
the queue it reports every route; with the callbacks off the queue it is green and the set stays empty.
The set, its `root => target` keying and the argument for keeping it remain - an exemption is how a
newly found reach is recorded as a defect on the books.

EVIDENCE, AS SHAPE AND REPRODUCE COMMANDS. `RetryQueueRebalancePathTest` holds the read lock the way
the controller does - a live iterator on another thread - and drives the real callback on a thread
named `broker-poll`: red on unfixed code, both callbacks parked on the write lock until the deadline;
green after, with the departed entry collected on the next controller pass, a queue-only entry no
longer holding drain open, the ordered-mode arm collected on the same tick, and a still-resident entry
left alone as the control. Ablations: deleting the purge call reds arms across three classes;
inverting the residency predicate reds the same plus the control. Two arms from #437's window test are
re-stated rather than deleted, because the sweep they described stopped existing. The contended harness
was then folded into the shared `BlockedThreadAsserter` (a `returnsWithin` idiom that returns the fact
rather than asserting it, so a caller can hold the contention while it reads state), re-proved red by
restoring the queue removal to both callback paths. The three `DLS_DEAD_LOCAL_STORE` findings this
branch's own lines introduced are cleared by using the values - logged, or a `void` return - rather
than parking them.

Reproduce: `bin/ci-unit-test.sh`; `bin/lincheck-test.sh`; `bin/check-all.sh`;
`./mvnw -Pci -pl parallel-consumer-core -am test -Dtest='RetryQueueRebalancePathTest,RetryQueueRequeueWindowTest,WorkManagerTest,ArchitectureTest'`;
`bin/check-pr-analysis-surfaces.sh 481`.

RECORDED, NOT APPLIED. The purge is the first unconditional full scan of the retry queue on the control
loop; both simplify reviewers converged on gating it behind a departure signal (a monotonic
`LongAdder` in `RecordPopulation`'s shape). Not done here: it would put poll-thread-written state back
on the rebalance path, and the cost is unmeasured - `DispatchScanMeter` is the precedent for metering
first. Filed as `docs/inflight/perf-retry-queue-purge-scan-is-unmetered.md` with that order stated.

RECORDS. `docs/solutions/runtime-errors/retry-queue-write-lock-on-the-rebalance-path.md` records both
designs. Retired: `docs/inflight/bug-retry-queue-write-lock-on-the-rebalance-path.md`,
`pr-431-must-pair-its-queue-removal-with-the-shard-removal.md`, and the branch note that guarded the
superseded PR while it was still open. Corrected beside their dated text: the
ArchUnit rules note (the set is empty on merit), the RetryQueue coverage note, the shard-displacement
note (its harm is now bounded by one tick), the runtime-ownership-guard note (the single-writer premise
makes that guard more load-bearing), two notes that named #431 as the fix, and the orphan-window
write-up (its assertion stays as written; the confirmation is belt-and-braces).

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

🟢 Throughput — OK

This branch measured about 3% slower than master, on the one test this measures. That is INSIDE this test's own run-to-run spread of about 17%, so read it as a reading and not as a result - re-running the same commit moves it by about as much.

What Value Meaning
Compared with master 0.967 above 1.00 is faster, below is slower
Subject test took 55.7s the test under measurement
Control tests took 28.9s the other tests in this same run
Subject ÷ controls, here 1.929 not a speed - a shape that cancels machine speed
Subject ÷ controls, master 1.865 median of recent master runs
Reported rate 71204 rec/s this machine only; not comparable across runners

Allowable range 🟢 ≥ 0.70 · 🟡 0.50–0.70 (about a 30% loss) · 🔴 < 0.50 (about a 50% loss)

What the numbers mean, and what they cannot tell you

The one that gets misread. Subject ÷ controls is a shape, not a speed. 1.7 means the subject took 1.7 times as long as the control tests in the same run - it says nothing about master on its own, and a reviewer has already read it as "1.7x faster than master". Only Compared with master answers that question.

Why a shape and not a rate. A rate depends on which runner you drew. A shape does not: every test here processes a fixed number of records, so a runner twice as slow doubles the subject and the controls together and leaves their ratio alone. That is the whole trick, and it is why the reported rate is shown last and labelled as this machine only.

Reading the comparison. master ÷ this run. Above 1.00 the subject is proportionally quicker here than on master; below 1.00 it is slower. 0.50 means it takes twice as long relative to its controls - that is the failing bound, not a small one.

By conservation, not by correction. Every test in this lane processes a fixed number of records, so within one run the ratio of one test's time to another's is invariant under machine speed — a runner twice as slow doubles both terms and leaves the ratio alone. There is no machine-index correction to be wrong, because nothing needed correcting. share = subjectSeconds / controlSeconds, both from this same run.

Per-method times, not class times. A class time is work + setup, and container startup and @BeforeAll do not scale with work — they are the non-conserved term, and leaving them in breaks the invariant.

Reference is the median of 6 recent perf baseline (master) run(s), read from their artifacts. There is no committed baseline to go stale, and a share is dimensionless, so an old entry stays comparable to a new one without re-baselining. Shares observed: 1.619 – 2.069.

What this still cannot do. It removes machine-to-machine variance. It does not remove this test's own run-to-run variance, measured at about 30% on a single unchanged commit while its controls stayed within 5%. That is a property of the test, not of the comparison, and no arithmetic here can touch it — which is why the reference is a median and the bounds are deliberately coarse. 🟡 means look at this; only 🔴 is outside the measured spread.

Runs used: 890aa53, fd3f913, 21e8728, 9302a89, c2d35c0, ad482ec

Since the previous push: ratio 1.144 -> 0.967, share 1.668 -> 1.929, rate 75354 -> 71204 (-5.5%). One push of difference sits inside this test's measured spread - read it as movement, not as a result.

Updated for f856852 · run 34185865241 · 2026-09-08 04:14 UTC

astubbs and others added 4 commits September 8, 2026 15:29
…s decided (#482)

The register's most-sighted flake, undiagnosed since 2026-08-18: `simpleBatchTest`
timing out at 30s with `Expected size: 3 but was: 4` across `ReactorBatchTest`,
`MutinyBatchTest` and `VertxBatchTest`, always on the KEY parameter, always on a
branch whose diff could not have caused it.

THE CAUSE ESTABLISHED: expectation-versus-input, the third of the three readings
the register kept live. Not contention, and not a batching defect - the library
is correct and is unchanged by this commit.

Under KEY ordering a shard is per key and `ProcessingShard.getWorkIfAvailable`
breaks out of its scan after one record when `isOrderRestricted()`, so a shard
yields at most one record per work-retrieval round; `makeBatches` then partitions
each round into chunks of batchSize. The batch count is therefore decided by how
many records sit on DISTINCT keys. `KafkaTestUtils.sendRecords` draws keys with
replacement from a hundred-key pool, so five records can land three to a key -
and `expectedNumOfBatches` is `ceil(records / batchSize)`, computed from the
record count alone and blind to the draw. Three records on one key cannot share a
batch without breaking the ordering guarantee KEY exists to provide, so four
batches is right and the expectation is wrong.

HOW IT WAS ESTABLISHED - three arms, KEY ordering, batchSize 2, five records,
everything but the key distribution held identical, predictions stated first:

  A control, random keys as shipped   predicted ~all 3    20/20 gave 3
  B forced 3-way collision 7,7,7,8,9  predicted 4         20/20 gave 4, 2+1+1+1
  C forced distinct keys 1,2,3,4,5    predicted 3         20/20 gave 3, 2+2+1

`2+1+1+1` is the shape every captured sighting payload carried. Supplementary
arms: 7,7,8,8,9 gave 3 three times and 4 twice - the same input, two counts,
which is what proves no exact expectation can survive a colliding draw; 7,7,7,7,8
gave 4 five times; 7,7,7,7,7 gave 5 five times.

RED THEN GREEN: pointing `simpleBatchTest` itself at 7,7,7,8,9 fails 1 of its 3
parameters in 31.7s with the CI signature character for character -
ConditionTimeoutException, alias 'expected number of batches', 30 seconds,
`Expected size: 3 but was: 4`. With this fix, green.

THE FIX KEEPS THE EXACT ASSERTION and removes the randomness from the input
instead. `KafkaTestUtils.sendRecordsWithDistinctKeys` draws WITHOUT replacement -
the distribution stays arbitrary, the collision cannot happen - and the
exact-count assertion is now actually justified rather than loosened.

That would leave KEY behaving like UNORDERED for that test, so the KEY-specific
guarantee gets a test of its own rather than being lost:
`keyOrderNeverBatchesTwoRecordsOfOneKey` sends a deliberate 3-way collision and
asserts what is deterministic about it - every record delivered exactly once, no
batch over batchSize, and no batch holding two records of one key. Net coverage
rises: the behaviour that broke the old expectation was never tested at all.

Rate check, as corroboration rather than proof: five draws from a hundred keys
collide three ways about once in a thousand, only KEY is exposed, four classes
run it per unit lane - so roughly one run in 250 should carry one, which is the
order of magnitude six sightings over three weeks predicts. A contention reading
never had to explain why UNORDERED and PARTITION were spared entirely.

OTHER INSTANCES OF THE CLASS - an exact count asserted over an input whose
distribution the test randomises. Every `sendRecords` caller and every `Math.ceil`
in test code was read; none found. Checked and dismissed: `batchFailureTest`
(same ceil, but `hasSizeGreaterThanOrEqualTo` - a collision can only add batches);
`averageBatchSizeTest`, `TransactionalBatchProduceTest`,
`MdcContextPropagationTest` and `OffsetEncodingBackPressureUnitTest` (all
UNORDERED, where every record is its own shard); `TransactionMarkersTest`
(PARTITION, and its own comment already says `// just so we dont need to use
keys`); `BitSetEncoder` (main code, unrelated ceiling arithmetic).

Register row retired to the header's fixed-and-out list; the payloads, the arms
and the residual are migrated to
docs/solutions/test-flakiness/a-randomised-key-draw-decided-a-batch-count-the-test-computed-from-the-record-count-2026-09-08.md

Co-authored-by: Claude Opus (1M context) <noreply@anthropic.com>
…, then fenced or redesigned (#469)

PartitionState carried three fields that cross the broker-poll / control thread boundary.
#349 fenced one of them, dirty, after measuring its window, and deliberately left the other
two alone for want of a measurement. This measures them with jcstress and then gives them DIFFERENT
fixes, because the measurement says they are different defects. The rule that falls out of it:
volatile is the fix for a one-writer field, not for a shared one.

THE MEASUREMENT, in jcstress-poc/, in the shape CommitPathVisibilityProbes already used for the
dirty pair. Rates are per raced pair on one workstation, not production incidence, and the recorded
figures are in each class's Calibration status block.

BackPressureFlagVisibilityProbes measures allowedMoreRecords, written on the broker-poll thread
inside the commit path's encode and read on the control thread in couldBeTakenAsWork. The plain
field with its real surrounding ConcurrentSkipListMap accesses fires; with the neighbours stripped
it fires about fifty times more often - so the map touch one statement earlier suppresses the window
fiftyfold and does not close it, the same incidental-fencing shape the dirty pair measured; the
volatile arm is FORBIDDEN at zero. What the arms do not show, said in the javadoc: this field's harm
is unbounded staleness - a stale false admits nothing, so nothing succeeds, nothing re-dirties the
partition and the encode never runs again - and "never" is not an outcome a bounded run observes.
They measure the fence, not the stall.

CommitWindowLostUpdateProbes measures stateChangedSinceCommitStart, written by BOTH threads, with
the arbiter asserting the invariant itself: a completion landing inside the commit window must leave
the partition dirty unless the commit covered it. As shipped it fires at around 1.6e-3. The arm that
decides the design is the flag made volatile as well - the obvious next step, and the one the
inflight note had recorded as next: statistically unmoved, because only one of the three mechanisms
breaking the invariant is a visibility effect. The check-then-act in the clean-marking step and the
lost update between two writers fire at interleaving rates on sequentially consistent hardware, and
no modifier addresses either. VolatileStateChangedFlagAcrossTheCommitWindow exists solely to hold
that line; re-run it before proposing the modifier again. The monotone-count protocol arm is
FORBIDDEN at zero, with a known weakness stated below.

THE TWO FIXES. allowedMoreRecords is volatile: one writer, so a release/acquire edge is the whole
fix. Its javadoc carries the figures, the arm name and the direction of the crossing - poll thread
writes, control thread reads, the OPPOSITE direction to the commit-path fence, so that fence never
covered it.

dirty and stateChangedSinceCommitStart are gone. In their place a monotone AtomicLong
completionCount, the completionCountCommitted a successful commit publishes, and the held
completionCountBeingCommitted. "Is this partition dirty" is a comparison of two values rather than a
flag somebody clears, which removes the defect rather than narrowing it: no check-then-act is left
in the clean-marking step and no write either thread can lose to the other. The release/acquire edge
#349 measured is preserved: incrementAndGet is a strictly stronger release than the volatile
store it replaces, and the counter load on the commit path is the paired acquire, so the plain longs
it published stay plain for the reason that PR gave. The held count is volatile because which thread
runs the commit cycle varies by mode, so a thread-confinement claim would have been a lie.

COMPOSED WITH #470, which merged first and changed the same protocol from the other side: the
partition remembers the commit offer it makes WHOLE and marks itself clean only on the broker's
acknowledgement of that offer. The two rules do not overlap. getCommitDataIfDirty samples the
completion count once, stashes it, and remembers the offer; onOffsetCommitSuccess records every
acknowledged offset and publishes the stashed count as committed only when the acknowledged offer
equals the last one made. The offer decides WHETHER an answer ends the commit window; the count
decides HOW MUCH it covered. Under the async mode the acknowledgement is no longer part of the
collect-and-commit unit, so a later cycle can overwrite both fields while an earlier request is
outstanding, which is safe because only the latest offer's answer publishes anything.

THE NAMES FOLLOW THE DESIGN. setDirty() is now recordCompletion(): nothing sets a flag any more, the
completing thread stamps a new version on the partition, and the count is a version stamp rather
than a quantity - its only use is inequality with what the last acknowledged commit covered.
isDirty() carries the definition the code had been paraphrasing three ways: dirty means a completion
has landed that no acknowledged commit has covered. The comparison lives once, in isDirtyAt(long):
isDirty() passes a fresh load, and getCommitDataIfDirty() passes the sample it then stashes, because
the collecting cycle must publish the very count it TESTED - a completion landing between two loads
would be published as covered while its offset need not be in the metadata this cycle encodes. That
burnt commit cycle is the defect this change closes.

THE RED PROOF. PartitionStateCommitWindowSeamTest drives, deterministically, the one interleaving
the old protocol lost a completion to: inside the clean-marking step, between its read of the flag
and its write of dirty. Master offered no override point between those two instructions, so
PartitionState.onCommitWindowClosing() was added - an overridable no-op in the one-shot-armed seam
shape of RacingSeamWorkManager, with firing tracked separately from arming so a test that forgot to
arm cannot pass while asserting nothing. The review searched for an existing hook at that instruction
and found none. Redness is shown twice: a permanent control arm reverts only the protocol and asserts
the loss, so the defect stays runnable after the fix removed it; and transiently, reverting the
protocol in PartitionState while keeping the seam turns the pinning arm red. Stashing the whole
change does not produce that, because it removes the seam too. PartitionStateDirtyFlagFenceTest
becomes PartitionStateCrossThreadFieldFenceTest, pinning the modifiers on all three fields, which is
the move its own failure message asked for.

KNOWN WEAKNESS, RULED ON RATHER THAN FIXED. The protocol arm's FORBIDDEN zero is unreachable by
construction: the flag arms are seeded dirty so the poll actor always enters the commit window, while
the protocol arm starts clean and enters the window only once it has already observed the
completion, and the AtomicLong acquire that lets it in publishes everything the release wrote. The
recorded run corroborates it - the flag arms reached all four outcomes, the protocol arm only two.
Seeding it dirty is not a fix, for the same release/acquire reason; a real power check would need a
deliberately broken protocol variant as a negative control. The maintainer ruled that the control
would not be built and the weakness documented instead, so its zero is cited as "no anomaly
observed" and never as "the window was measured shut". The argument this change rests on is the
plain-versus-volatile pair, which none of this affects. Every other FORBIDDEN arm in the module earns
its zero from a paired arm of the same state shape that fires; this is the only arm whose fixed
version has a different state shape from its comparator.

THE PROBE ARMS SHARE THEIR SCAFFOLDING and keep only the measured term apart, answering the
duplicate-code check. The rule: a member may be hoisted only if it contains no access to the arm's
measured field. The map and its setup, the neighbouring writes, the instrumentation payload, the
encode read and the arbiter's result mapping moved into a package-private base class; the measured
field's own declaration per @State class and each actor's literal access sequence stay in the arm,
so plain versus volatile on one line is the term under test and the JIT compiles the shape
production has. jcstress 0.16 permits a @State class to inherit - read out of its annotation
processor, whose inheritance ban binds @Result classes only - and jcstress -l lists every arm after
the change. The recorded rates are deliberately not overwritten with the re-run's.

A LEDGER THAT WAS WRONG, and nothing went red: docs/refactoring.md's AT_STALE_THREAD_WRITE_OF_PRIMITIVE
entry listed two fields while SpotBugs named six, three of them in PartitionState - the class
#349 was fencing at the time - and one, ProgressTracker.highestRoundCountSeen, that no ledger
carried. The lane runs spotbugs:check with failOnError=false, so the finding annotates and never
blocks. The repair is the command that re-derives the membership, the spotbugs step's own line
verbatim, not a better list.

DOCS. The two docs/inflight/bug-* notes for these fields are retired, their durable content in
docs/solutions/logic-errors/volatile-is-the-fix-for-a-one-writer-field-not-a-shared-one-2026-09-07.md,
and the engine AGENTS.md ledger carries the rule. docs/inflight/test-jcstress-probe-module-open-items.md
carries the vacuous-arm line item and the generalisation that a green FORBIDDEN tick is worth nothing
unless some arm shows the outcome is reachable at all. Two local-only test failures seen while
verifying are recorded in docs/inflight/test-untracked-ci-flakes.md with their same-base control
arms; the row for ReactorPCTest.concurrencyTest no longer rules this change out as a contributor,
because setAllowedMoreRecords(true) fires on two paths and the fence publishes unblocking sooner too.
The master merges kept the retired notes deleted over the vetting sweep's stamps, which asserted the
fields were still unfenced and unmeasured - precisely what this change falsifies.

Co-authored-by: Claude Fable 5.1 (1M context) <noreply@anthropic.com>
…n no run can answer, and a control arm settles it instead (#485)

The revoke-path commit deadlock's write-up carried two contradictory verification
statuses: a 2026-08-31 header block saying the fix was proven by a controlled A/B,
and a body section titled "the part that matters" saying Unproven. The second is the
state of #29 as an unmerged draft on 2026-08-07, and it is the one a reader
greps to. Six capture sections in the family register cite it, each closing by asking
for the same replay.

That replay cannot answer the question. Each capture identifies the defect by the poll
thread being BLOCKED on an AtomicBoolean monitor; #29 replaced that monitor with
a ReentrantLock, and a thread waiting on a lock parks rather than blocking. So the
answer is "no BLOCKED frame" whether the cycle is closed or wide open.

Measured, not argued. The deadlock was deliberately restored at 6aab3ff - master plus
everything through #466, whose change to the revoke path the 2026-08-31 A/B
predates - by the single term commitLock.tryLock() -> commitLock.lock(), and
Rebalance857CommitSyncDeadlockProbeIT run against it.

  arm                    assignor      result      declines   commit timeouts
  fix                    eager         5/5 pass    6          0
  fix                    cooperative   5/5 pass    5          0
  pre-fix control        eager         5/5 FAIL    0          65

All 80 diagnoses from the failing control read WAITING on a ReentrantLock$NonfairSync
held by pc-control, through tryCommitOffsetsOnRevoke under onPartitionsRevoked. Not one
read BLOCKED; not one named an AtomicBoolean. A future capture must be matched by the
method pair and the holder, never by thread state or lock type, or a genuine
reproduction gets mis-filed. The declines and uncontended columns count log lines, one
per execution of the revoke fork, which the eager fix cell settles from inside the grid:
six declines cannot be a per-repetition count of five repetitions.

Two seeds replayed as falsification attempts, and the pair is instructive:

  7728704565782280867  ChaosRevokeUnderWorkKeyOrderIT   eager  pass  182 uncontended, 1 decline
  2867310537409227917  ChaosRevokeUnderWorkCooperativeIT coop   pass  54 uncontended, 0 declines

The second is VOID by this repo's own standard - the window never opened, so its green
is uninformative - and it is visible only because both branches of the revoke fork now
log at INFO. One void in two is the base rate this method has. The remaining four seeds
were deliberately not run; a third green buys nothing.

Also recorded: the JVM's own findDeadlockedThreads() cannot see this cycle even fully
closed, because its other edge is a LinkedBlockingQueue.poll rather than a lock. That is
why the probe exists instead of an assertion on the deadlock detector.

Defect-class sweep - a document identifying a concurrency defect by a signature that is
an artefact of the implementation, so the signature dies when the implementation
changes: one further instance fixed (the A/B soak harness note, the highest-traffic
one, now says the seeds could never have answered the question), the family register's
literal dump signatures corrected, and six candidates checked and dismissed as ordinary
prose about a stuck thread rather than a fingerprint.

Prior art that shaped the method rather than being rediscovered: the family register's
standing "stop replaying seeds" rule and the 2026-08-31 control-armed replay that came
back void, and #478's finding that a master-versus-branch A/B measures the whole
branch. The dated Unproven section is superseded in place, not rewritten, per
docs/citations.md.

PROPOSED, owner-gated: the revoke-path deadlock line may now be closed. Not #119,
which is a symptom bucket, and not the register, which still holds open items that
reproduce on trees carrying this fix.

Co-authored-by: Claude Opus (1M context) <noreply@anthropic.com>
, #481, #482, #485

Two conflicts, and one silent auto-merge artefact that only the compiler found.

PartitionState. #469 replaced the pair of booleans with a version counter, deleting
stateChangedSinceCommitStart; #410's ledger snapshots at the same point. Joined by taking
master's counter whole - the single completionCount load, tested and stashed as
completionCountBeingCommitted - and moving the ledger's snapshotForCommit() inside that same branch,
still ahead of createOffsetAndMetadata(), so the ledger is still snapshotted before the offer is made
and trimmed only on the answer to that offer.

The artefact: restoreCompletedButUncommittedWork() still called setDirty(), which #469
deleted, and git merged both sides clean because neither touched the other's lines. It is
recordCompletion() now - the replay has moved the partition's state on, so it stamps a new version
and isDirty() derives the rest. PartitionStateAbortedTransactionReplayTest 7/7 and
PartitionStateCommitWindowSeamTest 3/3 cover the join.

The flake ledger takes both sides' rows and master's retirement of the simpleBatchTest section, which
#482 fixed - expectation-versus-input, not the library.

config/infer-known-findings.txt takes master's version whole. The
"1 NULLPTR_DEREFERENCE AbstractParallelEoSStreamProcessor.processWorkCompleteMailBox" line this
branch carried was a merge artefact: #346 added it, #466 retired it in the same change
that reshaped the mailbox loop, and an earlier merge here re-took #410's older side. The
static lane was red on it. Re-ran infer locally on the merged tree - 9 findings, all known, none new.

Verified: ProducerManagerTest 22/22, ProducerRecoveryTest 15/15, the PartitionState family, the
RetryQueue family, WorkManagerTest, ArchitectureTest, TestConventionsArchTest,
TransactionalClaimCoverageTest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
Two more lines of the burn-down land on master on 2026-09-08: the
shard-displacement orphan is known unreachable (#483, whose review
round widened the regression test to every ordering mode), and the fifth
857 item is the group protocol (#486, carrying the PROPOSED close
for the owner).

The release-note draft said #408 adds the decline. It no longer
does: #466 refuted declining as the fix by experiment, so the
decline is only the deadline fallback, and what #408 now carries is
the measurement of the bound and the held decline seam - the reading its
reconciliation with master recorded today.

Claude-Session: 460f7df9-dcc2-4b00-a9f9-62f3a2c6d5e4
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 8, 2026
…PR that carries it

The three lines the release note still has to name now read like the
rest of the burn-down: a box, the PR in front, and what closes the box.
A ticked box means the v6 action for that line is done, not that the
defect is closed - the revoke wait is ticked because its release-note
sentence is written and its fix is #408 after v6; the
INSTANCE_STALL line waits on #488, whose load arm has now
measured the starvation reading from the direction the idle replays
never could; the intake stall waits on #487, whose soak arms are
instrumented and predicted but not yet run.

Claude-Session: 460f7df9-dcc2-4b00-a9f9-62f3a2c6d5e4
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
astubbs added a commit that referenced this pull request Sep 18, 2026
… not decline the lock

#408 refuted its own decline design after #466 moved the revocation commit off the
poll thread; what remains is bounding the delegated wait under max.poll.interval.ms. The two
posted comments claimed decline was the fix and were edited today; the tracker row now matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XS64Xttx4vF5datYh7fmk
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.

confluentinc#803: Transactional Producer instance gets timeout getting commit lock while second instance starts

1 participant