Visitar URL original
Persist upsert snapshot diagnostics for replica divergence triage by KKcorps · Pull Request #19637 · apache/pinot · GitHub
Skip to content

Persist upsert snapshot diagnostics for replica divergence triage - #19637

Merged
KKcorps merged 8 commits into
apache:masterfrom
KKcorps:kk/upsert-observability
Oct 9, 2026
Merged

KKcorps merged 8 commits into
apache:masterfrom
KKcorps:kk/upsert-observability

Conversation

@KKcorps

@KKcorps KKcorps commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

PR flow

Flow: ConsumerCoordinator provides trigger to RealtimeSegmentDataManager, which triggers snapshot via the upsert manager. ImmutableSegmentImpl writes/reads DocIdsSnapshot. TablesResource loads snapshot and adds diagnostics to response.

flowchart TD
  N0["ConsumerCoordinator #40;F1#44; F2#41;"]:::stModified
  N1["RealtimeSegmentDataManager #40;F2#41;"]:::stModified
  N2["BasePartitionUpsertMetadataManager #40;F4#41;"]:::stModified
  N3["ImmutableSegmentImpl #40;F3#41;"]:::stModified
  N4["DocIdsSnapshot #40;F5#41;"]:::stAdded
  N5["TablesResource #40;F13#41;"]:::stModified
  N0 -->|"getUnsealedRelease"| N1
  N1 -->|"takeSnapshot"| N2
  N2 -->|"persistDocIdsSnapshot"| N3
  N3 -->|"write#47;fromBytes"| N4
  N5 -->|"loadDocIdsSnapshot"| N3
  N5 -->|"metadata#40;#41;"| N4
  classDef stAdded fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
  classDef stModified fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
  classDef stRemoved fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
  classDef stUnchanged fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Diff evidence
  • F1: pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/ConsumerCoordinator.java — before · after
  • F2: pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManager.java — before · after
  • F3: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/immutable/ImmutableSegmentImpl.java — before · after
  • F4: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/BasePartitionUpsertMetadataManager.java — before · after
  • F5: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/DocIdsSnapshot.java — after
  • F13: pinot-server/src/main/java/org/apache/pinot/server/api/resources/TablesResource.java — before · after
  • Regenerate PR flow

Equal valid-document counts can hide different document IDs on two replicas. This adds persisted
bitmap fingerprints and capture context to the server
POST /tables/{tableNameWithType}/validDocIdsMetadata response, so an operator can identify which
saved segment snapshots differ and judge whether the observations are comparable.

Diagnostics are included by default for SNAPSHOT and SNAPSHOT_WITH_DELETE when the saved file
contains them. There is no request flag. IN_MEMORY and IN_MEMORY_WITH_DELETE do not expose these
fields.

How the fields help triage

Each segment's additional fields are grouped under diagnostics:

Field Triage value
docIdsCrc, docIdsType Detects different saved document-ID membership even when totalValidDocs matches. docIdsType says whether the CRC covers VALID_DOC_IDS or QUERYABLE_DOC_IDS. Compare the same segment contents and bitmap type, using the existing segment CRCs as context.
snapshotCapturedAtMs Identifies when this particular bitmap was captured. Correlate it with rollover, replacement, restart, and snapshot logs. Repeated API requests retain the original timestamp.
snapshotAgeMs Shows how old the saved observation is at request time. Helps distinguish an old persisted view from a recent observation; unchanged segments can legitimately retain old snapshots. Omitted if the capture timestamp is ahead of the server clock.
snapshotConsumingSegmentName, snapshotConsumedUpToOffset Identify the consumer startup that triggered this snapshot. The offset is exclusive and per replica: it is the consuming segment's start offset, or where this replica's previous segment stopped if that segment was still unsealed (parallel build or download), so the snapshot reflects messages before it. Different values help explain replicas reporting snapshots from different ingestion stages and identify the relevant consuming-segment logs. Omitted for snapshots without startup context.

A CRC mismatch narrows manual inspection to a saved bitmap; matching CRCs are not proof of equality.
The consumed-up-to offset is not a partition consistency watermark: predecessor replacement can still
be in progress, and segment snapshots are captured separately. Even matching trigger context does
not prove the replicas captured equivalent partition state. Use the existing bitmap download API
for document-ID inspection and ingestion diagnostics for current progress.

Capture and persistence

sequenceDiagram
  participant C as Consumer startup
  participant U as Upsert manager
  participant F as Snapshot file
  participant A as Metadata API
  C->>U: Snapshot with consuming segment name and consumed-up-to offset
  U->>U: Capture bitmap and timestamp; hash detached IDs
  U->>F: Publish bitmap and diagnostics with existing file rename
  A->>F: Read bitmap and diagnostics together
  A->>A: Derive age from saved capture time
