PHOENIX-7996 Correctly maintain immutable global indexes on the server side for partial upserts - #2611
Conversation
…efault Flip DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED to true so that immutable, global, non-transactional secondary indexes are maintained server-side by IndexRegionObserver (PHOENIX-7426) without an explicit opt-in. The client stops generating index mutations for these tables and instead ships the serialized IndexMaintainer, which the region server uses to build index updates exactly once. Immutable data tables that declare a ROW_TIMESTAMP column stay client-maintained regardless of the flag. Server-side maintenance stamps every data cell with the server batch timestamp, which would overwrite the user-supplied ROW_TIMESTAMP value and silently drop rows on ROW_TIMESTAMP range scans (the same reason a mutable ROW_TIMESTAMP table with an index is rejected via CANNOT_CREATE_INDEX_ON_MUTABLE_TABLE_WITH_ROWTIMESTAMP). The decision is centralized in IndexUtil.isServerSideImmutableIndexMaintenanceEnabled and applied at every data-table gate that reads the flag so the client and server agree on which side maintains a given table. Tests that assert client-side index mutation accounting pin the flag off at the driver level; the index end-to-end helpers derive their expectation from the live flag. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k covered columns on partial immutable upserts Two correctness fixes needed once server-side maintenance of immutable indexes is on by default: - IndexRegionObserver skipped the current-row read-back for immutable batches, so a partial upsert that omitted a covered column built the index from the partial mutation alone and dropped that column from the index while it survived in the data table. Force a read-back for immutable covered-global batches when any enabled mutation omits an indexed or covered column, resolved to on-disk qualifiers so full-row and single-cell upserts keep the fast path. - The immutable server-serialize filter matched IndexType.GLOBAL only, so an uncovered global immutable index whose storage scheme matched the data table was serialized by neither client nor server and went unmaintained. Match IndexUtil.isGlobalIndex so uncovered global indexes are serialized to the region server too. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed global indexes Server-side maintenance of an immutable global index skips the current-row read-back. A partial upsert that omits an indexed column then builds the index entry from the partial mutation alone. The covered case was already handled; an uncovered global index has the same hazard: the omitted indexed column is materialized as null, so a spurious null-keyed index entry is written while the data row keeps the earlier value. A point lookup self-heals through the index read-repair join-back, but a server-side aggregate over the index rows returns the wrong count. Generalize the read-back predicate to union the required on-disk columns of both covered and uncovered global maintainers, and fire the read-back for immutable batches carrying either kind of global index when a partial upsert omits one of those columns. Columns resolve to their on-disk qualifiers per the storage scheme, so full-row upserts and single-cell tables keep the fast path. Add GlobalIndexCheckerIT#testPartialRowUpdateForImmutableUncovered covering the scenario via the index path. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two integration tests broke when server-side maintenance of immutable indexes became the default: - GlobalIndexCheckerIT#testPartialRowUpdateForImmutable and #testPartialRowUpdateForImmutableUncovered asserted on the index immediately after commit. Under CONSISTENCY=EVENTUAL the index is maintained asynchronously, so the assertions raced ahead of index convergence. Add the waitForEventualConsistency() call the other index-mutation assertions in this class already use. - DeleteIT#testPointDeleteRowFromTableWithImmutableIndex2 asserted a point delete on an immutable table with a non-PK index is never a single-row plan. With server-side maintenance the region server reads and maintains the index, so the point delete stays a single-row plan. Make the assertion aware of the server-side-immutable setting. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Differing storage schemes bypass the ROW_TIMESTAMP safeguard and can still trigger unsafe server-side maintenance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Enables server-side maintenance for immutable indexes by default while preserving client maintenance for ROW_TIMESTAMP tables and correcting partial-upsert handling.
Changes:
- Centralizes immutable-index routing and flips the default.
- Adds server read-back for partial immutable global-index mutations.
- Updates integration tests and metrics expectations.
File summaries
| File | Description |
|---|---|
PhoenixTableLevelMetricsIT.java |
Preserves client-side mutation metrics. |
BasePhoenixMetricsIT.java |
Pins client-side maintenance for metrics tests. |
GlobalIndexCheckerIT.java |
Tests partial uncovered-index updates. |
BaseIndexWithRegionMovesIT.java |
Updates mutation-routing assertions. |
BaseIndexIT.java |
Updates mutation-routing assertions. |
DeleteIT.java |
Adjusts delete-plan expectations. |
IndexRegionObserver.java |
Detects partial global-index mutations. |
IndexUtil.java |
Adds centralized ROW_TIMESTAMP guard. |
QueryServicesOptions.java |
Enables server maintenance by default. |
IndexMetaDataCacheClient.java |
Applies guarded metadata routing. |
IndexMaintainer.java |
Broadens server serialization to uncovered indexes. |
MutationState.java |
Applies guarded index-checker filtering. |
DeleteCompiler.java |
Routes delete maintenance using the data table. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return sendIndexMaintainer(index) && ((IndexUtil.isGlobalIndex(index) | ||
| && (dataTable.getImmutableStorageScheme() != index.getImmutableStorageScheme() | ||
| || connection.getQueryServices().getConfiguration().getBoolean( | ||
| SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB, | ||
| DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED))) | ||
| || IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(dataTable, connection))) |
There was a problem hiding this comment.
Confirmed — the analysis is correct. The storage-scheme-mismatch term is the left operand of the inner ||, so for a mismatched-scheme index the predicate short-circuits to true there and the ROW_TIMESTAMP carve-out on the right operand (isServerSideImmutableIndexMaintenanceEnabled) is never consulted. There is also no client-side rescue: getClientMaintainedIndexes routes an immutable ROW_TIMESTAMP table through maintainedGlobalIndexesWithMatchingStorageScheme, which excludes a cross-scheme index, so it cannot safely be client-maintained without a deeper change.
For a covered global index this routing is unchanged by this PR — the mismatch term is pre-existing (relative to the merge-base, the only edits to that operand are IndexType.GLOBAL -> IndexUtil.isGlobalIndex and the direct flag read -> the helper). So a covered mismatched-scheme index on a ROW_TIMESTAMP immutable table was already server-maintained and re-stamped before this flip. The isGlobalIndex broadening does newly bring UNCOVERED_GLOBAL mismatched-scheme indexes onto the server path, extending the same corner to the uncovered variant.
The correct fix is a server-side ROW_TIMESTAMP exemption in the re-stamp path (setTimestamps) rather than routing changes; it is orthogonal to this default flip and best handled separately.
…mmutable maintenance default PhoenixClientRpcIT.testIndexQos asserts that immutable-index writes do not use the region-server index RPC queue, which only holds for client-side maintenance. Pin the flag off in the test setup so it keeps exercising that path deterministically: under server-side maintenance the write reaches the index handler pool only when the index region is not colocated with the data region, which makes the assertion non-deterministic. IndexToolIT.testIndexToDataVerification* asserts the extra index rows are counted as EXTRA_VERIFIED for immutable tables. Under server-side maintenance immutable indexes adopt the mutable two-phase verified protocol, so make the expectation config-aware and assert EXTRA_UNVERIFIED when server-side maintenance is enabled. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| || context.hasStrictConditionalTTL() | ||
| || !context.immutableRows && context.hasUncoveredIndex | ||
| && isPartialUncoveredIndexMutation(indexMetaData, miniBatchOp) | ||
| || context.immutableRows && (context.hasGlobalIndex || context.hasUncoveredIndex) |
There was a problem hiding this comment.
Do you know why this check is now needed and how was the client handling partial updates ?
There was a problem hiding this comment.
Before this change, immutable indexes were maintained entirely on the client: each index mutation was built from only the columns present in the current upsert, with no read-back. So a partial upsert produced an index row from just those columns, and correctness was recovered lazily at read time — GlobalIndexChecker's two-phase verified/unverified protocol rebuilds the index row from the full data-table row on read. Uncovered indexes weren't maintained at write time at all.
Now that the server maintains immutable indexes at write time, a partial mutation no longer carries every column the index needs, so we have to read the current row state to build a correct entry. This check gates that read-back to the immutable global/uncovered case on partial mutations (isPartialGlobalIndexMutation), matching what the mutable path already does; full upserts (all index columns present) still skip it.
|
UpsertCompiler.java:629-633 (pre-existing) reads the flag directly, not through the new isServerSideImmutableIndexMaintenanceEnabled helper: |
|
Some test gaps identified
|
Add integration-test coverage in BaseImmutableIndexIT for partial upserts and deletes over immutable tables with indexes. The tests run under both ServerSideImmutableIndexIT (flag on) and ClientSideImmutableIndexIT (flag off), so they exercise both maintenance paths and do not depend on the default: partial upsert over a covered index (per storage scheme), over an uncovered index (COUNT path, which does not self-heal), across multiple global indexes, across multiple column families, with an index WHERE clause, a delete over an immutable ROW_TIMESTAMP table with an index, and a mixed matching/mismatched storage-scheme index case. Route the server-side-immutable-index flag read in UpsertCompiler through IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(table, connection) and drop the now-dead static imports. Functionally identical today, but every flag read now goes through the single helper. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@tkhurana added coverage in
Also added |
| 7 * 24 * 60 * 60 * 1000; /* 7 days */ | ||
| public static final boolean DEFAULT_INDEX_REGION_OBSERVER_ENABLED = true; | ||
| public static final boolean DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED = false; | ||
| public static final boolean DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED = true; |
There was a problem hiding this comment.
What kind of test coverage will we have if we keep this false by default ? Do we need to explicitly enable this in few ITs ?
There was a problem hiding this comment.
most of the tests added are in BaseImmutableIndexIT which is being extended by ServerSideImmutableIndexIT and ClientSideImmutableIndexIT (irrespective of default) we are testing both for these ITs atleast. Other general index ITs work on default value.
I'll update the PR to make it false by default and update the PR and JIRA accordingly. Thanks for approval
Revert DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED to false so this change delivers only the server-side correctness hardening without changing the product default. The opt-in flag still routes every gate through IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(...), the partial-upsert read-back, and the broadened uncovered-global serialize filter; flipping the default on is deferred to a follow-up. GlobalIndexCheckerIT#testPartialRowUpdateForImmutableUncovered now reads the effective flag and asserts the index-COUNT invariant only when server-side maintenance is enabled, mirroring BaseImmutableIndexIT: the scan path self-heals under both maintenance modes, but the aggregate path agrees only when the server rebuilds a verified entry from the read-back row. The point-lookup and IS NULL assertions remain unconditional. This keeps the test green with the default off and still exercises the COUNT invariant wherever the flag is enabled. Generated-by: Claude Code (Opus 4.8) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What changes were proposed in this pull request?
This hardens the server-side maintenance path for immutable, global, non-transactional secondary indexes and fixes two correctness gaps that server-side maintenance exposes. The client default
DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLEDstaysfalse— this change is a pure correctness fix for the opt-in path (phoenix.server.side.immutable.indexes.enabled=true). Flipping the default on is deferred to a separate follow-up so it can be adopted independently once the path is proven.When the flag is enabled, immutable global indexes are maintained server-side by
IndexRegionObserver(added in PHOENIX-7426) rather than by the client: the client ships the serializedIndexMaintainerand the region server builds the index updates exactly once.To keep the opt-in path safe, immutable data tables that declare a
ROW_TIMESTAMPcolumn continue to be maintained client-side regardless of the flag. The decision is centralized in a new helper,IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(...), and applied at every data-table gate that reads the flag:IndexUtil.getClientMaintainedIndexesIndexMaintainer.maintainedLocalOrGlobalIndexesWithoutMatchingStorageScheme(theINDEX_UUIDgate)MutationState.filterIndexCheckerMutationsDeleteCompiler.isMaintainedOnClient(signature extended to take the data table so the guard resolvesROW_TIMESTAMPagainst the data table, not a projected or index table)IndexMetaDataCacheClient.setMetaDataOnMutations(the send-metadata gate)UpsertCompiler's flag readRouting all gates through the same helper keeps the client and server in agreement on which side maintains a given table; a disagreement would cause a
ROW_TIMESTAMPtable to be maintained on both sides.The two correctness fixes, both required before server-side maintenance can be relied on:
IndexRegionObserverskips the current-row read-back for immutable batches. A partial upsert that omits an indexed, covered, or index-WHERE column then builds the index entry from the partial mutation alone. For a covered index this drops the column, so it is lost from the index while it survives in the data table (silent divergence, the same class of hazard as PHOENIX-7961). For an uncovered index the omitted indexed column is materialized as null, so a spurious null-keyed index entry is written while the data row keeps the earlier value: a point lookup self-heals through the index read-repair join-back, but a server-side aggregate over the index rows returns the wrong result. The read-back gate now forces a read-back for immutable batches carrying a covered or uncovered global index when any enabled mutation omits one of that index's on-disk columns. Columns are resolved to their on-disk qualifiers per the data table storage scheme, so full-row upserts and single-cell tables keep the fast path with no read-back. The gate trades a bounded in-memory column scan (only on the immutable global-index write path) for avoiding a disk read-back on the common full-row-upsert case.IndexMaintainermatchedIndexType.GLOBALonly. An uncovered global immutable index whose storage scheme matched the data table was therefore serialized by neither the client (which returns no client-maintained indexes for these tables when the flag is on) nor the server (whose filter dropped it), so it went unmaintained. The filter now matchesIndexUtil.isGlobalIndex, which covers bothGLOBALandUNCOVERED_GLOBAL.Why are the changes needed?
PHOENIX-7426 added server-side maintenance of immutable-table indexes behind a flag that defaults to off. Before that path can be enabled anywhere, two correctness gaps it exposes must be closed and the maintenance-side decision must be centralized so the client and server never disagree. This PR does both without changing the product default:
ROW_TIMESTAMPcarve-out is required for correctness whenever the flag is on. Server-side maintenance re-stamps every data cell — including theROW_TIMESTAMPcolumn — with the server batch timestamp, overwriting the user-suppliedROW_TIMESTAMPvalue.ROW_TIMESTAMPrange predicates push an HBase scanTimeRange, so re-stamped cells fall outside it and rows are silently dropped on range reads (SCN-based visibility breaks for the same reason). This is the same hazard behindCANNOT_CREATE_INDEX_ON_MUTABLE_TABLE_WITH_ROWTIMESTAMP, which already forbids the mutable variant; the immutable variant is safe only because it stays client-maintained.Does this PR introduce any user-facing change?
No default behavior change:
phoenix.server.side.immutable.indexes.enabledstaysfalse, so immutable global indexes remain client-maintained by default.When the flag is explicitly enabled:
ROW_TIMESTAMPcolumn stay client-maintained.IndexRegionObserverpath, which is enabled by default; on a default cluster a new client talking to an already-upgraded (or default-configured) server loses no index data. Silent index-data loss is only reachable on the deprecated legacy-Indexer configuration (phoenix.index.region.observer.enabled=false); clusters still on that configuration must move off it before enabling this flag.How was this patch tested?
RowTimestampIT— regression lock; asserts raw-scan cell timestamps equal the userROW_TIMESTAMPon both data and index tables for the immutable case. Passes with the guard.GlobalIndexCheckerIT#testPartialRowUpdateForImmutable— regression lock for the read-back fix on a ONE_CELL_PER_COLUMN immutable covered index; a full upsert is followed by a partial upsert that omits a covered column, and the test asserts the index read still returns the earlier value. Fails without the read-back fix (expected:<abcd> but was:<null>) and passes with it.GlobalIndexCheckerIT#testPartialRowUpdateForImmutableUncovered— regression lock for the read-back fix on a ONE_CELL_PER_COLUMN immutable uncovered index; a full upsert is followed by a partial upsert that omits the indexed column, and the test asserts (via the index path) that the row still resolves under its original key and thatIS NULLreturns nothing under both maintenance modes. The index-COUNT invariant is asserted only when server-side maintenance is enabled (read from the effective flag), because the scan path self-heals unverified index rows at read time but the aggregate path agrees only when the server rebuilds a verified entry from the read-back row — mirroringBaseImmutableIndexIT.UncoveredGlobalImmutableNonTxIndexITandUncoveredGlobalImmutableNonTxIndex2IT— pass with the broadened serialize filter (uncovered global immutable indexes are now maintained under the flag).GlobalImmutableNonTxIndexIT(covered global) stays green, confirming no regression to the covered path.ServerSideImmutableIndexITandClientSideImmutableIndexIT— pass. These extendBaseImmutableIndexIT, which gained partial-upsert/delete coverage that runs under both drivers (flag on and off) and is parameterized overcolumnEncoded, so each case executes against bothONE_CELL_PER_COLUMNandSINGLE_CELL_ARRAY_WITH_OFFSETS: partial upsert over a covered index, over an uncovered index (COUNT path, which does not self-heal), across multiple global indexes, across multiple column families, with an indexWHEREclause, a delete over an immutableROW_TIMESTAMPtable with an index (the branch theDeleteCompilersignature change was added for), and a mixed matching/mismatched storage-scheme index case.PhoenixMetricsIT,PhoenixLoggingMetricsIT, andPhoenixTableLevelMetricsIT#testMetricsWithIndexUsage— pass; the flag is pinned off at the driver level for the assertions that account for client-side index mutations.IndexToolIT#testIndexToDataVerification*— when the flag is on, the immutable case expects the extra directly-written index rows to be counted asEXTRA_UNVERIFIEDrather thanEXTRA_VERIFIED, because server-side maintenance applies the same two-phase verified write protocol as mutable indexes; the assertion reads the flag so it stays correct under either default. Passes across all parameter combinations.PhoenixClientRpcIT#testIndexQos— pins the flag off so it keeps exercising the client-maintained path it was written to verify (that immutable-index writes do not use the region-server index RPC queue).Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)