xiangfu0 opened a new pull request, #20020:
URL: https://github.com/apache/pinot/pull/20020

   ## Problem
   
   A real-time table on a **low-traffic transactional Kafka topic** paged 
`StreamDataLoss` (P0) three times in 46 minutes, every time on a one-offset gap 
(196 → 197, 480 → 481). The committed segment metadata accounted for every 
offset and the controller's retention-loss meter stayed at zero: nothing was 
lost. The same pattern recurs daily on another production table.
   
   What happens: a transactional producer writes a commit marker after each 
transaction. The marker occupies an offset but the Kafka client strips control 
batches under both isolation levels, so after consuming record `N` the consumer 
is parked at `N + 1` (the marker) and the next record lands at `N + 2`. 
`RealtimeSegmentDataManager` keeps requesting `N + 1` because no record was 
ever delivered there, so with one record per transaction every new transaction 
shows up as `firstOffset > startOffset`. Before #19161 that was reported as 
data loss on every poll. The per-table workaround 
`stream.kafka.isolation.level: read_committed` suppresses it, but a customer 
config must not be able to fire a false P0, so detection has to be right in 
code.
   
   Internal tracking: DATA-2966.
   
   ## What master already has
   
   #19161 (merged 2026-08-12, after the pages above) replaced the contiguity 
assumption with the retention-truncation signal: a gap is loss only when the 
broker's **log start offset** (`beginningOffsets`) is above the requested 
`startOffset`, i.e. records at/after `startOffset` were actually deleted. That 
is the correct signal and this PR keeps it. Deployments pinned before #19161 
still produce the false alerts; this PR hardens what sits around that check.
   
   ## Changes
   
   **`RealtimeSegmentDataManager.reportDataLoss`** (pinot-core)
   - The `STREAM_DATA_LOSS` gauge is still set for every batch that reports 
loss (that is the alert signal).
   - The `ERROR` log line and the segment error entry are now emitted at most 
**once per minute per segment**, and carry the number of batches with loss 
since the previous report. Previously both fired on every poll.
   
   **`KafkaPartitionLevelConsumer`** (pinot-kafka-3.0 and pinot-kafka-4.0, 
identical)
   - After an **empty poll**, `_nextReadOffset` is snapped to 
`KafkaConsumer.position()` under both isolation levels, not only 
`read_committed`. The old comment claimed a `read_uncommitted` empty poll can 
never move the position; it can, past control records (the client returns an 
empty poll as soon as its position advanced over a control batch). 
`getOffsetOfNextBatch()` of an empty batch now reports where consumption really 
resumes. `position()` is answered from the client's local state once a position 
is known; only while a partition is awaiting an out-of-range reset does it 
block, bounded by the fetch timeout, which is the bound master already had for 
`read_committed` empty polls and for the gap lookup.
     - Two callers consume that offset. `RealtimeSegmentDataManager` ignores it 
for batches with zero unfiltered messages, so its `_currentOffset` and the loss 
decision are unchanged. `StatelessRealtimeSegmentWriter` (re-ingestion) adopts 
it after every fetch, so under `read_uncommitted` it now steps past a skipped 
commit marker on the empty poll instead of on the next data poll; nothing but 
control records (and aborted records under `read_committed`, as before) can be 
stepped over, and an out-of-range reset lands it where the following poll would 
have put it anyway.
   - The `WARN` for a failed log-start-offset lookup (added in #19161, where 
the reviewer asked whether it needed a rate limiter) is throttled to once per 
minute with a suppressed count; suppressed failures still log at `DEBUG`.
   - Comments spell out why the gap is compared against the caller's 
`startOffset` and why the broker lookup is the cheapest correct signal (below).
   
   No config, API, wire or serialization change. `reportDataLoss` goes from 
`private` to package-private (`@VisibleForTesting`).
   
   ## Why the detection algorithm stays as in #19161
   
   I looked for a cheaper way to tell a skipped control record from real loss:
   
   - **Control flag**: not exposed. `ConsumerRecord` has no control bit; 
`CompletedFetch` drops control batches and only bumps `nextFetchOffset`.
   - **Consumer position as the baseline**: a jump in `position()` that no 
delivered record explains is either skipped control/aborted batches (benign) or 
an `auto.offset.reset` after `OffsetOutOfRange` (real loss). Both look 
identical through the client API. Validating a jump needs the same 
`beginningOffsets` round trip, so on the ticket's topic it only moves the 
lookup from the data poll to the preceding empty poll: same count (one per 
transaction), more state. Using the position as the baseline *without* 
validating jumps would hide real loss whenever a lagging consumer's reset 
completes inside an empty poll. So the check stays keyed on the caller's 
`startOffset`, which is stale but conservative.
   - **Cost today**: one `ListOffsets` round trip per gap, never on the 
contiguous hot path. On a low-traffic transactional topic that is one per 
transaction; while catching up, at most one per poll that happens to start at a 
marker.
   - **Zero-cost design (follow-up, not done here)**: handle 
`OffsetOutOfRangeException` in Pinot itself (`auto.offset.reset=none` towards 
the client, apply the configured policy by hand) and flag loss exactly when it 
fires. That changes how the configured reset policy is applied and deserves its 
own review.
   
   ## Should `read_committed` become the default on transactional topics?
   
   Recommendation only, not implemented:
   
   - It is a **semantic change**: `read_uncommitted` delivers records of 
aborted and in-flight transactions; flipping the default silently drops rows 
that existing tables ingest today.
   - It is an **availability change**: under `read_committed` the consumer 
cannot read past the last stable offset, so an open or hung transaction stalls 
the partition until `transaction.timeout.ms` (60 s by default, up to the 
broker's 15 min cap).
   - There is **no consumer-side way to detect a transactional topic** (the 
control batches are stripped), and a topic can become transactional after the 
table is created.
   - Since #19161 the detection no longer depends on the isolation level, so 
there is no correctness reason to flip it. 
`stream.kafka.isolation.level=read_committed` stays the right setting for 
tables that want exactly-once semantics, not a workaround for the alert.
   
   ## `recordsLag` pinned at 1 (documented, not fixed)
   
   On an idle transactional partition the last offset in the log is a commit 
marker, so the high watermark (and the last stable offset) is `N + 2` while the 
segment's current offset is `N + 1`: nobody can ever consume `N + 1`, so the 
lag reads 1. The consumer now reports the real position (`N + 2`) as 
`getOffsetOfNextBatch()` of the empty batch, but `RealtimeSegmentDataManager` 
only adopts that offset when a batch had messages that were all filtered out. 
Adopting it for empty batches would fix the lag and also remove the 
stale-`startOffset` round trip above, but it changes offset-advancement 
semantics for every stream plugin (Kinesis, Pulsar), so it is left for a 
follow-up.
   
   ## Tests
   
   Both Kafka plugins (embedded KRaft broker for 3.0, Testcontainers 
`apache/kafka:4.0.0` for 4.0):
   
   | Test | Scenario | Expected |
   |---|---|---|
   | 
`DataLossIntegrationTest.testCaughtUpConsumerOnLowTrafficTransactionalTopicIsNotDataLoss`
 (new) | one-record transactions; consumer caught up at the marker; next record 
one past the requested offset, twice | no loss; empty batch reports position 
past the marker |
   | 
`DataLossIntegrationTest.testTransactionalGapWithRetainedDataIsNotDataLoss` 
(existing) | real commit-marker gap, data retained | no loss |
   | `DataLossIntegrationTest.testTruncatedStartOffsetIsDataLoss` (existing) | 
`deleteRecords` moved the log start past `startOffset` | **loss** |
   | `DataLossIntegrationTest.testReadCommittedGapIsNotDataLoss` (existing) | 
`read_committed` gap | no loss |
   | `DataLossCheckTest` (new, mocked) | contiguous batch / retained gap / 
truncated gap / `read_committed` / lookup failure | `beginningOffsets` never / 
once / once / never / twice; loss only for the truncated gap |
   | 
`SeekTest.testRepeatedStartOffsetAfterReadUncommittedEmptyFetchDoesNotSeekAgain`
 (new) | empty poll moved the position past a marker | `getOffsetOfNextBatch()` 
= position, no re-seek, no loss |
   
   pinot-core: `RealtimeSegmentDataManagerTest.testDataLossReportIsRateLimited` 
(new) drives `reportDataLoss` with a fake clock: first detection reported, 10 
more within the minute suppressed, the next one after the minute reported with 
"batches with loss since last report: 11", batches without loss never reported.
   
   Local runs (JDK 25, Kafka clients 3.9.2 / 4.2.0, `apache/kafka:4.0.0` 
container for the 4.0 suite):
   
   | Module | Result |
   |---|---|
   | `pinot-kafka-3.0`, all `*Kafka*` classes | 68 tests, 0 failures (new: 5 
`DataLossCheckTest`, 1 integration scenario, 1 seek case) |
   | `pinot-kafka-4.0`, all `*Kafka*` classes | 51 tests, 0 failures (same new 
cases; integration suite against a real 4.0 broker) |
   | `pinot-kafka-base` | 47 tests, 0 failures |
   | `pinot-core` `RealtimeSegmentDataManagerTest` | 39 tests, 0 failures (new: 
`testDataLossReportIsRateLimited`) |
   
   `spotless`, `checkstyle` and `license` are clean on the three touched 
modules.
   
   ## Runbook
   
   Once this is merged the `StreamDataLoss` runbook should get an entry: when 
the gauge fires, read the segment error ("Message loss detected ... 
batchFirstOffset ... batches with loss since last report"), compare the 
partition's `beginningOffsets` with the segment's start offset, and treat 
one-offset gaps on transactional topics from versions without #19161 as known 
false positives.
   


-- 
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