fix(core): keep materialized views current after concurrent invalidation - #7330
Conversation
When an apply-time INVALIDATE deferred because a concurrent refresh held the view lock, only the incremental-refresh holders (refreshIncremental and refreshDependentViewsIncremental) finalized the pending marker on completion. The range-refresh and interval-update holders did not, leaving the view stuck as valid-in-memory with stale rows until a restart, REFRESH FULL, or role-switch rebuilt the store. Extend finalizeDeferredInvalidation to the finally blocks of rangeRefresh and updateRefreshIntervals. Both holders now call the helper as the first statement of their finally block, mirroring the incremental pattern: while the lock is still held, clear the pending marker and re-enqueue a fresh INVALIDATE so the guard in invalidateView no longer swallows the re-delivered task. Fix the write-ordering in markAsPendingInvalidation: publish the reason before the flag so a reader on a weak-memory arch cannot observe pending==true with a null reason. Wire the @testonly seam into refreshIncremental0 (after the isLocked assert), rangeRefresh (after tryLock), and updateRefreshIntervals (after tryLock) so all three holders can be driven deterministically in tests. Add testRangeRefreshHoldingLockFinalizesDeferredInvalidation and testUpdateRefreshIntervalsHoldingLockFinalizesDeferredInvalidation to MatViewPendingInvalidationTrapTest; each was witnessed RED without its respective finally call and GREEN with it. Also refine two comments: the invalidateView guard comment now correctly notes that a fullRefresh-holder deferral only self-resolves for an invalidation set before the full refresh's reader snapshot (fullRefresh does not call finalizeDeferredInvalidation); and the finalizeDeferredInvalidation guard comment now explains that the getLastRefreshBaseTxn()==-1 skip mirrors invalidateView's own force||getLastRefreshBaseTxn()!=-1 gate rather than claiming the view has no stale rows.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the βοΈ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
π₯ Pre-merge checks | β 4 | β 1β Failed checks (1 warning)
β Passed checks (4 passed)
β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The first cut of finalizeDeferredInvalidation early-returned when getLastRefreshBaseTxn() == -1, leaving a deferred invalidation's pending marker set forever. A range-only-populated view (a MANUAL view backfilled with REFRESH ... RANGE, whose rangeRefreshSuccess never advances lastRefreshBaseTxn) that received a base-cascade INVALIDATE while one of its range refreshes held the lock was left frozen: every refresh path skips a pending view, the re-enqueued INVALIDATE is swallowed by the top guard, and getViewStatus reads the persisted invalid flag (never the in-memory marker), so the view reported valid while serving stale rows with no auto-recovery. finalize now clears the marker and re-enqueues regardless of lastRefreshBaseTxn. The re-enqueued view task re-delivers force=true, which mints regardless of lastRefreshBaseTxn, so such a view ends cleanly invalid -- visible and recoverable via REFRESH ... FULL -- instead of frozen. This is deliberately stricter than the no-contention path (which leaves a never-incrementally-refreshed view valid); the comment that claimed finalize matched no-contention is corrected to state the real behavior and why matching it is not reachable once the deferral has queued a force=true retry. fullRefresh was the lone lock-holding path that never finalized: an INVALIDATE deferring during its pump (the lock is held for the whole insert-as-select, far wider than a tight race) left the view frozen stale-but-valid. Add finalizeDeferredInvalidation to its finally, plus a test-only seam after resetInvalidState to model the deferral. Other changes: - Add MatViewState.clearPendingInvalidation(), separating clearing the marker from the invalid flag; finalize uses it instead of the broader markAsValid(), which could resurrect an invalid view. - Correct the invalidateView residuals comment: fold full refresh into the finalizing holders and state that the finalize-to-unlock window, when hit, is a permanent silent freeze, not self-healing. - Refresh the stale MatViewState class javadoc to describe the new transient pendingInvalidationReason. Tests pin each path: the range-only (== -1) frozen branch, the null-reason full-refresh marker (asserting no spurious invalidation), and the fullRefresh finalize path. Each fails when its fix is reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fd3dGESij17765qqbDTSWF
Round-2/3 review of PR #7330 surfaced an un-finalized sixth lock-holder and several robustness items. M1: invalidateView is itself a lock-holder -- its force=false decline on a never-incrementally-refreshed view holds the lock without minting, so a concurrent INVALIDATE deferring during that hold stranded pending and froze the view (silently stale while it reports valid). Its finally now finalizes too. A new test pins it and fails without the fix. The auth-rollback defer is handled: it is read-only when finalize runs (no-op), or re-delivers the invalidation it already queued if the role flipped back to writable. M2 (accepted, documented): the fullRefresh finalize can flip a view that the full refresh just rebuilt correctly to invalid under a lock race. Kept -- it is visible and recoverable, and dropping it would re-expose the silent frozen-pending freeze across the whole pump. finalize is reason-blind; txn-gating is left as a follow-up. Minor hardening: - finalizeAndUnlock wraps finalize so an OutOfMemoryError cannot skip unlock and leak the latch/cursorFactory; finalize stays under the latch to keep the finalize->unlock race narrow. - assert non-null reason at the three defer sites (null is reserved for the full-refresh reschedule marker; document the torn-write route into the residual freeze). - test seam made volatile, renamed to the *ForTesting idiom, read via a local before the null-check. - new tests: invalidateView finalize, single-view incremental finalize, multi-dependent fan-out; trivia (license year, dangling javadoc pointer, text blocks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fd3dGESij17765qqbDTSWF
Pull in #7275 (retry mat view refresh on transient errors instead of invalidating, plus the refresh block list). It rewrites the same MatViewRefreshJob refresh/invalidate paths this branch touches. Conflict: MatViewState.markAsValid() reset both this branch's pendingInvalidationReason and #7275's refreshRetryAfterMicros and refreshRetryCount. Resolved by resetting all three. MatViewRefreshJob auto-merged textually; audited every overlapping method. #7275's tryScheduleRetry and block-list guards live in the catch blocks and the pre-tryLock gates; this branch's finalizeAndUnlock lives in the finally blocks. They compose: a scheduled retry still runs the finally, where finalizeDeferredInvalidation is a no-op unless a concurrent INVALIDATE actually deferred during the lock hold. Verified: core main and tests compile; the trap suite (10), busy-retry (19), block-list (5) and cancel (2) suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fd3dGESij17765qqbDTSWF
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
invalidateView's finally finalized its own auth-rollback deferral: finalizeDeferredInvalidation cleared the pending marker the catch had just set and re-enqueued the INVALIDATE. When the WAL writer refusal is sticky (getWalWriter keeps throwing an authorization error until a re-promote), clearing the marker lets the retry back past the top guard on every drain pass, so the refresh worker busy-spins instead of deferring once. finalize's isReadOnlyMode skip did not cover this: getWalWriter can refuse while the node still reports writable (the TOCTOU the auth-rollback branch exists for), so isReadOnlyMode is false and finalize proceeds to clear and re-enqueue. Track the self-deferral with a selfDeferred flag and skip finalize on that path -- just unlock. The marker stays set, the top guard stops the next pass, and a re-promote rebuilds the view from disk. The decline path still finalizes a deferral a concurrent invalidate left while this invalidate held the lock. Caught by the enterprise MatViewSwitchInvariantsTest method invalidateInnerLoopDefersOnReadOnlyRefusalAndDoesNotSpin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fd3dGESij17765qqbDTSWF
β¦invalidation_finalize
Code Review β PR #7330:
|
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
β¦latile markAsPendingInvalidation() and clearPendingInvalidation() wrote the (pendingInvalidation, pendingInvalidationReason) pair as two independent volatiles in opposite orders. An off-latch deferral racing a holder's under-latch finalize-clear could tear the pair to (pending=true, reason=null). That state is terminal: every refresh path and the invalidateView top guard skip a pending view, while finalize reads the null reason as a full-refresh marker and returns without clearing -- yet no full refresh is queued, so the view sits valid+stale with no self-heal (only REFRESH ... FULL or a restart/role-switch recovers it). Collapse both fields into a single volatile Object marker: null -> not pending sentinel -> pending, no reason (full-refresh reschedule) String -> pending, with that reason Every transition is now one atomic write/read, so the torn intermediate cannot exist. The sentinel (reasonless) marker is only ever written alongside a queued full refresh, so finalize leaving it is safe. Closes M1 (the torn composite). The narrower M2 lost-update/swallow window is unchanged and stays documented in invalidateView. Adds two regression tests covering the marker state machine and a concurrent defer-vs-clear guard.
bluestreak01
left a comment
There was a problem hiding this comment.
@jovfer β Level 3 review (full mission-critical pass). I ran all review dimensions (correctness, concurrency, performance, resources, tests, quality, metadata) against the source, with per-finding source verification.
What I verified (the core fix is sound)
- Re-enqueue re-delivers
force=true.finalizeDeferredInvalidation->stateStore.enqueueInvalidate(viewToken, reason)enqueues a view-scoped task;invalidate()(MatViewRefreshJob.java:1520) dispatches it asinvalidateView(token, reason, /*force*/ true). So a range-only / never-refreshed view mintsinvalidon re-delivery. - All 6 in-diff lock-holders route through
finalizeAndUnlock(830/1583/1764/1935/2084/2307), andfinalizecorrectly no-ops on the common no-deferral path (single volatile read!isPendingInvalidation()). - The single-volatile marker eliminates the torn
(pending=true, reason=null)write. Setter writes one reference;clearPendingInvalidation/markAsValidwritenull;getPendingInvalidationReason()is one read. - The no-reason sentinel never reaches an INVALIDATE path.
fullRefresh's tryLock-fail marks pending with no reason and enqueues a FULL_REFRESH (MatViewRefreshJob.java:833-834), not an INVALIDATE; every INVALIDATE producer (CairoEngine.java:2596-2641,ApplyWal2TableJob.java:891) passes a non-null reason, so the threeassert invalidationReason != nullguards can't fire. refreshSuccessdoes not clear the marker (MatViewState.java:739), so a deferral that lands mid-incremental-refresh survives intofinalizeβ the tests' assertedinvalidend-state is real, not accidental.- No new deadlock / leak / infinite re-enqueue.
finalizeAndUnlock's inner-try keepsunlock()running past an OOM in finalize; double-enqueued INVALIDATEs are idempotent (second is swallowed by!isInvalid());selfDeferredskips finalize on the auth-rollback deferral to avoid a busy-spin.
Post-fix silent freezes are a strict subset of pre-fix. No correctness, concurrency, or resource defect is introduced by the diff.
Critical
None.
Moderate
M1 β A 7th lock-holder (REFRESH ... STATS) is not finalized; the PR's "every lock-holder" claim is inaccurate (out-of-diff, pre-existing)
invalidateView's new comment (MatViewRefreshJob.java:1551-1553) asserts "Every lock-holder finalizes a deferral on completion" and the PR body says "Applied to every lock-holder." Both are factually wrong. I grepped every MatViewState.tryLock() in the repo: there are 7 holders β the 6 in MatViewRefreshJob the PR routes through finalizeAndUnlock, plus SqlCompilerImpl.java:3586:
if (viewState.tryLock()) {
try {
viewState.refreshStats(); // MatViewState.java:727 - zeroes 3 EMA longs only; never touches the marker
} finally {
viewState.unlock(); // plain unlock, no finalizeDeferredInvalidation
}
}refreshStats() never clears a pending marker. Trigger: a user runs REFRESH MATERIALIZED VIEW v STATS (synchronous, on the SQL thread, wins the latch); concurrently a base op's INVALIDATE is processed by a refresh-pool worker -> invalidateView -> tryLock() fails -> markAsPendingInvalidation(reason) + enqueueInvalidate + return. The STATS unlock() runs without finalizing -> the re-enqueued task is swallowed at the !isPendingInvalidation() top guard -> the same terminal silent freeze the PR is closing everywhere else.
Pre-existing (the PR doesn't touch this path) and the window is microscopic (~3 field writes), but the PR newly asserts an invariant it does not fully establish. Per the bundle-related-fixes convention, prefer routing STATS through the same finalize; at minimum correct the in-code comment and PR body to list STATS as a fourth residual.
M2 β PR body omits the selfDeferred busy-spin fix; the squashed commit message will be incomplete (metadata)
The body's Fix section says only that invalidateView's finally "now finalizes too." The shipped behavior is conditional: commit 3cc2e1285f added selfDeferred (MatViewRefreshJob.java:1600,1657-1668) which skips finalize on invalidateView's own auth-rollback deferral, because with a sticky read-only writer refusal, re-clearing the marker would let the retry past the top guard every drain pass β a busy-spin. The body never mentions selfDeferred, the sticky refusal, or the spin. PRs here are squash-merged, so the body is the message that lands on master; it should describe the whole change. Add a Fix paragraph and a test-plan bullet.
Note: the enterprise module (
questdb-ent) is not present in my workspace, so I could not verify the enterprise regression tests the branch presumably relies on for the read-onlyselfDeferred/no-spin and demote/promote paths (MatViewSwitchInvariantsTest,MatViewInvalidateRepromoteLosslessTest). Those coverage claims are unverifiable here and should be confirmed against the enterprise CI run.
Minor
-
m1 (test-infra latch-leak asymmetry). In
rangeRefresh(1774-1777),updateRefreshIntervals(2313-2316), andinvalidateView(1595-1598), theseam.run()executes aftertryLock()but outside thetrywhosefinallyunlocks. A throwing seam leaks the latch (wedges the view + leaks the parkedcursorFactoryat teardown).fullRefresh(867) andrefreshIncremental0(2175) place the seam inside the unlock-protected region. Unreachable in production (onHoldingLockForTestingisnull, not copied bycloneInstance), but it's a real structural inconsistency β move each of the three seams to the first statement inside the unlockingtry. -
m2 (member ordering, in-diff). The new
private static final Object PENDING_INVALIDATION_NO_REASON(MatViewState.java:88) is inserted afterREFRESH_RETRY_AFTER_UPDATER; alphabeticallyP<R, so it should precede it within the private-static-final group. (TheisPendingInvalidation()mis-ordering at line 562 is pre-existing on master β not this PR.) -
m3 (naming, in-diff).
finalizeAndUnlock(..., boolean incrementRefreshSeq)β the param is a bare verb identical to the method it gates; call sites read as baretrue/false. PrefershouldIncrementRefreshSeq.boolean selfDeferred(1600) lacks theis/hasprefix βisSelfDeferredis more consistent (minor). -
m4 (test fidelity, in-diff). Every seam lambda calls only
state.markAsPendingInvalidation(...)β never the pairedstateStore.enqueueInvalidate(...)a real losinginvalidateViewissues. So no single test reproduces the actual end-to-end bug (concurrent INVALIDATE loses the lock -> re-delivered task swallowed at 1561 -> finalize recovers) in one flow; the 10 tests prove finalize given a marker re-enqueues and mints.testLockContendedInvalidationDefersWithReasondoes exercise the real defer site, and two flagship tests were confirmed to fail pre-fix, so these are genuine regression tests β but the comments' "exactly as a losing concurrent invalidateView would" overstate the seam. Consider one test that enqueues a real second INVALIDATE and drains, or soften the comments. -
m5 (coverage gap, in-diff).
finalizeDeferredInvalidation's|| viewState.isInvalid()early-return is OSS-reachable (a refresh that fails ->markAsInvalidwith a deferral also pending) but untested. Worth a test that fails the refresh with a pending marker armed and asserts finalize does not re-enqueue and the view carries the fail reason.
Tradeoffs / regressions to weigh (documented, accepted)
These are real behavior changes the PR documents; they are strictly narrower than the pre-fix always-lost freeze, but reviewers/operators should be aware:
-
valid->invalidflip under a lock race the holder wins.finalizeis reason-blind, not txn-aware, so a full refresh (or aforce=falserange/interval-update on a never-incrementally-refreshed view) that already rebuilt the view correctly can still flip itinvalidand cascade to dependents when a concurrent deferral lands mid-hold β a spurious-looking flip for the "UPDATE then REFRESH FULL" flow. Verified conservatively safe (invalid is visible;REFRESH ... FULLrecovers) and better than the alternative (an eventually-stale frozen-pending view that also blocks future refreshes via the pending guard). Reachable in normal operation for MANUAL/range-only views because timer-drivenUPDATE_REFRESH_INTERVALSis a background lock-holder, not only under manualREFRESH ... FULL. -
finalize->unlock and torn-write windows. A deferral landing between a holder's finalize-clear and its unlock is still lost (terminal silent freeze). Verified the worst case is exactly the documented freeze β no lost/double-freed factory, no half-mint, no spin. For an UPDATE/DROP-PARTITION-caused loss, recovery is
REFRESH ... FULL-only (a later incremental advanceslastRefreshBaseTxnpast the non-TRUNCATE txn without re-detecting it).
Downgraded (draft findings dismissed after source verification)
- "finalize under the latch can deadlock (
isReadOnlyMode/enqueueInvalidate)." False βisReadOnlyMode()is a lock-free flag read; the task queue is unbounded/non-blocking; neither re-acquires the view latch; theWalWritertry-with-resources closes before the outerfinally. - "the three
assert reason != nullsites can fire." False β verified every INVALIDATE producer passes a non-null reason; the only null marker enqueues FULL_REFRESH, never INVALIDATE. - "finalize double-enqueues cause an infinite INVALIDATE loop." False β the second re-delivery hits
!isInvalid()and is swallowed; monotonic towardinvalid. - "perf: redundant volatile reads / per-row cost / capturing lambda." False β finalize runs once per task (never per row); no-deferral path is one volatile read;
seamis a plain field read, not a capturing lambda; no autoboxing, nojava.util.*. isPendingInvalidation()alphabetical mis-ordering. Pre-existing on master; the PR modifies the body only, doesn't move it β not a finding for this PR.assertMemoryLeak(...) { assertQuery(...).noLeakCheck() }anti-pattern. False β each lambda holds many statements (DDL, inserts, job lifecycle, WAL drains, multiple assertions) that must share one leak scope; the.noLeakCheck()is correct there. Tests use.returns(...)(not.returnsOnce) throughout.
Summary
Verdict: request changes β not blocking on the correctness of the diff itself.
The core concurrency fix is sound: I traced all 6 in-diff lock-holders, the selfDeferred auth-rollback path, the single-volatile marker state machine, every INVALIDATE producer, and the force=true re-delivery, and post-fix freezes are a strict subset of pre-fix. No new correctness bug, deadlock, leak, or measurable perf cost. The two flagship tests are genuine pre-fix-failing regressions.
The one item worth acting on before merge is M1: the in-code comment and PR body claim the fix covers "every lock-holder," but REFRESH ... STATS (SqlCompilerImpl.java:3586) is a 7th holder that still does a plain unlock() and can strand the identical deferral β either cover it (preferred, bundle here) or correct the claim. M2 (body omits the selfDeferred spin fix) should be fixed so the squashed master commit is accurate. Everything else is Minor.
- Findings: 5 verified as actionable (2 Moderate + 3 substantive Minor) + 2 trivial Minor; 6 draft findings dropped as false positives.
- In-diff vs out-of-diff: 6 in-diff (M2 + m1-m5), 1 out-of-diff (M1).
- Caveat: the enterprise
questdb-entmodule is absent from my workspace, so the read-only/demote-promote/no-spin enterprise test coverage the branch relies on could not be verified here.
Review follow-up (PR #7330, @jovfer): The REFRESH MATERIALIZED VIEW ... STATS reset in SqlCompilerImpl takes the same per-view latch as the refresh paths, synchronously on the SQL thread, but unlocked without finalizing -- a concurrent INVALIDATE deferring during that hold stayed swallowed by invalidateView's pending guard, the same terminal freeze the branch closes elsewhere. The stats path now routes its unlock through MatViewRefreshJob.finalizeAndUnlock, exposed as a public static helper shared with the job's own six lock-holders (a private instance wrapper keeps the in-class call sites unchanged). The static also runs tryCloseIfDropped/tryCloseIfClosed after the unlock, so a teardown that raced the stats hold now frees the parked cursor factory instead of leaking it. Also addressed from the review: - Move the rangeRefresh, invalidateView and updateRefreshIntervals test seams inside the unlock-protected try, matching fullRefresh and refreshIncremental0: a throwing seam can no longer wedge the latch. - Rename the finalizeAndUnlock parameter to shouldIncrementRefreshSeq and the invalidateView local to isSelfDeferred, per the boolean naming convention. - Move PENDING_INVALIDATION_NO_REASON to its alphabetical slot ahead of REFRESH_RETRY_AFTER_UPDATER. - Update the invalidateView holder-list comment to name the stats reset and note MatViewState's teardown-only tryLock holders. New tests in MatViewPendingInvalidationTrapTest (15 total now): - testStatsResetFinalizesDeferredInvalidation arms a swallowed deferral and runs REFRESH ... STATS; confirmed to fail with a plain unlock. - testRefreshHoldingLockFinalizesDeferredInvalidationWithQueuedTask drives the complete defer-site pair (marker plus the re-enqueued task the guard swallows) through a real refresh finalize; confirmed to fail with finalizeDeferredInvalidation no-op'd. - testFailedRefreshLeavesDeferredInvalidationUntouched pins finalize's isInvalid early-return: a refresh that fails with a deferral pending keeps the failure reason and leaves the marker untouched. - Seam comments now say they model the marker half of a losing invalidateView's defer instead of claiming exact equivalence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦invalidation_finalize
Review follow-up (PR #7330, jovfer's June 26 pass, item m5): the 4-line "read the volatile seam field, run if armed" idiom was copy-pasted at five lock-holder sites. runHoldingLockSeamForTesting() now carries the idiom once, with the latch-safety contract (callers invoke it inside the unlocking try) documented on the helper instead of repeated at each call site. No behavior change; the trap test class passes unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up (level-3 pass, 2026-07-06). Comment-accuracy and style fixes only; no behavior change. - invalidateView's residuals comment now scopes the lost-update window correctly: it opens at finalize's marker read (not its clear), and it strands a first-and-only deferral through finalize's early-return path too, not only a second deferral after a clear. The old "unlike it, not self-healing" contrast read as if the pre-fix loss self-healed; the new wording states that finalize now recovers the pre-fix class while nothing recovers a deferral lost in this window. - finalizeAndUnlock's javadoc names the one deliberate exception to "every lock-holder routes through here": invalidateView's auth-rollback self-deferral unlocks inline to avoid the busy spin against a sticky read-only writer refusal. - finalizeAndUnlock's OOM comment discloses the clear-then-enqueue non-atomicity: an OOM between the two drops the deferral outright (the view reads healthy while stale), and enqueue-before-clear would be no better because a second worker could swallow the task against the still-set marker. - rangeRefresh's finally cross-references the fullRefresh tradeoff: a deferral landing mid-hold flips the view invalid even when the range refresh just recomputed the affected rows. - getPendingInvalidationReason uses an instanceof pattern variable. - The trap test class lists its two marker state-machine tests in alphabetical position. MatViewPendingInvalidationTrapTest passes 15/15 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a2bdb42 to
a746808
Compare
Round-3 review follow-up (PR #7330). M1: the no-arg markAsPendingInvalidation() now CASes the marker from null to the reschedule sentinel (AtomicReferenceFieldUpdater), so a full refresh that loses the lock race can no longer demote a concurrent reason-bearing deferral to a marker that only the re-queued full refresh clears. invalidateView's residuals comment stops claiming the sentinel "is only ever written alongside a queued full refresh that recovers it": the re-queued full refresh's short-circuit exits (refresh block list, write suspension, missing base table) return without finalizing, so a stranded sentinel is now documented as a third residual -- the same freeze the pre-fix boolean produced on those paths, cleared by a restart (the marker is in-memory) or a successful REFRESH ... FULL. M2: the defer-vs-clear hammer no longer claims "it can only fail if the torn composite returns" -- its resting-state check catches a revert whose write orders leave the torn pair at rest, and the comment now explains why transient torn states are not black-box observable (the two-getter API yields the same (true, null) observation on correct code when a clear lands between a reader's two reads). The hammer shrinks to 50 rounds. The new testConcurrentSentinelMarkNeverDemotesReasonDeferral pins the keep-strongest CAS with a single-read reader; it and the extended state-machine test both fail against a plain last-write-wins sentinel write (verified by temporarily reverting the CAS). Minors from the review: - finalizeDeferredInvalidation folds its guard into one marker read. - finalizeAndUnlock's javadoc documents shouldIncrementRefreshSeq, and its window comment names the self-fed variant (finalize's under-latch enqueue feeds sibling pollers) plus the enqueue-after-unlock follow-up shape. - unlockAndTryClose dedupes the unlock tail shared by finalizeAndUnlock and the isSelfDeferred branch; the deferral-reason assert message moves to a constant. - New testDroppedViewLeavesDeferredInvalidationUntouched pins finalize's isDropped early-return and the tryCloseIfDropped leak path for a view dropped mid-hold. - The trap test class javadoc lists both OSS-uncovered branches (the read-only early-return and the isSelfDeferred skip) and names the enterprise tests that cover them. - SqlCompilerImpl's stats-reset comment shrinks to a cross-reference. MatViewPendingInvalidationTrapTest passes 17/17; the mat view suites pass 379 tests (1 pre-existing timestamp-type Assume skip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new tests drive fullRefresh's real tryLock-fail branch -- the only production caller of the no-arg sentinel mark -- end-to-end: a drainer thread spins on the republished FULL_REFRESH task while the test thread holds the latch. testFullRefreshLosingLockArmsSentinelAndRecovers pins the arm/recover lifecycle (sentinel armed with a null reason, the re-queued full refresh clears it, the view ends valid). testFullRefreshLosingLockCannotDemoteReasonDeferral pins the keep-strongest CAS in integration form: the live losing branch cannot demote a reason-bearing deferral. It fails within half a second against a plain last-write-wins sentinel write (verified by a temporary revert). The invalidateView guard comment now enumerates the residuals as a list and adds three verified variants: timer-driven refreshes re-running the finalize->unlock window once finalize clears the marker, a sentinel CASed into the just-cleared marker swallowing finalize's own re-enqueue, and the start-of-fullRefresh window where resetInvalidState wipes a deferral whose queued task a sibling already swallowed. The rewrite drops the tear/CAS mechanics that duplicated the MatViewState field comment. finalizeDeferredInvalidation's header now names the String overload the defer sites actually call, and both "tracked follow-up" claims soften to "a possible follow-up" (no issue is filed yet). Smaller test changes: the marker state machine covers the (String) null routing into the sentinel CAS; the incremental-holder test asserts the refresh seq bumps through finalizeAndUnlock; the demotion hammer's reader spins with Thread.onSpinWait(); the contended-defer test asserts against UpdateOperation.MAT_VIEW_INVALIDATION_REASON instead of a literal. unlockAndTryClose moves after finalizeDeferredInvalidation to restore alphabetical order. All eight mat view suites pass: 381 tests, one pre-existing Assume-based timestamp-type skip in MatViewTest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment claimed the counter tracks every FULL enqueue reaching the engine-installed store across all callers. It does not: only InstrumentedStateStore's 2-arg enqueueFullRefresh(TableToken, Object) override increments it. ForwardingMatViewStateStore's 1-arg overload forwards straight to the delegate without routing through the 2-arg method, and impl-internal redeliveries push onto the store's private task queue directly, bypassing the wrapper entirely. Reword the comment to state that narrower, accurate scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦invalidation_finalize
β¦invalidation_finalize
MatViewTimerJob.processRefreshRetries keys the retry-heap gate on hasPendingInvalidationReason(). Nothing drove that path with a pending marker parked, so reverting the gate to isPendingInvalidation() left the whole tree green: the retry heap is the only path that wakes up immediate views, which have no timer of their own, so a stuck owner-only marker would silently freeze a busy-backoff view until the next base commit. Add testRetryHeapRedrivesRefreshDespiteOwnerOnlyMarker, the retry-heap analog of testTimerSchedulesRefreshDespiteOwnerOnlyMarker. It arms a backoff deadline via scheduleRefreshRetry plus a matching RETRY timer entry, parks an owner-only marker, and asserts the due retry still re-drives the incremental refresh and clears the backoff deadline. Add engineIncrementalRefreshEnqueues, a counter on InstrumentedStateStore that tracks enqueueIncrementalRefresh calls reaching the engine-installed store wrapper, following the same pattern as the existing engineFullRefreshEnqueues counter.
finalizeAndUnlock0's invalidation-wake catch arms the FULL retry flag via requestPendingFullRefreshReenqueue for a co-pending owner, in addition to arming the invalidation retry flag, before re-throwing. No existing test reached those lines: the only both-facets finalize test throws from the FULL enqueue rather than the INVALIDATE one, so the FULL arm inside the INVALIDATE catch was untested. Add testFinalizeBothFacetsInvalidationWakeFailureArmsBothRetryFlags, which finalizes through a store whose INVALIDATE wake throws first while a FULL owner is also pending. It then drives the retry scan directly on MatViewStateStoreImpl and inspects the drained queue: one scan must redeliver both the INVALIDATE and the FULL_REFRESH task with its owner. Without the arm, only the INVALIDATE facet comes back, which the added assertion isolates.
MatViewStateStoreImpl's enqueueFullRefresh owner overload, enqueueInvalidate0, reenqueueRefreshTask, and the per-state catch inside reenqueueFailedPendingTasks each catch a queue-append failure and arm a retry flag so a later scan redelivers the facet -- but the private task queue never actually throws, so no test could reach any of the four catches. Add a @testonly Runnable seam that MatViewStateStoreImpl fires immediately before both private queue-append sites: the shared enqueueMatViewTask tail (which the owner overload and enqueueInvalidate0 funnel through) and reenqueueRefreshTask's direct append, which bypasses enqueueMatViewTask entirely. A single-site seam would leave the put-back catch permanently unreachable, so the seam fires at both. Four new tests drive each catch through a genuine queue-append failure and red-verify it: the owner-overload catch, the invalidate catch, both arms of the put-back catch, and the per-state scan catch. The scan-catch test arms both facets and inspects the raw queue directly, rather than through a job drain -- a job drain's finalize-unlock handoff independently re-wakes whatever the marker still holds, which would mask the very recovery path the test needs to isolate. Rewrite testFinalizeBothFacetsInvalidationWakeFailureArmsBothRetryFlags to call the new resolveStateStoreImpl() helper instead of its inline unwrap cast, and correct InstrumentedStateStore's docstring now that a genuine append failure can be injected through the real store.
Pin four previously uncovered branches in MatViewRefreshJob with two new one-shot getWalWriter failure hooks (walSuspendedRefusalToken, walGenericFailureToken) added to the trap test's engine factory. - fullRefresh's outer catch defers a suspension landing between the isViewWriteSuspended gate and the writer acquire: it retains the owner instead of failing, and the finalize wakes it exactly once so the redelivered attempt (finding the one-shot refusal consumed) rebuilds and leaves the view valid. - invalidateView's mint-loop catch retains the pending marker on the same suspend-window race instead of letting the CairoException escape run(); the finally's finalize wakes it and the redelivery mints the invalid state with the retained reason. - fullRefresh's generic terminal else-branch (non-suspend, non-auth, non-rename writer failure) fails the view state and clears the pending-full-refresh owner, so a stranded sentinel cannot wake into the same failure forever. - A stale full-refresh handoff task, whose owner terminal cleanup already consumed before delivery, is a no-op: the early !isPendingFullRefreshOwner return skips the lock, truncate, and rebuild entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
REFRESH ... STATS takes the per-view latch like any refresh, so its unlock must finalize a deferred invalidation through MatViewRefreshJob#finalizeAndUnlock. That finalize releases the latch before attempting any wake enqueue, so by the time a wake can fail, the stats reset has already durably succeeded. Previously a wake enqueue failure propagated out of the statement anyway, turning an already-successful stats reset into a reported failure. Wrap the finalize call in a try/catch that logs and swallows, mirroring the identical contract in alterTableResume for ALTER TABLE RESUME WAL (SqlCompilerImpl.java:1661-1672): the finalize's own catch in MatViewRefreshJob#finalizeAndUnlock0 arms the invalidation retry flag on the engine's canonical store before rethrowing, so recovery rides those retry flags on the next refresh-job tick. Adds a trap test to MatViewPendingInvalidationTrapTest that arms an injected wake-enqueue failure, confirms the STATS statement still succeeds and releases the latch, then drains the queue to confirm the retry flag redelivers the invalidation. A new failure-surface entry for this contract lands in the PR body in a later task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four independent, behavior-preserving refactors to MatViewPendingInvalidationTrapTest, collapsing duplication that accumulated across review rounds: - Add assertPriceViewValid() and replace all 22 exact-match sites of the 3-column "valid" baseline query block with a single call. - Add assertHolderFinalizesDeferredInvalidation(reason, trigger) and collapse the four holding-lock tests that only differ by trigger and reason (FULL, RANGE, single-view INCREMENTAL, UPDATE REFRESH INTERVALS) into one-line calls. - Replace the anonymous counting ForwardingMatViewStateStore in testStickyWriterRefusalStillWakesFullRefreshFacet with the existing CountingStateStore, verified to have identical delegate/bound semantics. - Replace three hand-written base_price CREATE TABLE blocks with the existing createBasePriceTable() helper. Two deliberate non-changes: testRefreshHoldingLockFinalizesDeferredInvalidation stays standalone (insert-driven, reordered drains, extra refreshSeq oracle -- it deviates from the four-way template). The splittingStore in testFinalizePartialFullWakeFailureRedrives stays anonymous (its FULL facet throws unconditionally, a combination CountingStateStore doesn't support). Full trap suite: 73/73 tests pass, same count as before this change.
Four mechanical, behavior-preserving changes flagged in r20 review:
- Reorder MatViewState's two markAsPendingInvalidationAndGetMarker
overloads to sit before markAsPendingInvalidationForTesting, matching
alphabetical member order ("AndGetMarker" sorts before "ForTesting").
- Rename the local didRefresh to refreshed in
MatViewRefreshJob.fullRefresh, matching the file's established name
for the insertAsSelect result and the refresh accumulators.
- Rename the AtomicBoolean latchRescued to isLatchRescued in the three
trap tests that use it, applying the is-prefix boolean convention.
- Drop the shadowed delegate field in InstrumentedStateStore and route
all six internal delegate. calls through the inherited getDelegate();
the store never delegate-swaps, so the volatile read is equivalent.
Also replace the two remaining inline
(MatViewStateStoreImpl) ((ForwardingMatViewStateStore)
engine.getMatViewStateStore()).getDelegate() casts in
testEnqueueOomWhileRedrivingRestoresSignal and
testPendingTaskReenqueueScanIsSingleRunner with the existing
resolveStateStoreImpl() helper, so every site now unwraps the same
way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦invalidation_finalize Master renamed MatViewGraph to DependentViewGraph and switched the accessor to CairoEngine.getDependentViewGraph() as part of the live-view feature, which collided with the branch's test-only MatViewRefreshJob constructor that injects a MatViewStateStore. The resolution keeps both: the renamed graph accessor from master and the injected state store from the branch. Everything else auto-merged. Master's mat-view edits in this window are the rename plus an idempotent MatViewStateStoreImpl.close(); its ApplyWal2TableJob, WalPurgeJob and SqlCompilerImpl changes add live-view handling in the new io.questdb.cairo.lv package, which carries its own state store and refresh job and does not touch the mat-view invalidation state machine.
Review β level 3 (full PR net diff)Reviewed the complete branch net diff (base Verdict: approve. Both gates pass. FindingsNo Critical, no Minor. One Moderate coverage gap (non-blocking): M1 β the base-table token-mismatch arm of the FULL-refresh coverage check is untested. Suggested test: record FULL coverage under base token A, publish a pending invalidation carrying a distinct non-null base token B with What was verified (in-diff and out-of-diff)
Test suiteThe new Tradeoffs (author-acknowledged, confirmed accurate)Per-publication ownership-object allocation on refresh-task boundaries (not per row); conservative over-invalidation when base-token/txn provenance is unknown (may require No submodule pointer moved; the whole change is in-scope in |
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
β¦invalidation_finalize
bluestreak01
left a comment
There was a problem hiding this comment.
Review β PR #7330, level 3
fix(core): keep materialized views current after concurrent invalidation β branch sm_matview_pending_invalidation_finalize, head 3b7c7a30, base 271ec07c. Full net diff (12 files, +5209/β92, 74 new tests) reviewed as it will squash-merge.
Step 2.4 β submodule provenance: no submodule pointer moved in this diff (git diff 271ec07c...HEAD --submodule=short returns no Subproject commit lines; git status --porcelain is clean). The whole change is in-scope core.
Step 2 β PR metadata: title is Conventional Commits with the verb repeated and end-user impact; body is level-headed, states tradeoffs and regressions with equal weight, has a plain-bullet test plan and an explicit out-of-scope list; labels (Bug, SQL, Core, WAL, regression, Materialized View) match the scope. No Fixes #NNN needed β this fixes a master-only regression from #7261, not a filed issue. No findings.
Critical
None.
Moderate
M1 β the cross-epoch arm of the FULL coverage check is unpinned
Problem: Token-mismatch guard in the coverage check has no test.
Net impact: Silent removal would let a FULL clear a foreign-epoch invalidation.
Evidence: rg over all of core/src/test at 3b7c7a30 finds one non-base TableToken (MatViewPendingInvalidationTrapTest.java:1917), used only at :1937 in the merge test, which asserts provenance collapses to null; recordFullRefreshSuccess is package-private and no test lives in io.questdb.cairo.mv.
core/src/main/java/io/questdb/cairo/mv/MatViewState.java:450:
|| !coverage.baseTableToken.equals(pending.invalidationBaseTableToken)This is the only clause that stops a FULL rebuild recorded under base-table token A from consuming a pending invalidation stamped under a different token B. Base txn numbering is per-table and incomparable across a drop+recreate, so without it a low-txn marker from epoch B compares <= a high-txn coverage from epoch A and is cleared.
The three sibling arms are each pinned (coverage == null and invalidationBaseTableToken == null by testFullRefreshConsumesProvenanceFreeMarkerCoveredByItsSnapshot; invalidationBaseTxn > coverage.baseTableTxn by testFullRefreshKeepsInvalidationNewerThanFixedSnapshot). This one is not.
- Regression consequence:
invalidateViewreturns early atMatViewRefreshJob.java:1873, somaterialized_views.view_statusstaysvalidandinvalidation_reasonstays empty while the view's rows do not reflect the change. No error, no log line, no operator signal. - Reachability / population: operators running mat views (
cairo.mat.view.enableddefaults true) who do a base-table identity swap (RENAME TABLE base TO tmp; RENAME TABLE other TO base;orDROP TABLE base; CREATE TABLE base ... WAL;) while a provenance-bearing INVALIDATE from a baseUPDATE/TRUNCATEis in flight. This is not a scriptable DDL sequence: the swap's own cascade publishes a provenance-free forced marker whose merge collapses the token tonull(MatViewState.java:823-830), so the mismatch arm needs the swap to land inside the window between the UPDATE's marker publication and its consumption β a multi-worker interleaving. That narrowness is why this is Moderate, not Critical. - Change risk: new method, new field, no compile-time or lint safeguard (
pom.xml/core/pom.xml/.githubhave no-Werror, checkstyle, spotbugs, or errorprone), and the mutant compiles becausecoveragestays used on the next line. - Least-fragile test, no new seam required: after a successful
REFRESH MATERIALIZED VIEW price_1h FULL(coverage txnC,lastRefreshBaseTxn != -1), call the already-public 5-argMatViewStateStore.enqueueInvalidate(viewToken, "update operation", foreignBaseToken, 1, false)with the syntheticforeignBaseTokenat:1917, drain, and assertview_status = 'invalid'/invalidation_reason = 'update operation'. At head the mismatch arm declines and the mint gate's third disjunct mints; with the clause deleted (1 <= C) the view staysvalid.
M2 β the isInvalidViewRecovery disjunct in the mint gate is masked in every existing test
Problem: Mint-gate recovery disjunct never decides an assertion.
Net impact: Silent removal drops a cascade and reports a stale invalidation reason.
Evidence: MatViewRefreshJob.java:1886; the four conditions are never simultaneously satisfied β the closest test (testFullRefreshKeepsInvalidationNewerThanFixedSnapshotWhenRecoveringInvalidView:1099-1145) fires its seam while fullRefresh holds the latch, so invalidateView returns at the tryLock at :1857 and never reaches :1886; every other owner-parking test publishes through the 1-arg/2-arg enqueueInvalidate, which defaults isInvalidationForced = true and satisfies the first disjunct on its own.
if (isPendingInvalidationForced || isInvalidViewRecovery || viewState.getLastRefreshBaseTxn() != -1) {The entry gate's isInvalidViewRecovery term (:1829) is tested and is what prevents the silent-stale class. The mint-gate term is a separate branch reached when the invalidate wins the free latch: isForced == false (a base cascade) and getLastRefreshBaseTxn() == -1 (never successfully refreshed) and isInvalid() and an owner parked by a REFRESH ... FULL that lost the latch.
- Regression consequence: the invalidation is declined at
:1949instead of minted β the view stays invalid either way, so this is not a data-correctness gap, butmaterialized_views.invalidation_reasonkeeps the older reason andenqueueInvalidateDependentViews(...)at:1960-1961never fires, so chained views are not re-invalidated by that event. No later path re-derives the missed cascade (enqueueInvalidateDependentViewscall sites::681, :1961, :2341, :2765). - Least-fragile test:
createUnseededAutoPriceViewFixture()βenqueueInvalidate(viewToken, "seed invalidation")+ drain βstate.markAsPendingFullRefreshForTesting()βenqueueInvalidate(viewToken, "update operation", baseToken, 1, false)+ drain β assertinvalidation_reason = 'update operation'. Uses only existing@TestOnlypublic seams.
Minor
None.
Coverage map
Test gate: PASS. Two admitted gaps, both Moderate (M1, M2); zero admitted Critical gaps. Rendered above with their recorded searches and failure links. Remaining rows are COVERED, ACCEPTED, or EXEMPT and stay private.
Independent runtime evidence at the reviewed revision: all completed Azure jobs are green β Cairo A/B, Griffin, Other A/B, and the coverage/fuzz runs across linux-arm64, linux-x64-zfs, and linux-x86-graal (build 263460); GitHub build and gitleaks pass. The coverage bot reports 454/469 new lines covered (96.80%).
Scope of verification (level 3)
Callsite analysis walked outward from the diff; zero out-of-diff breakage found, and both admitted items are in-diff.
- Two-sided handoff. Exhaustive latch inventory: production
tryLock()sites areMatViewRefreshJob:565, 992, 1856, 2061, 2233, 2382, 2640,SqlCompilerImpl:3867, andMatViewState:489, 1161, 1171. The first eight all release throughfinalizeAndUnlock*; the threeMatViewStateones acquire only whenclosed/dropped, exactly the statesfinalizeAndUnlock0:470-474short-circuits on. The only productionMatViewState.unlock()isunlockAndTryClose(:540). The publishβfailed-tryLockβunlockβpost-read ordering is a correct volatile handoff for both facets. - Coverage soundness.
ApplyWal2TableJob:658stamps the batch-endwriter.getSeqTxn()(an upper bound, making coverage strictly harder to claim);findRefreshIntervals:813stampstoBaseTxnfrom the same numbering. Every provenance-free publisher (CairoEngine:4290/4299/4334/4345) collapses to a null token, which the coverage check rejects unconditionally, so a forced/hydration invalidation can never be swallowed. - Gate flips.
WalPurgeJob:541is neutral-or-more-conservative: identical for a reason-bearing marker, and for an owner-only marker it pins the retention floor where base released it. First-refresh protection viagetLastRefreshBaseTxn() == -1is intact.MatViewTimerJob:252/318and the fourMatViewRefreshJobrefresh gates are pinned by dedicated tests. - Suspension/resume.
ALTER ... RESUME WALβreenqueuePendingOnResumeredelivers both facets; base dropped the invalidation permanently for the same trigger. The residual operator hazard (lifting a config-list suspension without runningRESUME WAL) is covered by the documented procedure onCairoEngine.isWalApplySuspendedand is recoverable viaREFRESH ... FULL,REFRESH ... STATS, or restart. - Standards on changed lines. All new LOG chains terminate with
.I$(); no.put()in a chain; no throw-capable expression inside a chain. Members are ordered per the file's alphabetical-within-kind convention. Nojava.utilon a data path, no TODO/FIXME, no non-ASCII in log text. Test file: noassertSql(, noreturnsOnce(, no reflection, noThread.sleep, no debugging residue; both threaded tests route spawned-threadThrowablethrough anAtomicReferencewith a boundedjoin. - Tradeoffs confirmed accurate. Per-publication
PendingInvalidation/owner allocation is on refresh-task boundaries, not per row. TheisInvalidViewRecoverywidening does add a redundantsetInvalidStateWAL transaction and a cascade on an already-invalid view β bounded to one per base-invalidation event inside a latch-contention window, non-amplifying (each iteration consumes one INVALIDATE and adds one no-op FULL), and the child cascade short-circuits at the child's own!isInvalidgate. ThefinalizeAndUnlock0rethrow escaping afinallyis OOM-gated and does not halt the pool (mat.view.refresh.worker.haltOnErrordefaults false,PropServerConfiguration:1591;Worker:422-437logs and continues).
Summary
Verdict: approve with comments. Both gates pass. I would like M1 landed on this branch β it is the one unpinned guard whose silent removal produces stale data reported as valid, and the test costs one existing-API call. M2 is optional.
- Correctness gate: PASS β no admitted Critical finding.
- Test gate: PASS β no admitted Critical coverage gap; 2 Moderate gaps.
- Severity distribution: 0 Critical, 2 Moderate, 0 Minor. Admitted split: 2 in-diff, 0 out-of-diff-breakage.
- Submodule provenance: no pointer moved β entire change in scope.
- Regressions/tradeoffs carried by this change, all disclosed in the PR body and confirmed accurate: bounded redundant invalid-state WAL churn on an already-invalid view during a full-refresh latch window; conservative over-invalidation when base-token/txn provenance is unknown;
REFRESH ... STATSandALTER ... RESUME WALnow swallow a post-release wake-enqueue failure and rely on the store's retry flags. - Cross-repo note (not a finding, not in the verdict): the six
MatViewStateStoreadditions have nodefaultbodies, andMatViewState.isPendingInvalidation()kept its name while changing meaning (it now also returns true for an owner-only marker). Any enterprise implementer or caller needs the paired change; the PR body already schedules the pin bump.
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
[PR Coverage check]π pass : 454 / 469 (96.80%) file detail
|
Problem
On a primary, a materialized view could continue to report
validwhile serving stale rows. An apply-timeINVALIDATEcould publish while another refresh worker held the same view latch. The losing worker queued a retry, but the pending-marker guard then consumed that retry without minting durable invalid state or arranging another wake-up.The original freeze required at least two refresh workers and a base operation such as
TRUNCATE, a rows-affectedUPDATE, or a structural change. #7261 introduced that master-only regression; no release includes it.The same state machine had related paths with the same user-visible outcome, an unnecessary invalid result, or an unbounded worker loop:
Changes
Coordinate invalidation and full-refresh ownership
MatViewStatenow keeps one atomic pending marker with independent invalidation and full-refresh facets. The marker carries the invalidation reason, base table token, base transaction, force flag, and a distinct full-refresh owner. Facet publications merge keep-strongest: force flags OR, same-token transactions keep the maximum, and unknown or mismatched provenance collapses to conservative.An invalidator publishes that marker before it attempts the view latch. It either acquires the latch and handles the marker itself, or the completing latch holder observes the publication after unlock and queues one authoritative wake-up. Lock losers no longer publish their own duplicate retries, so one contention episode produces bounded queue traffic.
Every operational latch holder routes release through
finalizeAndUnlock, including incremental, range, interval, full, invalidation, and synchronousREFRESH ... STATSpaths. The helper always releases the latch and runs close/drop cleanup before it publishes follow-up work;fullRefreshnests its owner-facet cleanup so the latch releases even when the marker-replacement allocation inside that cleanup throws.If queue growth throws, the state store records allocation-free retry flags on the view state. A later refresh-job tick claims those flags and retries only the facets whose publication failed. The marker remains authoritative until the operation reaches a terminal state.
Make full refresh snapshot-aware
Apply-WAL invalidations now carry the base-table token and the latest sequence transaction covered by the apply batch. A successful full refresh records its fixed reader snapshot and clears an invalidation only when the token matches and the snapshot transaction covers the invalidation transaction. A successful full refresh also clears, by object identity, the exact marker that was published before its fixed reader snapshot -- a provenance-free invalidation covered by construction no longer re-invalidates the view the rebuild just repaired.
A parked provenance-free cascade marker ("base materialized view is invalidated") is likewise consumed by a child's covering FULL rebuild; this is intended β neither master nor this branch gates FULL on the parent's invalid status, so the resulting child-valid-on-stale-parent state matches what a user
REFRESH FULLalready produces on master, and the chain self-heals when the repaired parent's next commit re-notifies the child.Unknown provenance, a different table epoch, or a newer transaction keeps the invalidation pending. A separate full-refresh owner also prevents an invalidation from consuming an independently requested rebuild. Blocked, suspended, missing-base, retry, and lock-loss exits now clear or transfer that owner instead of leaving a permanent sentinel.
Defer instead of spinning on read-only refusal
An authorization refusal from the read-only writer gate is a transient role condition, not a refresh failure, so neither facet invalidates the view or retries in a loop. The refused holder keeps its facet pending on the marker, re-queues nothing, and hands
finalizeAndUnlockthe exact publication it consumed; the post-release handoff suppresses only that publication's wake, keyed on object identity. Publications mint fresh marker and owner objects, so identity proves no newer request arrived during the hold β anything newer still wakes exactly once, and convergence stays bounded by real publications.The retained facets wait for out-of-band redelivery: a later latch holder's handoff,
RESUME WAL, a fresh request, or promote-time queue rebuild. The refresh and WAL-purge gates key on the marker's reason facet only, so a retained full-refresh owner does not freeze a valid view; the next ordinary refresh holder's finalize redelivers it.Preserve intent across suspension and non-mint exits
Suspended views retain pending invalidation and full-refresh facets. A successful
RESUME WALrepublishes those facets after writes reopen. A REFRESH FULL issued while the view is suspended now parks its owner facet before the suspended exit, so RESUME WAL redelivers it instead of silently dropping the request; RESUME WAL itself reports success even when the post-resume redelivery enqueue fails, leaving recovery to the store's retry flags.invalidateViewnow cascades to dependent views only after it persists an invalid state. Aforce=falsedecline on a never-incrementally-refreshed parent therefore leaves both the parent and its children valid. The String marker API also rejects a null invalidation reason; only the no-argument full-refresh API can create a reasonless facet.Hardening from external review (r19)
A further review pass on the finalized state machine surfaced two behavioral gaps and confirmed that one remaining simplification request targets an unreachable state; five new regression tests close the gaps and pin the invariant.
A view rename landing between a FULL task's enqueue and its writer acquire made the pass fail with table-does-not-exist; the rename branch re-enqueued the task's owner under the updated token while the finalize owner wake, which had no suppression handed to it on this branch, enqueued the same owner again under the stale token - two tasks for one request.
fullRefreshnow hands that owner tofinalizeAndUnlock0's existing identity-keyed suppression, so the rename re-enqueue is the single redelivery.The remaining review recipe asked for a test that reaches a FULL refresh with no rows and an unchanged watermark; that state is unreachable. The FULL pump unconditionally resets the watermark to -1 before it runs, and
findRefreshIntervalsstampstoBaseTxnwith the reader'sseqTxn, which is never negative for a WAL table, soinsertAsSelect's no-interval branch always advances the watermark and reports success. The dead|| intervalIterator == nullsub-clause is replaced byif (didRefresh)plus an assertion that pins the invariant, and the recipe is recorded as refuted rather than implemented.New tests close coverage gaps this and the prior review round left open: a rename racing a full refresh's finalize redelivers the owner exactly once;
RESUME WALredelivery that swallows an enqueue failure on one facet still recovers both the invalidation and full-refresh halves through the store's retry flags; the refresh timer schedules a run despite an owner-only marker with no invalidation reason; the WAL-purge gate treats an owner-only marker as a valid view rather than freezing purge; and a put-back failure under the promote gate recovers through the same retry-flag mechanism. A further pass consolidates duplicated helper methods across the trap test suite.Hardening from external review (r20)
A from-scratch review pass found no production defects but blocked on one coverage row: the retry-heap gate flip in
MatViewTimerJob.processRefreshRetriesβ per its javadoc the only path that wakes immediate views β had no test, so reverting it to the marker-any predicate left the whole tree green. A new test drives a due busy-retry entry with an owner-only marker parked and pins the reason-only gate.Ten further tests close the round's remaining coverage rows. A
@TestOnlyappend seam inMatViewStateStoreImpl, fired before both task-queue append sites, makes the store's real recovery catches reachable for the first time: tests now drive the owner-overload,enqueueInvalidate0, and put-back catches genuinely instead of modeling them at the engine wrapper, and a mid-scan failure test pins the per-state catch's unique duty of restoring retry flags for facets whose enqueue was never attempted. The rest cover the finalize catch's both-facets arm, suspension landing between the up-front gate and the writer acquire in both the FULL and invalidation paths, the generic terminal failure clearing the owner sentinel, and a stale handoff owner delivering as a no-op.REFRESH ... STATSnow swallows a wake-enqueue failure inside its post-release finalize instead of failing the statement after the stats reset durably succeeded, mirroringALTER TABLE RESUME WAL; the finalize's own catch has armed the store's retry flags before the swallow sees the throw.Effects and tradeoffs
valid, and it avoids invalidating a view when a full snapshot can prove that it covered the triggering base transaction.invalidand requireREFRESH ... FULLeven when a refresh happened to include the change, because suppressing an unproven invalidation could hide stale data.RESUME WAL. This avoids queue spin while writes remain unavailable, but the catalogue continues to show the last durable state during suspension.REFRESH ... STATSno longer surfaces a wake-enqueue failure from its post-release finalize; the statement logs the error and leaves redelivery to the store's retry flags. The caller loses direct visibility of that failure, in exchange for the statement no longer failing after its stats reset durably succeeded.Out of scope (follow-ups)
reenqueuePendingOnResumeinto that path closes the gap for both facets.MatViewStateStoredirectly, so the interface additions cannot break enterprise compilation; the inheritedalterTableResumeredelivery tail is untouched by the enterprise SQL compiler, so it fires for enterpriseRESUME WALtoo; and noMatViewStatesurvives a role switch, since demote frees the whole store and promote hydrates a fresh one, so stale-marker reuse across roles cannot arise. Only the routine OSS submodule pin bump remains on the enterprise side.MatViewFuzzTestwith a rows-affectedUPDATEmix plus occasionalREFRESH FULL, asserting post-drainview_statusconsistency, is the highest-value randomized check for the provenance and covering-FULL machinery; the current fuzz surface is truncate-driven and never stresses it.Test plan
mvn -pl core -Dtest=io.questdb.test.cairo.mv.MatViewPendingInvalidationTrapTest testSUSPEND WALandRESUME WAL, and aRESUME WALthat swallows a redelivery failure still recovers both facets on a later job tick via the store's retry flags.isMatViewRefreshSuspended()is constant false in OSS, confirms recovery via the same retry-flag mechanism for both facets.@TestOnlyappend seam: the owner-carrying full-refresh enqueue, the invalidation enqueue, put-back for both facets, and a mid-scan failure that must restore unclaimed retry flags for the second scan to redeliver.