Loading

The checksum hashes ascending document IDs as four big-endian bytes each, independent of Roaring
container layout. Each written bitmap gets its own capture timestamp and fingerprint. Unchanged
or skipped snapshots retain their previous metadata. A later snapshot without startup context
does not inherit an earlier consumer's offset.

A versioned trailer keeps diagnostics in the same file as the bitmap, so a file replacement cannot
pair a bitmap with another capture's metadata. Existing Roaring readers still read the bitmap
prefix. Legacy files remain readable and omit diagnostics; unreadable or unsupported trailers do
not block recovery of a valid bitmap. The bitmap download response remains bitmap-only, so peer
copies made through that endpoint also omit diagnostics until a local snapshot is written.

This adds an O(valid document IDs) checksum pass outside the bitmap lock when a snapshot is written.
The trailer is written after the bitmap bytes, so the bitmap is not copied again. The API parses metadata from the snapshot bytes it already reads;
it does not compute a checksum or scan consumers. Existing snapshot scheduling, TTL ordering, and
segment-lock behavior are preserved.

Validation

113 targeted tests passed across the snapshot codec/manager, consumer startup, and server API.
Coverage includes equal counts with different membership, different Roaring layouts, old readers,
legacy/malformed/unknown-version trailers, retained metadata for unchanged and skipped snapshots,
startup offset forwarding in both TTL branches, the previous segment's stop offset (ahead, behind, and
a stale record from an older segment), and omission for both in-memory modes.

./mvnw -pl pinot-server -am \
  -Dtest='DocIdsSnapshotTest,BasePartitionUpsertMetadataManagerTest,ConcurrentMapPartitionUpsertMetadataManagerTest,ConsumerCoordinatorTest,RealtimeSegmentDataManagerTest,TablesResourceTest#testValidDocIdsMetadata*+testDownloadValidDocIdsSnapshot' \
  -Dsurefire.failIfNoSpecifiedTests=false \
  -Dcheckstyle.skip -Dspotless.check.skip -Dlicense.skip test -B -ntp -e

Spotless, Checkstyle, and license checks passed on all four changed modules. Warning-enabled
compilation reported no warnings on added lines. The existing ZstandardDecompressor dependency
issue (org.jetbrains.annotations.NotNull missing only with deprecation diagnostics enabled) was
worked around using a temporary validation POM for pinot-segment-local; the PR does not change
build dependencies. Normal compilation and the tests above passed with the repository POMs.

@codecov-commenter

codecov-commenter commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.58559% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.57%. Comparing base (f818bae) to head (8d4aeb5).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
.../index/mutable/ThreadSafeMutableRoaringBitmap.java 0.00% 6 Missing ⚠️
...che/pinot/segment/local/upsert/DocIdsSnapshot.java 91.66% 0 Missing and 4 partials ⚠️
...che/pinot/server/api/resources/TablesResource.java 80.00% 1 Missing and 2 partials ⚠️
...t/local/upsert/PartitionUpsertMetadataManager.java 0.00% 2 Missing ⚠️
...a/manager/realtime/RealtimeSegmentDataManager.java 90.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19637      +/-   ##
============================================
+ Coverage     68.55%   68.57%   +0.02%     
  Complexity     1486     1486              
============================================
  Files          3525     3526       +1     
  Lines        229852   229939      +87     
  Branches      36507    36523      +16     
