Skip to content

KAFKA-20876: Windowed restore optimisation gives up when endOffset-1 is a transaction control record - #23086

Open
alanlau28 wants to merge 7 commits into
apache:trunkfrom
alanlau28:KAFKA-20876-probe-retry
Open

KAFKA-20876: Windowed restore optimisation gives up when endOffset-1 is a transaction control record#23086
alanlau28 wants to merge 7 commits into
apache:trunkfrom
alanlau28:KAFKA-20876-probe-retry

Conversation

@alanlau28

@alanlau28 alanlau28 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Jira: https://issues.apache.org/jira/browse/KAFKA-20876

When restoring a windowed store with no checkpoint,
StoreChangelogReader seeks to latestTimestamp - retentionPeriod
rather than log-start, since data older than the retention is discarded
on write. It learns latestTimestamp by seeking to endOffset - 1 and
polling once in seekNewPartitions; an empty poll falls back to
seekToBeginning.

The probe now starts at endOffset - 32 and polls repeatedly at each
position before stepping back, bounded by PROBE_MAX_ATTEMPTS and the
log's beginning. It takes the newest record of the returned batch, so a
deeper start costs nothing in accuracy — measured, the resolved record
sat 2 offsets behind the end. seekToBeginning remains the behaviour
when no data record can be found.

Three defects stopped the KAFKA-13499 windowed restore optimisation from ever
executing under exactly-once. All are reachable on trunk with
enable.transactional.statestores at its default of false, since the EOS unclean
close still wipes the task directory and forces a from-scratch restore.

1. PlainToHeadersWindowStoreAdapter holds its delegate in a private field rather
   than as a WrappedStateStore, so extractRetentionPeriod's unwrap walk ends on
   the adapter and resolves -1. That fails the gate and silently skips the
   optimisation for every stream-stream join store. Measured on a 4.3 soak
   carrying a backport: 441 and 379 non-finite retentions, 0 optimised seeks.

2. The head-timestamp probe seeked endOffset-1 and gave up on an empty poll.
   Under EOS that offset is a transaction control record -- confirmed by dumping
   a live segment: the last batch is isControl=true, count=1, size=78, and the
   pattern recurs at every transaction boundary. Markers occupy an offset but are
   never delivered to a consumer, so the poll returns nothing.

   Replaced with a bounded backward probe. It starts at endOffset-32 rather than
   -1, which costs nothing in accuracy because the probe takes the newest record
   of the returned batch.

3. The probe shared one poll across all unresolved partitions, so an empty result
   could merely mean another partition's fetch landed first -- doubling that
   partition's step-back on false evidence and spending a shared attempt budget
   on its behalf. Partitions starved into log-start seeks with zero margin and
   were lapped into OffsetOutOfRangeException. Each partition is now probed
   alone, with the others paused.

   Note poll() updates fetch positions for the whole assignment, so every probed
   partition is seeked up front; without that the first poll throws
   NoOffsetForPartitionException under auto.offset.reset=none.

Tests: StoreChangelogReaderTest 47, SeekFallback 5, adapter 2, unwrap probe 1,
soak topology probe 1, TxnStoreWipeAB 3, and a 7-test EOS-v2 integration suite
against a real cluster. Each fix was verified to fail its test when reverted.
…e back

Every probe attempt re-seeks before polling, which cancels a fetch that was in
flight. An empty poll therefore does not distinguish "no record at this offset"
from "the fetch has not landed yet", and the step-back doubles on that false
evidence.

Soak evidence that this is happening: with the probe starting at endOffset-32,
~46% of probes still walk out to backUsed 512-2048, and probeMs medians sit at
1.0-1.5s against ~0.5s for the previous build. Long runs of control records
would explain that, but so would cancelled fetches, and the two are
indistinguishable from the current instrumentation.

Polling a second time at the same position separates them. If the deep
step-backs are self-inflicted, backUsed should collapse to 32 and probeMs should
fall; if they persist, the runs of control records are real.

Note the existing tests pass either way -- this changes timing behaviour that
neither MockConsumer nor a loopback EmbeddedKafkaCluster reproduces, so the
result has to come from the soak.
… probing

Individual polling removed the starvation but lost amortisation: 5 partitions
cost 22 attempts and 2013ms against 1 attempt and 3ms for a shared poll that is
simply polled more than once. The shared poll always could serve every
partition; the defect was giving up after the first empty result, which only
means a fetch has not landed.

Also fixes the regression test's observable: a partition's position after
restore reflects records it has since consumed, not where it was seeked, so the
test now records seekToBeginning calls directly.
… one empty poll

When restoring a windowed store with no checkpoint, StoreChangelogReader seeks to
latestTimestamp - retentionPeriod rather than log-start, since data older than the
retention is discarded on write. It learned latestTimestamp by seeking to
endOffset - 1 and polling once; an empty poll fell back to seekToBeginning.

Two reasons that poll comes back empty without the offset being at fault:

- Under EOS the last offset is almost always a transaction control record, which
  occupies an offset but is never delivered to a consumer. Measured on a soak,
  endOffset - 1 returned a record on 0 of 195 probes, and dumping a segment shows
  the last batch is isControl=true with the pattern recurring at every
  transaction boundary.
- One poll returns as soon as any fetch lands, so a partition can be empty
  because another was served first or because its own fetch has not arrived.

The probe now starts at endOffset - 32 and polls repeatedly at each position
before stepping back, bounded by PROBE_MAX_ATTEMPTS and the log's beginning. It
takes the newest record of the returned batch, so a deeper start costs nothing in
accuracy. Falling back to seekToBeginning remains the behaviour when no data
record can be found.

PlainToHeadersWindowStoreAdapter also now reports the retention of the store it
adapts. It holds its delegate in a private field rather than as a
WrappedStateStore, so extractRetentionPeriod's unwrap walk terminated on the
adapter and resolved -1, silently skipping the optimisation for every
stream-stream join store.

shouldRetryProbePollBeforeFallingBackToLogStart fails without the change.
The probe stepped back from 32, re-seeking after three polls whether or not
those polls had waited on a fetch, so a partition could be widened away from the
answer it was about to give and end up restoring from log start. Start at 128,
which answers the large majority outright, and give each window until it stops
resolving anyone before widening to 512 and 2048.
@alanlau28
alanlau28 force-pushed the KAFKA-20876-probe-retry branch from f527149 to bc2e02f Compare August 9, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

streams triage PRs from the community

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant