nahidupa opened a new pull request, #17925:
URL: https://github.com/apache/iceberg/pull/17925

   Closes #16282
   
   ## Problem
   
   The coordinator's control-topic consumer is group-managed via `subscribe()`, 
so it participates in rebalances. Nothing observes those rebalances, and 
`CommitState` holds the entire in-flight commit in memory:
   
   ```java
   private final List<Envelope> commitBuffer = Lists.newArrayList();
   private final List<DataComplete> readyBuffer = Lists.newArrayList();
   private int receivedPartitionCount = 0;
   private UUID currentCommitId;          // null == no commit in flight
   ```
   
   When the coordinator loses its assignment mid-commit, the consumer resumes 
from its last *committed* offset and re-reads records it has already processed. 
The in-flight state survives that rewind, so the replayed events land in a 
commit that is still live.
   
   Two things go wrong when they do.
   
   **The readiness quorum is inflated.** `addReady` only skips an event when 
the commit ids differ:
   
   ```java
   } else if (Objects.equals(currentCommitId, dataComplete.commitId())) {
     receivedPartitionCount += dataComplete.assignments().size();
   }
   ```
   
   After a rewind the id still matches, so the same `DataComplete` is counted 
twice and `isCommitReady()` fires before every partition has reported.
   
   **The recorded offset floor regresses.** `Channel.consumeAvailable` tracks 
progress with a plain `put`:
   
   ```java
   controlTopicOffsets.put(record.partition(), record.offset() + 1);
   ```
   
   A replay overwrites a higher value with a lower one, while `commitBuffer` 
still holds the higher offsets. That map is what `commitToTable` writes into 
the snapshot summary as `kafka.connect.offsets.<topic>.<group>`, and it is the 
floor the next cycle uses to drop already-committed events:
   
   ```java
   Long minOffset = committedOffsets.get(envelope.partition());
   return minOffset == null || envelope.offset() >= minOffset;
   ```
   
   So the snapshot ends up advertising a floor behind its own contents, and the 
dedup filter is disarmed for exactly the range that was already committed. The 
reporter in #16282 observed the end result in production: the same `file_path` 
with the same `record_count` registered in two snapshots, and every row in that 
file returned twice.
   
   Nothing downstream catches it. `distinctByKey(ContentFile::location)` only 
deduplicates within a single batch, and `AppendFiles` has no uniqueness 
constraint on data file location, so a duplicate registration is valid metadata.
   
   ## Why no `seek()` appears in the code
   
   This was the part that made the bug hard to see. There is no explicit rewind 
anywhere. IKC never sets `partition.assignment.strategy`, so the client default 
`[RangeAssignor, CooperativeStickyAssignor]` applies and takes its protocol 
from the first entry — **eager**. An eager rebalance revokes every partition 
and discards its fetch position, even for a member handed the same partition 
straight back; `updateFetchPositions()` then refetches the committed offset. 
The rewind happens inside the consumer.
   
   ## What changes
   
   ```mermaid
   flowchart LR
       R["Partitions revoked"] -->|onPartitionsRevoked| 
RS["commitState.reset()"]
       R -->|"consumer resumes<br/>from offset 0"| RW["Consumer rewinds"]
       RS -->|"no commit id held"| G["Stale event ignored<br/>no commit in 
progress"]
       RW -->|"replayed DataComplete"| G
       G -->|"counter stays at zero"| F["Fresh StartCommit<br/>new id, live 
quorum"]
       F -->|"refilled in lockstep"| OK["Registered once"]
   
       R -.->|"before this patch"| S["State survives<br/>commit id still held"]
       S -.->|"map regresses"| D["Double-counted"]
       D -.->|"quorum met early"| P["Premature commit<br/>floor behind 
contents"]
       P -.->|"stale floor admits it"| DUP["Duplicate file<br/>same path in two 
snapshots"]
   
       classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d
       classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
       classDef evt fill:#ffedd5,stroke:#ea580c,color:#7c2d12
       class RS,F,OK good
       class S,D,P,DUP bad
       class R,RW,G evt
   ```
   
   Three pieces:
   
   1. `Channel.start()` registers a `ConsumerRebalanceListener` and adds a 
protected `onControlPartitionsRevoked` hook (default no-op).
   2. `Coordinator` overrides that hook to call a new `CommitState.reset()`.
   3. `CommitState.reset()` is `clearResponses()` + `endCurrentCommit()` — it 
drops both buffers, the counter, and the commit id.
   
   State is **reset, never flushed**. Flushing control-topic offsets without 
committing the files would drop data. The events stay on the control topic and 
are re-read; nothing is lost.
   
   `startTime` is deliberately preserved, so a commit interrupted mid-cycle 
re-drives on the next `process()` rather than waiting out another full commit 
interval.
   
   Partitions that are *lost* rather than cleanly revoked arrive at the same 
