Skip to content

PHOENIX-7961 Secondary index diverges from data table after TTL expiry on partial-touch upserts#2574

Open
sanjeet006py wants to merge 9 commits into
apache:masterfrom
sanjeet006py:fix-index-data-table-sync
Open

PHOENIX-7961 Secondary index diverges from data table after TTL expiry on partial-touch upserts#2574
sanjeet006py wants to merge 9 commits into
apache:masterfrom
sanjeet006py:fix-index-data-table-sync

Conversation

@sanjeet006py

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR makes Phoenix's internal current-row read during secondary-index maintenance TTL-mask exactly like an ordinary client read, so a
data table and its secondary index stay consistent after TTL expiry.

Index maintenance in IndexRegionObserver.preBatchMutateWithExceptions reads the current on-disk row via getCurrentRowStates to rebuild
the correct index entry. That read was opened directly through region.getScanner(scan), which bypasses the postScannerOpen coprocessor
hook — the only place a scan is normally wrapped in TTLRegionScanner. As a result the internal read was not TTL-masked, so on a "partial
touch" upsert (an UPSERT that does not re-write any index-referenced column) the index was rebuilt from
logically-expired-but-not-yet-compacted cells, while the data table correctly expired them on read.

Changes:

  1. Client side — ScanUtil.annotateMutationWithLiteralTTL (called from MutationState) threads onto each mutation exactly what the client
    read path sets as scan attributes: the empty-column CF/CQ (unconditionally for any literal-TTL table/view), a view's compiled literal
    TTL as the _TTL attribute (base tables rely on the CF-descriptor TTL), and IS_STRICT_TTL=false only when the table/view is non-strict.
  2. Server side — new ServerScanUtil (package org.apache.phoenix.coprocessor, module phoenix-core-server) reproduces the client read path
    server-side: setInternalScanAttributes / setInternalScanAttributesForPaging set the same empty-column/TTL/strict/paging attributes, and
    openRegionScanner wraps the scan in TTLRegionScanner + PagingRegionScanner exactly as postScannerOpen does. IndexRegionObserver
    captures the client-threaded attributes into the batch context (extractLiteralTTLForInternalScan) and passes them into
    getCurrentRowStates.
  3. Anchor the masking clock at batchTimestamp — batchTimestamp is computed before the current-row read, and the internal scan sets
    scan.setTimeRange(0, batchTimestamp) so TTLRegionScanner's "current time" equals the exact timestamp at which the index is rebuilt,
    closing a sub-millisecond boundary window between the scan-open wall clock and batchTimestamp. The half-open range drops nothing
    current, since every pre-existing locked-row cell was written at ts < batchTimestamp.
  4. Companion correctness fix — moving getBatchTimestamp earlier means a registered set could be structurally mutated outside its
    synchronized block; batchesWithLastTimestamp now registers a defensive TreeSet snapshot to avoid a ConcurrentModificationException,
    staying conservative for the sleep/timestamp invariant (at most an extra sleep, never a missed one).

Production files touched: IndexRegionObserver, ScanUtil, new ServerScanUtil, MutationState, and a doc-comment update in MetaDataClient.

Why are the changes needed?

This is a silent data-integrity/correctness bug. On a table or view with a literal TTL and a secondary index, after TTL expiry the
secondary index can return a column value the data table no longer returns — the two diverge with no error raised. The divergence
surfaces after logical TTL expiry and before major compaction physically purges the expired cells.

The root cause is that the internal current-row read bypassed TTLRegionScanner, so index rebuild saw expired cells the data-side read
masks. The affected paths are: secondary indexes (global covered, global uncovered, and immutable indexes whose data/index storage
schemes differ) and the no-index current-row reads on a literal-TTL table (atomic / ON DUPLICATE KEY upserts, returnResult upserts, and
row deletes). Conditional-TTL, non-TTL, and non-strict-TTL tables are unaffected — masking is a no-op there and the read is
byte-identical to before.

Does this PR introduce any user-facing change?

Yes — a bug fix (behavior change) relative to master and released versions, for tables/views with a literal TTL and a secondary index.

  • Before: on a partial-touch upsert near/after the TTL boundary, the secondary index could return a value that the data table no longer
    returned (index resurrected a logically-expired value); data and index disagreed.
  • After: the internal current-row read is TTL-masked identically to a client read and anchored at batchTimestamp, so the index is
    rebuilt from the same masked state the data table exposes, and the two stay consistent.

No API, syntax, or configuration change. No new config flag is introduced.

