Skip to content

fix: runlength decoding after no-progress commit - #563

Merged
John Byrne (johnbyrnejb) merged 1 commit into
confluentinc:masterfrom
rkolesnev:fix/runlength-offset-decoding
Mar 8, 2023
Merged

John Byrne (johnbyrnejb) merged 1 commit into
confluentinc:masterfrom
rkolesnev:fix/runlength-offset-decoding

Conversation

@rkolesnev

@rkolesnev Roman Kolesnev (rkolesnev) commented Mar 3, 2023 •

Copy link
Copy Markdown
Contributor

When offset data is serialized using RunLength encoder - and base offset to commit is same as the next expected offset - 1 (or lower) - basically there is no extended metadata to encode - could be caused by slow processing (slower than consumption) or on rebalance etc.

When that data is subsequently decoded - the HighestOffset is returned as 0 - that causes the warning about state truncation to be shown and the bootstrap state truncation logic to be invoked.

This fix addresses the decoding side - initializing the HighestOffset = baseOffset-1 - same as bitset decoder, but potentially we could detect and skip encoding of metadata in those scenarios - need to dig deeper and can be done as follow up.

Related issue #546

Checklist

  • Documentation (if applicable)
  • Changelog

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@johnbyrnejb
John Byrne (johnbyrnejb) merged commit 857c384 into confluentinc:master Mar 8, 2023
Antony Stubbs (astubbs) added a commit to astubbs/parallel-consumer that referenced this pull request Sep 8, 2026
…d the invariant that keeps it shut is pinned (#484)

WHAT WAS OPEN. confluentinc#546 holds three defects behind one warning string. One is fixed by
inheritance (confluentinc#563, already in this tree and guarded in OffsetRunLength). One is live.
The third - the "Bootstrap polled offset has been reset to an earlier offset" branch of
PartitionState#maybeTruncateBelowOrAbove - was never diagnosed upstream, and this fork's note
carried an UNTESTED hypothesis for it, proposing a control-arm test nobody had run. This change
runs it, and the hypothesis is refuted.

THE HYPOTHESIS, AND WHY IT NAMES THE WRONG BRANCH. The note proposed a race: PC issues its own
consumer.committed() in loadPartitionStateForAssignment, separate from the consumer's own position
resolution for a newly assigned partition, so a commit landing between the two reads would leave
PC one commit ahead of where the fetcher starts. It is not a race at all. In kafka-clients 3.9.2 -
the version this tree builds on - ClassicKafkaConsumer#updateAssignmentMetadataIfNeeded runs
coordinator.poll, which invokes the rebalance listener and therefore PC's committed(), and only
afterwards updateFetchPositions, whose ConsumerCoordinator#initWithCommittedOffsetsIfNeeded is what
gives an initializing partition its position. PC's read strictly PRECEDES the fetcher's, so a
commit landing between the two is the one the FETCHER sees: the polled offset comes out at or
above PC's expectation. The proposed mechanism can only produce the other warning.

WHAT IS LEFT, AND THE INVARIANT THAT SHUTS IT. One route into the branch belongs to PC rather than
to the broker: a commit whose encoded payload decodes to an expectation ABOVE the offset that
commit was filed under - the shape #337 closed. PartitionStateBootstrapTruncation162Test
round-trips a commit PC itself wrote back through MockConsumer, WorkManager#onPartitionsAssigned
and the decoders, over four shapes PC can genuinely commit (fully drained, contiguous tail
incomplete, a completed island above the commit frontier, only the base offset incomplete), and
asserts the bootstrap expectation equals the committed offset exactly. Hold that invariant and a
partition PC hands to itself cannot take either truncation branch. Every remaining way in is the
broker handing back a position below the committed offset - an offset reset, a manual rewind,
another member committing lower, a stale OffsetFetch across a coordinator failover - and there
replaying is the right answer, not a defect.

THE TWO BRANCHES AS A CONTROLLED PAIR. One fixture, one differing term - the offset the first poll
batch starts at - so the outcome that flips is the branch's own. Below expected: every loaded
incomplete is discarded, including one above the batch that the batch does not re-register, and
offsetHighestSucceeded resets, so records the previous owner completed and committed are processed
again. Above expected, the control: only incompletes beneath the polled offset are pruned, and the
one above the batch survives. The note's "duplicate processing, not loss" claim is asserted in BOTH
halves - nothing tracked can be stranded below the polled offset, because the expectation the
branch fires against IS the lowest tracked incomplete.

NEGATIVE CONTROLS, ONE PER ASSERTION, so no arm can pass by accident. Removing the reset fails only
the below-expected arm; removing the prune fails only the above-expected arm; dropping the
payload's lowest incomplete on decode flips the two round-trip shapes that carry a payload and
leaves untouched the two that do not.

AN INSTRUMENT THAT LIED FIRST, recorded because it read as a product defect. The round trip
initially reported an expectation of 0 for three of the four shapes. MockConsumer#committed hands
back a bare OffsetAndMetadata(0) - indistinguishable from a real commit at offset 0 - for any
partition its own SubscriptionState does not hold, and MockConsumer#assign clears the committed
map, so the assignment has to precede the commit. The arm now proves itself, and the comment at
that line says why. A test harness that fabricates a plausible wrong answer is worth writing down:
it cost a diagnosis before it was caught.

WHAT REMAINS OPEN ON #162, and why this does not close it. The false
warning: maybeTruncateBelowOrAbove never asks whether commit data existed, so a partition with no
committed offset reports "expected 0 from loaded commit data" and truncates nothing. Misdirection,
still untested, and unchanged here. The inflight note is shrunk to that one case and the draft
mirror reply is corrected to name it, so the upstream thread is not told that both remaining cases
are covered. src/docs/development/upstream-map.yaml records the lifecycle transition in the commit
that caused it.

CROSS-CHECKED AGAINST THE MASTER THIS LANDS ON. #470 rewrote
PartitionState's commit-acknowledgement path while this branch was open, and its
offerLastMadeForCommit javadoc names this same downward reset from the other side - only the latest
commit offer is kept rather than a set, because the bootstrap reset is "exactly the event no
pruning rule would see". The two records agree: the reset is real, reachable only in
bootstrapPhase, and destructive of anything keyed to the pre-reset expectation. Nothing in the
refutation is disturbed, because the refutation is about how the branch is ENTERED, not what it
does once entered. Re-run after merging master rather than assumed.

No production code changes: one new test class, the inflight note, and the upstream manifest.

Upstream-Issue: confluentinc#546

Co-authored-by: Claude Opus (1M context) <noreply@anthropic.com>
Antony Stubbs (astubbs) added a commit to astubbs/parallel-consumer that referenced this pull request Sep 9, 2026
…bsence is recorded rather than inferred (#494)

WHAT WAS WRONG. PartitionState#maybeTruncateBelowOrAbove took its bootstrap expectation from
getOffsetToCommit() and never asked whether commit data existed. A partition assigned with no
committed offset is built from OffsetMapCodecManager.HighestOffsetAndIncompletes.of(), so
offsetHighestSucceeded is KAFKA_OFFSET_ABSENCE and the expectation computes to 0 - and any first poll
above offset 0 took the above-expected branch, logged "Truncating state - removing records lower
than ..." at WARN, and pruned nothing, because there was nothing loaded to prune. False in both
halves. It fired for the normal state of a new consumer group, and for a group whose committed offset
had aged out of the offsets topic. That is the shape reported upstream on 0.5.2.7 - after
confluentinc#563 shipped and fixed the OTHER form of the same string - alongside a broker CLI
screenshot showing an empty CURRENT-OFFSET for exactly the partitions that warned.

THE BEHAVIOUR NOW. Absent commit data reports at INFO and takes no truncation branch: "No committed
offset for partition N of topic T - starting from the first polled offset X. Nothing was loaded, so
nothing is being removed. Expected for a new consumer group, or where the group's committed offset has
aged out of the offsets topic." The WARN is unchanged for the genuine cases - a committed offset the
broker no longer has records for, compaction, a raised committed offset - so an operator alerting on
"Truncating state" keeps exactly the alert they had, minus the false firings.

WHY NOT THE SENTINEL, WHICH IS THE WHOLE OF THE DESIGN. The cheap check reads
offsetHighestSucceeded == KAFKA_OFFSET_ABSENCE, and it is WRONG: a real commit filed at offset 0 with
no offset map decodes to a highest-seen of 0 - 1, which is that same sentinel, with commit data that
genuinely existed and an expectation of 0 that is genuinely meant. Testing the sentinel would silence
the true truncation report for exactly the partition most likely to be caught by retention - the one
that has processed nothing yet. -1 means "nothing succeeded", not "no commit arrived". So the absence
is carried from where it is known: every decode path yields a PRESENT
HighestOffsetAndIncompletes#getHighestSeenOffset, because every decode has a committed offset to be
relative to, and only the default entry yields an empty one. PartitionState samples that into a final
boolean in its constructor, before initStateFromOffsetData collapses the Optional into the sentinel -
the engine AGENTS.md collapse-parallel-state rule applied to a fact rather than a cache. The field
being final is also the whole thread-safety story: publication is the constructor's freeze, and it is
read on the control thread in the same statement that closes bootstrapPhase.

RED FIRST, THEN THE CONTROL ARM. PartitionStateAbsentCommitData162Test was red on master in three arms
- the first poll above 0 warned, the assignment path through a consumer reporting no committed offset
warned, and the at-offset-0 arm had no INFO line to find - and green after. The fourth arm is the
control: same sentinel, same expectation of 0, same first polled offset, one term differing (commit
data existed), and it must still warn. Prediction stated before running: substituting the sentinel
shorthand for the recorded fact fails ONLY that arm. Run with the shorthand substituted, it did
exactly that - one failure, the control arm, with the absent-data arms and
PartitionStateBootstrapTruncation162Test's two genuine branches still green.

A CLAIM CORRECTED ON THE WAY PAST. The inflight note said #217's foreign-metadata recovery
lands on the same default entry through catch (OffsetDecodingError), and so inherits the false
warning. It does not. Under the runtime-default IGNORE policy,
EncodedOffsetPair#handleUnreadableMetadata returns of(baseOffset - 1) - commit data carrying the
committed offset - so such a partition bootstraps with a real expectation and the truncation branches
stay correct for it; under FAIL the typed exception propagates instead. Neither reaches the default
entry, and the only production caller of OffsetEncoding#decode is EncodedOffsetPair#unwrap, which has
test callers only. Asserted by the test's last arm; the now-dead catch is recorded in
docs/refactoring.md.

DEFECT-CLASS SWEEP - other places deriving "no commit data" from sentinel arithmetic. Every reader of
KAFKA_OFFSET_ABSENCE and of the offset accessors in main was checked, and none is the same defect:
OffsetSimultaneousEncoder's highestSucceededOffset == KAFKA_OFFSET_ABSENCE asks about SUCCESS, which
the sentinel answers faithfully; isRecordPreviouslyCompleted's recOffset <= offsetHighestSucceeded is
correct under both readings; RemovedPartitionState returns the sentinel as a deliberate no-op contract
and never reaches truncation; RunLengthEncoder's use is a loop initialiser. Two sites borrow the
constant for a second meaning without being defects, and are recorded in docs/refactoring.md:
getOffsetHighestSequentialSucceeded uses it as a below-every-offset floor for ceiling(), and
PartitionStateManager#incrementPartitionAssignmentEpoch defaults an EPOCH from an OFFSET constant.

WHY THIS CLOSES THE ISSUE. All three defects behind confluentinc#546's one warning string are settled
here: the "expected 1" RunLength decode by inheritance from confluentinc#563; the "expected 0 from
loaded commit data" form by this change; and the "reset to an earlier offset" branch refuted as a
PC-reachable defect by #484. Upstream's own issue stays open - that repository is no longer
maintained.

RECORDS. docs/inflight/bug-162-offset-state-truncation.md is deleted, which is its own "Delete when"
condition. What outlives it - the three-defects reading, the sentinel-versus-fact discriminator and
the #217 correction - is migrated to docs/solutions/logic-errors/. The drafted mirror reply
moves to docs/inflight/issue-response-162.md, held for the pre-release sweep rather than deleted with
this PR. Citations in process-candidate-ranking.md and upstream-mirror-bodies-are-stale.md are
repaired, and src/docs/development/upstream-map.yaml records the lifecycle transition.

Closes #162

Upstream-Issue: confluentinc#546

A SIBLING FIELD, AND WHY IT IS NOT THE SAME FACT. #480 landed while this was open and
added committedOffsetTheMapWasLoadedAgainst to the same constructor, defaulting to the same
absence sentinel, so the next reader meets two fields that look like one and deletes one. They
differ: that field records what the caller passed, and NO_OFFSET_MAP_WAS_LOADED is also what
every direct constructor call passes; this one is derived from the offset data itself, so it
answers what the codec produced. Measured, not argued - substituting one for the other fails
six assertions across three test classes, because partitions that did load commit data would
go quiet. The javadoc records that and names what would collapse the two into one: the
constructor gaining the committed offset, at which point this field is deleted rather than
kept in sync. #480's refusal path, which re-initialises from the offset data a third
time, deliberately does not re-sample this field either: a refused map still leaves a real
committed offset to bootstrap against.

Co-authored-by: Claude Opus (1M context) <noreply@anthropic.com>
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.

3 participants