hook, since `ConsumerRebalanceListener.onPartitionsLost` delegates to 
`onPartitionsRevoked` by default.
   
   ## Why this is sufficient
   
   The invariant the sink depends on is:
   
   > `controlTopicOffsets[P] >= max(control offset of every buffered envelope 
for P) + 1`
   
   It holds naturally within a session, because `consumeAvailable` updates the 
map *before* dispatching to `receive()`. The eager rewind broke it by lowering 
the map while the buffer kept the higher offsets.
   
   `reset()` empties the buffer at the same moment the consumer rewinds, so the 
replay refills both in lockstep and the recorded floor can never be written 
behind its own snapshot contents again.
   
   With `currentCommitId == null`, `addReady` takes the `!isCommitInProgress()` 
branch and counts nothing, and `isCommitReady()` short-circuits to `false`. 
With `commitBuffer` empty, `tableCommitMap()` has nothing to hand to 
`AppendFiles`. Both paths are closed.
   
   ## The state machine, before and after
   
   ```mermaid
   stateDiagram-v2
       direction LR
       state "BEFORE — a rebalance ends nothing" as before {
           [*] --> Idle1
           Idle1: Idle
           Started1: Commit started
           Collecting1: Collecting
           Quorum1: Quorum check
           Committed1: Committed
           Idle1 --> Started1
           Started1 --> Collecting1
           Collecting1 --> Quorum1
           Quorum1 --> Committed1
           Rebalance1: Rebalance (unobserved)
           Rebalance1 --> Collecting1: replay re-buffers
           Rebalance1 --> Quorum1: id still matches
       }
   ```
   
   ```mermaid
   stateDiagram-v2
       direction LR
       state "AFTER — revoke is a real transition" as after {
           [*] --> Idle2
           Idle2: Idle
           Started2: Commit started
           Collecting2: Collecting
           Quorum2: Quorum check
           Committed2: Committed
           Idle2 --> Started2
           Started2 --> Collecting2
           Collecting2 --> Quorum2
           Quorum2 --> Committed2
           Revoked2: Partitions revoked
           Collecting2 --> Revoked2: rebalance
           Revoked2 --> Idle2: reset(), startTime kept
       }
   ```
   
   The arrows invert. Before, the replay points *into* a live commit. After, 
the rebalance pulls state *off* the rail and returns it to Idle.
   
   ## Tests
   
   - `TestCommitState.testResetDiscardsInFlightState` — asserts `reset()` 
clears both buffers, the counter and the commit id.
   - `TestCoordinator.testControlPartitionsRevokedResetsInFlightCommit` — 
drives a `MockConsumer` rebalance and asserts a fresh commit is started.
   - `TestCoordinator.testControlPartitionsRevokedRewindDoesNotDoubleCount` — 
models the rewind directly. Two source partitions, one reports, the partition 
is revoked and reassigned, and the same `DataWritten`/`DataComplete` pair is 
delivered again. Asserts no `CommitToTable` is emitted; without the reset the 
stale `DataComplete` pushes readiness to 2 and a commit fires with half its 
data.
   
   Verified that both coordinator tests **fail** when `commitState.reset()` is 
commented out, so they are not vacuous.
   
   ```
   19 tests, 0 failures  (TestCoordinator 14, TestCommitState 5)
   ./gradlew :iceberg-kafka-connect:spotlessCheck  BUILD SUCCESSFUL
   ```
   
   ## Scope
   
   Deliberately limited to the reset. Not addressed here, and better as 
separate PRs:
   
   - `controlTopicOffsets` is still written with `put()` rather than 
`merge(..., Long::max)`. Making the map monotonic is independent hardening.
   - `onPartitionsAssigned` is an empty listener. Seeking forward on assignment 
would prevent the replay entirely, which is a different and arguably stronger 
guarantee — it would also stop replayed events from distorting 
`validThroughTs()`.
   - The zombie-coordinator path (`CommitterImpl.hasLeaderPartition()` 
returning a partial member snapshot mid-rebalance, and `startCoordinator()` 
no-opping on `if (null == this.coordinatorThread)`) is untouched. #17376 
targets that.
   
   ## Related
   
   - #16282 — the report this fixes
   - #13763 — same symptom, trigger never confirmed
   - #13756 — coordinator lifecycle on task stop
   - #15710, #15651 — per-commitId `RowDelta` separation; addresses 
sequence-number collision for equality deletes, not append-only re-registration
   - #17376 — coordinator election / split-brain
   
   ---
   **AI Disclosure**
   - Model: Claude Opus 4.6
   - Platform/Tool: opencode
   - Human Oversight: partially reviewed
   - Prompt Summary: The source change (`Channel`, `CommitState`, 
`Coordinator`, and the three tests) was written by the human author. An AI 
agent was asked to analyse issue #16282 against the codebase, verify the 
failure mechanism, confirm the new tests fail without the fix, and draft this 
PR description and its diagrams. The description and diagrams are AI-generated 
and have been partially reviewed by the author.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to