How was this patch tested?

New integration test IndexDataTableSyncIT (parameterized over column-encoded on/off), covering:

  • base-table covered-column re-sync;
  • uncovered-index indexed-column re-sync;
  • view covered-column re-sync;
  • non-strict table is not masked;
  • no-index atomic (ON DUPLICATE KEY) upsert masks an expired row;
  • UPSERT ... SELECT and UPSERT ... VALUES touches near the TTL boundary keep data and index consistent;
  • an immutable index with a differing storage scheme;
  • a concurrency regression (testConcurrentMajorCompactionDuringIndexWriteKeepsDataAndIndexConsistent) where a data-table major
    compaction runs during a slow index write — after the current-row read and after the data locks are released, but before the index write
    completes — and data/index must remain consistent.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Anthropic Claude Opus 4.8)

Sanjeet Malhotra added 9 commits July 5, 2026 02:52
…nc in IndexRegionObserver

Make server-side secondary-index maintenance honor Phoenix TTL exactly like
a client read, and re-persist index-referenced data columns so a data table
and its global index are retained or expired together under compaction.

Two distinct defects caused a global index to keep a value the data table no
longer had after TTL expiry:

- The internal current-row scan in IndexRegionObserver.preBatchMutate opened
  via region.getScanner bypassed postScannerOpen, so it was never wrapped in
  TTLRegionScanner and read TTL-expired-but-present cells, rebuilding the
  index from logically-expired data.

- A partial "touch" upsert that omitted an index-referenced column left the
  data-side cell at its original timestamp while the index cell was rebuilt
  at batchTimestamp; a later major compaction opened a >ttl gap on only the
  data side, dropping the value there while the index kept it.

Changes:
- New ServerScanUtil (setInternalScanAttributes, openRegionScanner) that sets
  the empty-column / TTL / strictness scan attributes and opens a
  TTLRegionScanner-wrapped scanner mirroring postScannerOpen.
- New ScanUtil.annotateMutationWithLiteralTTL, called from
  MutationState.sendMutations, threading a view's literal TTL and non-strict
  flag to the server per mutation.
- IndexRegionObserver: extract/remove a literal _TTL mutation attribute before
  identifyMutationTypes so it is never misread as conditional TTL; wire the
  current-row scan through setInternalScanAttributes + openRegionScanner; and
  re-inject index-referenced non-PK columns into the touch's data Put at
  LATEST_TIMESTAMP so setTimestamps re-stamps them to batchTimestamp.
- New phoenix.index.ttl.column.resync.enabled flag (default true) gating the
  column re-sync as an operator kill switch.
- New IndexDataTableTTLSyncIT and IndexDataTableTTLSyncFlagOffIT covering
  base-table, view, non-strict, and flag-off cases.
…ow reads

Set the empty-column CF/CQ mutation attributes unconditionally in
ScanUtil.annotateMutationWithLiteralTTL for any mutable literal-TTL
table/view, mirroring the client read path (setScanAttributesForClient),
which sets the empty column on every non-analyze scan. They only identify
the table's empty column and enable masking; TTLRegionScanner still
independently requires an effective, non-FOREVER, strict TTL to actually
mask, so setting them whenever a current-row read may happen makes the
internal scan mask identically to a client read rather than diverging.

Server side, getCurrentRowStates now falls back to these client-threaded
CF/CQ bytes when no IndexMaintainer is available, so the no-index
current-row read (atomic / ON DUPLICATE KEY / returnResult / row-delete on
a TTL table) is masked and an expired row is treated as absent instead of
resurrected. extractLiteralTTLForInternalScan captures the CF/CQ off the
representative mutation regardless of the _TTL attribute and leaves them on
the mutations (inert on the write path).

The internal scan is also given the client read path's server-paging setup
(SERVER_PAGE_SIZE_MS plus a PagingFilter wrap) via
ServerScanUtil.setInternalScanAttributesForPaging, and readDataTableRows
skips the dummy results paging can emit.

Extend the index-referenced column re-sync to uncovered global indexes: an
uncovered index encodes its indexed column positionally into the index key
rebuilt at batchTimestamp, so the indexed column must be re-persisted on the
data side too, else a live row silently drops out of an indexed-column
predicate after compaction trims the stale data-side cell.