============================================
+ Hits         157568   157689     +121     
+ Misses        59965    59931      -34     
  Partials      12319    12319              
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 68.57% <85.58%> (+0.02%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 68.57% <85.58%> (+0.02%) ⬆️
unittests 68.57% <85.58%> (+0.02%) ⬆️
unittests1 58.29% <18.75%> (-0.02%) ⬇️
unittests2 40.20% <76.57%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@KKcorps KKcorps changed the title Add opt-in upsert bitmap diagnostics to server metadata API Persist upsert snapshot diagnostics for replica divergence triage Sep 23, 2026
crc.update(docId >>> 8);
crc.update(docId);
}
Metadata metadata = new Metadata(crc.getValue(), capturedAtMs, trigger != null ? trigger.segmentName() : null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we also capture end offset?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is actually end offset only. It's the start offset of current consuming segment at the beginning of which we are taking snapshot

let me rename this variable

@KKcorps KKcorps Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed it to consumedUpToOffset so it reads as where the snapshot's data ends (exclusive), not a start. Also renamed the CRC to docIdsCrc with a docIdsType saying valid or queryable, and the segment field to consumingSegmentName.

long capturedAtMs;
synchronized (bitmap) {
snapshot = bitmap.getBytesAndCardinality();
capturedAtMs = System.currentTimeMillis();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we need snapshotCapturedAtMs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is mostly to determine how stale the valid doc id snapshot is

@deepthi912

Copy link
Copy Markdown
Collaborator

/tables/{tableNameWithType}/validDocIdsMetadata controller API in PinotTableRestletResource is going to pick only one replica per segment and give us the details of the metadata. Are we going to add any other api to detect the differences between the replicas?

@KKcorps

KKcorps commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

/tables/{tableNameWithType}/validDocIdsMetadata controller API in PinotTableRestletResource is going to pick only one replica per segment and give us the details of the metadata. Are we going to add any other api to detect the differences between the replicas?

Yes Deepthi, that will be the next step

@deepthi912

deepthi912 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Thinking more about this: Does it satisfy the case where we have sorted column? When getting the details and showing the api results, do we ensure that there is no other segment replacements from Compaction or FIT invalidating at different pace across replicas?

@KKcorps

KKcorps commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Thinking more about this: Does it satisfy the case where we have sorted column? When getting the details and showing the api results, do we ensure that there is no other segment replacements from SRT or FIT invalidating at different pace across replicas?

So the details added in this PR mostly make sense when using on valid doc id snapshots and not on live bitmaps.
Valid doc id snapshots should be consistent across replicas if taken at single point in time.

I am planning to use this crc introduced here along with new data crc here https://github.com/startreedata/startree-pinot/pull/4594 to check for divergence

}
if (snapshotConsumingSegmentName != null && snapshotConsumedUpToOffset != null) {
response.put("snapshotConsumingSegmentName", snapshotConsumingSegmentName);
response.put("snapshotConsumedUpToOffset", snapshotConsumedUpToOffset);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The offset gives the same value across all the replicas. Can we somehow capture the actual offset until which each replica consumed as well? Rest of the changes looks good to me!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, it was the consuming segment's start offset so every replica showed the same value. Now if the previous segment was still unsealed at snapshot time (parallel build or download), it records where this replica's own consumer stopped, so a replica that ran ahead or fell behind shows its real offset.

@KKcorps
KKcorps force-pushed the kk/upsert-observability branch from 6b69e89 to 2736785 Compare October 7, 2026 03:21

@deepthi912 deepthi912 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the offset change — recording it before _semaphore.release() is the right ordering, and the name guard on clearUnsealedRelease correctly handles a later segment overwriting an earlier one.

Left comments inline. The one I'd like fixed before merge is the unsealed offset not being compared against the new segment's own start offset; the rest are things I'd change if cheap.

Two that don't anchor to a line:

  • The description's triage table still says snapshotConsumedUpToOffset "is the start offset of that consuming segment". That's stale after 2736785 — the Trigger javadoc in the code is the accurate one.

Things I checked that look fine: the trailer never leaves the server (no raw snapshot-file endpoint, and downloadValidDocIdsBitmap re-serializes docIds()); the other snapshot-file consumers (BaseTableDataManager reload-restore, SegmentPreloadUtils.hasValidDocIdsSnapshot) only copy or exists()-check; fromBytes legacy detection is safe and the ByteBuffer.wrap(bytes, off, len) arithmetic is correct since arrayOffset() is 0; and processValidDocIdsMetadata can't NPE on snapshot.metadata().

// mutable in the metadata, so this replica's snapshot covers its rows up to where it stopped.
ConsumerCoordinator.UnsealedRelease unsealedRelease = _consumerCoordinator.getUnsealedRelease();
String consumedUpToOffset =
unsealedRelease != null ? unsealedRelease.stoppedAtOffset().toString() : _startOffset.toString();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_unsealedRelease is only cleared by the segment that set it, in its own doOffload(). So if N's build or download stalls and N+1 commits sealed (plain release(), which sets nothing), N+2 still reads N's record and publishes an offset from two segments back — which reads as "this replica is far behind" when it isn't. That's the exact misreading this field exists to prevent.

Can we only use it when it's actually ahead of where this segment starts? StreamPartitionMsgOffset is Comparable:

String consumedUpToOffset = unsealedRelease != null
    && unsealedRelease.stoppedAtOffset().compareTo(_startOffset) > 0
    ? unsealedRelease.stoppedAtOffset().toString() : _startOffset.toString();

That also covers the race where the predecessor offloads between acquire and snapshot, which today silently falls back to _startOffset and makes the value nondeterministic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! It now uses the record only when it comes from the segment right before this one, so a stuck older segment falls back to _startOffset. I skipped the > _startOffset check since it would also hide a replica that fell behind and is downloading, and once the predecessor is offloaded _startOffset is already the right value.


// Scoped to the caller so concurrent snapshot triggers cannot exchange provenance. Preserve the existing virtual
// takeSnapshot()/doTakeSnapshot() hooks used by custom managers.
private final ThreadLocal<DocIdsSnapshot.Trigger> _snapshotTrigger = new ThreadLocal<>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The ThreadLocal returns null without any error if doTakeSnapshot() ever runs on a different thread, so the diagnostics would just quietly stop appearing. The constraint you're working around is real (subclasses override doTakeSnapshot()), but an overload keeps those working and makes the dataflow explicit:

protected void doTakeSnapshot() { doTakeSnapshot(null); }
protected void doTakeSnapshot(@Nullable DocIdsSnapshot.Trigger trigger) { ... }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, switched to the doTakeSnapshot(@Nullable Trigger) overload and dropped the ThreadLocal.

throws IOException {
ThreadSafeMutableRoaringBitmap.CardinalityAndBytes snapshot;
long capturedAtMs;
synchronized (bitmap) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This works only because ThreadSafeMutableRoaringBitmap.getBytesAndCardinality() is public synchronized, i.e. locks on this. If that class moves to a private lock, the timestamp silently stops being atomic with the capture and nothing fails to compile. Worth putting the combined accessor on ThreadSafeMutableRoaringBitmap itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, getBytesAndCardinality() now reads the clock under its own lock, so DocIdsSnapshot no longer syncs on the bitmap.

Metadata metadata = new Metadata(crc.getValue(), docIdsType, capturedAtMs,
trigger != null ? trigger.consumingSegmentName() : null, trigger != null ? trigger.consumedUpToOffset() : null);
byte[] json = JsonUtils.objectToString(metadata).getBytes(StandardCharsets.UTF_8);
byte[] bytes = ByteBuffer.allocate(snapshot.getBytes().length + 2 * Integer.BYTES + json.length)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this copies the whole serialized bitmap a second time — an extra MB-scale array per segment per snapshot round on large partitions. Writing the bitmap and the trailer as two writes in persistDocIdsSnapshot would avoid it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, persistDocIdsSnapshot now writes the bitmap and then the trailer, so there is no second copy.

@KKcorps

KKcorps commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review Deepthi! All four comments are addressed in d0fa749, and the description's offset row now matches the code.

Kartik Khare and others added 7 commits October 8, 2026 09:57
- snapshotTriggerStartOffset -> snapshotConsumedUpToOffset. The value is the new
  consuming segment's start offset, so it marks where the snapshot's data ends.
- validDocIdsCrc32 -> docIdsCrc32. The same field is written for the queryable
  doc ids file, so "valid" was wrong for SNAPSHOT_WITH_DELETE.
- snapshotTriggerSegmentName -> snapshotConsumingSegmentName, to pair with the
  offset and not read like the bitmap's own segment.
The same trailer is written for the valid and queryable doc ids files, so each
file now carries docIdsType (VALID_DOC_IDS or QUERYABLE_DOC_IDS) next to its
CRC, and the API returns both.
The default mapper fails on unknown fields, so a field added by a newer server
would make an older server drop all diagnostics for that file.
When the previous consuming segment releases the partition before it is
sealed (parallel build or download), its rows are still in the upsert
metadata when the next consumer snapshots. Hand its stop offset over
through the ConsumerCoordinator so snapshotConsumedUpToOffset shows what
this replica actually covered, not the shared start offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unsealed release is now used only when it comes from the segment right
before the new consumer. A record left by an older segment that was never
offloaded made the replica look far behind, although it consumed every segment
since. A predecessor that fell behind still reports where it stopped.

The trigger now flows through doTakeSnapshot(Trigger) instead of a ThreadLocal,
the capture time is taken inside getBytesAndCardinality() under the bitmap's own
lock, and the trailer is written after the bitmap instead of copying the bitmap
into a new array.
@KKcorps
KKcorps force-pushed the kk/upsert-observability branch from d0fa749 to 4f07c99 Compare October 8, 2026 04:31
@KKcorps
KKcorps merged commit ee2ee57 into apache:master Oct 9, 2026
14 of 15 checks passed
xiangfu0 added a commit to pinot-contrib/pinot-docs that referenced this pull request Oct 9, 2026
Follow-up to apache/pinot#19637. Documents persisted snapshot diagnostic
fields, supported modes, and replica-comparison limitations. Validation:
git diff --check and documentation validator pass (10 existing anchor
warnings).

Co-authored-by: Xiang Fu <xiangfu@Xiang-mac-mtv-2.local>
@xiangfu0

xiangfu0 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Documentation follow-up merged: pinot-contrib/pinot-docs#1094 — snapshot diagnostic fields and replica-comparison caveats.

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.

4 participants