Add ITs: testNoIndexAtomicUpsertMasksExpiredRow (no-index masking),
testUncoveredIndexIndexedColumnResync, and a flag-off uncovered counterpart.
The client now threads the empty-column CF/CQ onto every mutation
unconditionally (ScanUtil.annotateMutationWithLiteralTTL, matching
setScanAttributesForClient), and the batch context captures them. That
is the single source for every path reaching getCurrentRowStates -- the
secondary-index case and the no-index atomic / ON DUPLICATE KEY /
returnResult / row-delete case alike -- so the maintainer branch is
redundant. Remove getDataTableMaintainerForInternalScan and the
IndexMaintainer parameter from getCurrentRowStates; resolve emptyCF/CQ
solely from the client-threaded batch-context bytes. This ties
internal-scan masking to the same client signal that governs client
read masking, keeping the two consistent.
…ath; drop config gate

The plain-upsert re-sync (rewriteIndexReferencedColumns) deliberately excludes
atomic / ON DUPLICATE KEY / returnResult Puts because generateOnDupMutations
reconstructs them from conditional expressions rather than writing them verbatim.
That left the atomic path re-creating the covered-column timestamp-skew divergence:
a touch that omits an index-referenced column leaves the data-side cell at its
original timestamp while the index side is rebuilt at batchTimestamp, so a later
major compaction drops the value only on the data table.

Add the equivalent re-sync inline in generateOnDupMutations:
- Snapshot the pre-upsert row into a local oldRowColumnCellExprMap (only exposed on
  the context under the existing OLD_ROW condition, unchanged semantics).
- rewriteIndexReferencedColumnsForAtomicPut injects every index-referenced non-PK
  column the upsert omitted, from that snapshot, at LATEST_TIMESTAMP so setTimestamps
  re-stamps it to batchTimestamp alongside the rest of the Put.
- Inject at the two upsert-reconstruction points: the opBytes == null (plain upsert
  with returnResult) branch into atomicPut, and after the checkCellNeedUpdate loop
  into the reconstructed put, guarded by !put.isEmpty() so a genuine no-op ON
  DUPLICATE KEY UPDATE is not turned into a spurious write.
- Share the index-referenced column-set computation via getIndexReferencedColumns.

Remove the phoenix.index.ttl.column.resync.enabled config gate entirely: the re-sync
now always applies (constants, field, start() init, and both guard sites deleted).
Delete IndexDataTableTTLSyncFlagOffIT (it only asserted the kill switch) and fix its
now-dangling javadoc references in IndexDataTableTTLSyncIT.
…ncedColumns

The previous commit added a separate rewriteIndexReferencedColumnsForAtomicPut on
the ON DUPLICATE KEY path because the general re-sync excluded atomic / returnResult
Puts. Relocating and re-sourcing the general re-sync makes that separate path
redundant, so remove it and let a single method cover every Put.

Relocate and re-source rewriteIndexReferencedColumns:
- Move the call to after prepareDataRowStates (was before setTimestamps) and source
  the re-persisted value from the merged next row state dataRowStates.getSecond()
  instead of the raw current row getFirst().
- This fixes a NULL-set resurrection bug: a column the touch sets to NULL is emitted
  as a separate Delete, not a Put cell, so getFirst() still held the old value and
  the previous logic (guarded only on put.has) resurrected it. After
  prepareDataRowStates the merged next state has the Delete applied, so a NULLed
  column is absent from getSecond() and correctly not re-persisted. The index is
  generated from the same getSecond(), so data and index always agree.
- Inject injected cells directly at batchTimestamp (setTimestamps has already run),
  matching the index cell rebuilt at the same timestamp.

Extend the same method to the atomic / ON DUPLICATE KEY / returnResult path:
- addOnDupMutationsToBatch runs before prepareDataRowStates and merges each
  reconstructed atomic Put (and its coproc Delete) into the in-batch operation, so
  by the time rewriteIndexReferencedColumns runs the atomic row's getSecond() is its
  fully reconstructed next state. Replace the atomic/returnResult exclusion with an
  isAtomicOperationComplete guard so genuine no-op ON DUPLICATE KEY / IGNORE ops are
  skipped (nothing written) while non-no-op atomic Puts are re-synced like any Put.
- Sourcing from getSecond() rather than the pre-upsert snapshot also fixes the same
  NULL-set resurrection for ON DUPLICATE KEY UPDATE ... = NULL.
- Delete rewriteIndexReferencedColumnsForAtomicPut, its two call sites, the now-dead
  indexReferencedCols local, and the now-unused PhoenixIndexMetaData parameter
  threaded through generateOnDupMutations / addOnDupMutationsToBatch. getIndex
  ReferencedColumns now has a single caller.

Thread empty-column CF/CQ unconditionally on the internal current-row scan:
- Drop the maskInternalScan gate in getCurrentRowStates and always call
  ServerScanUtil.setInternalScanAttributes at both scan branches. TTLRegionScanner
  no-ops masking when the empty-column attributes are absent, so passing null args is
  safe and keeps the internal scan masking identically to a client read.
… comments

Follow-up to the atomic / ON DUPLICATE KEY re-sync consolidation.

- Correct the rewriteIndexReferencedColumns Javadoc: it now processes every
  index-enabled Put (plain upsert or reconstructed atomic / ON DUPLICATE KEY upsert
  alike), not only non-atomic Puts. The stale "non-atomic" wording contradicted both
  the method body and its own closing paragraph.
- Trim the getIndexReferencedColumns Javadoc: drop the "shared by ... generateOnDup
  Mutations (atomic path)" note now that the atomic path no longer calls it; the
  method has a single caller.
- Simplify the OLD_ROW snapshot in generateOnDupMutations: build
  context.oldRowColumnCellExprMap directly under the returnOldRow condition instead
  of via a throwaway local, now that the local is no longer needed for the removed
  atomic re-sync.
- Remove a stray whitespace-only line before the addEmptyKVCellToPut block.
…undary data/index consistency

Adds an integration test that reproduces the RCA divergence against pre-fix
code and verifies the fix keeps a data table and its global covered index
consistent when a partial touch upsert lands across the TTL expiry boundary.
The test asserts data/index agreement on the covered and indexed columns
(GREEN when consistent, RED on the pre-fix divergence), major-compacting both
tables so gap-analysis trimming is applied physically. Uses unique per-method
physical table names so future-dated cells from the injected clock cannot
bridge the next method's TTL gap on the shared mini-cluster.
…teIndexReferencedColumns

Replace the re-persist fix for the data/index TTL-boundary divergence with a
TTL-anchored masked read. On a partial "touch" upsert that does not re-write an
index-referenced column, the index side is rebuilt in full at batchTimestamp
while the data-side cell keeps its original timestamp; near the TTL boundary the
internal current-row read was masked as of the scan-open wall clock, which is
slightly earlier than batchTimestamp (getBatchTimestamp bumps it forward), so a
cell expiring in that sub-millisecond window was visible to the index build yet
let expire on the data side.

Fix: compute batchTimestamp right after lockRows (before the current-row read)
and set scan.setTimeRange(0, batchTimestamp) on the existing TTLRegionScanner-
wrapped internal scan, so TTLRegionScanner anchors its masking clock
(currentTime = scan.getTimeRange().getMax()) at exactly the timestamp the index
is built at. CompactionScanner converges the data side to the identical keep/drop
decision via the same empty-column gap rule, so both sides always agree. This
avoids the read-all-versions GC pressure and the per-touch write amplification of
the previous approach.

- IndexRegionObserver: hoist getBatchTimestamp above the current-row read; thread
  batchTimestamp into getCurrentRowStates and setTimeRange both scan branches;
  register a defensive TreeSet copy in batchesWithLastTimestamp (the reorder puts
  registration before releaseLocksForOnDupIgnoreMutations's unsynchronized
  remove); delete rewriteIndexReferencedColumns and getIndexReferencedColumns.
- ScanUtil.annotateMutationWithLiteralTTL: no longer early-return for immutable
  tables (the server-side literal-TTL current-row read is driven by table/index
  structure and mutation type, not the annotated attribute).
- IndexDataTableTTLSyncIT: invert the four re-stamp assertions to assert the data
  cell retains its original timestamp; rewrite Javadocs for the anchored trim.
- IndexDataTableTTLBoundaryConsistencyIT: set max-lookback non-zero (below TTL);
  assert data/index agreement at the query layer.
…-sync ITs

Rename IndexDataTableTTLSyncIT to IndexDataTableSyncIT and add
testConcurrentMajorCompactionDuringIndexWriteKeepsDataAndIndexConsistent,
which parks the index write via a blocking region observer on the index
table so a data-table major compaction runs after the current-row read
but before the index write completes, then asserts data/index agreement.

Remove the redundant IndexDataTableTTLBoundaryConsistencyIT (its boundary
coverage is subsumed by IndexDataTableSyncIT).

Remaining changes are doc/comment reflow and an unused-import removal in
ServerScanUtil; no production logic changes (the fix landed in 339fe94).
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.

1 participant