fix(#4824): make Raft applied-index tracking per-database-aware - #4847
Conversation
ArcadeStateMachine multiplexes every database onto one Raft group but persisted a single global .raft/applied-index scalar. The per-database bootstrap replay-skip in applyBootstrapFingerprintEntry consulted that global value, so a co-located database that advanced the shared log past another database's BOOTSTRAP_FINGERPRINT_ENTRY index would silently suppress verification of a database that was never bootstrapped. The persisted file now carries a global Raft-log position plus a per-database map; applyTransaction records the index against the database the entry targeted, the full-state-machine install records every present database, and the bootstrap skip consults this database's own applied index (no global fallback). reinitialize()'s snapshot-gap check stays on the global value since it compares against the inherently global Ratis snapshot index. A legacy plain-number file is read as the global value.
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Code Review
This pull request addresses issue #4824 by transitioning the single global applied-index tracking to a per-database-aware JSON structure, preventing co-located databases from incorrectly skipping bootstrap verification. Feedback on the changes highlights potential concurrency issues in writePersistedAppliedIndex and writePersistedAppliedIndexForAllDatabases, recommending synchronization on appliedIndexFileLock to prevent race conditions on the in-memory state and concurrent file write conflicts.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| void writePersistedAppliedIndex(final long index, final String dbName) { | ||
| ensureAppliedIndexLoaded(); | ||
| globalAppliedIndex = index; | ||
| if (dbName != null) | ||
| appliedIndexByDb.put(dbName, index); | ||
| persistAppliedIndexFile(); | ||
| } |
There was a problem hiding this comment.
The writePersistedAppliedIndex method updates the in-memory applied index state and serializes it to disk. Since this can be called concurrently with other threads (such as during snapshot installation via writePersistedAppliedIndexForAllDatabases), we should synchronize this update on appliedIndexFileLock. This prevents race conditions on the in-memory state and avoids concurrent write conflicts on the temporary file applied-index.tmp.
void writePersistedAppliedIndex(final long index, final String dbName) {
synchronized (appliedIndexFileLock) {
ensureAppliedIndexLoaded();
globalAppliedIndex = index;
if (dbName != null)
appliedIndexByDb.put(dbName, index);
persistAppliedIndexFile();
}
}| private void writePersistedAppliedIndexForAllDatabases(final long index) { | ||
| ensureAppliedIndexLoaded(); | ||
| globalAppliedIndex = index; | ||
| if (server != null) | ||
| for (final String dbName : server.getDatabaseNames()) | ||
| appliedIndexByDb.put(dbName, index); | ||
| persistAppliedIndexFile(); | ||
| } |
There was a problem hiding this comment.
Similarly, writePersistedAppliedIndexForAllDatabases runs asynchronously on a ForkJoinPool thread during snapshot installation. It should be synchronized on appliedIndexFileLock to prevent concurrent write conflicts and race conditions with the transaction apply thread.
private void writePersistedAppliedIndexForAllDatabases(final long index) {
synchronized (appliedIndexFileLock) {
ensureAppliedIndexLoaded();
globalAppliedIndex = index;
if (server != null)
for (final String dbName : server.getDatabaseNames())
appliedIndexByDb.put(dbName, index);
persistAppliedIndexFile();
}
}
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
🟢 Coverage 94.20% diff coverage · -7.81% coverage variation
Metric Results Coverage variation ✅ -7.81% coverage variation Diff coverage ✅ 94.20% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (d38a9fc) 133643 99953 74.79% Head commit (8248b99) 165514 (+31871) 110863 (+10910) 66.98% (-7.81%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#4847) 69 65 94.20% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Review: fix(#4824) per-database Raft applied-index tracking Nicely scoped fix. The root-cause analysis is correct: the per-database replay-skip in applyBootstrapFingerprintEntry was consulting a value that mixes all databases, and making it consult per-database evidence (strict, no global fallback) is the right call. The legacy-file compatibility, atomic-rename, and lazy double-checked load are all handled cleanly, and the regression test is verified failing-before/passing-after. Comments explain the why well. A few points worth considering, mostly minor: 1. Concurrent writers race on the fixed temp file name (worth verifying)
If those two paths can ever overlap, they race on the same applied-index.tmp (one ATOMIC_MOVE fails because the temp was already moved, the exception is swallowed at FINE, and a write is silently lost). The single-temp-name pattern pre-dates this PR, but the PR adds the second writer. If install-snapshot and apply are guaranteed mutually exclusive in Ratis this is a non-issue - worth a one-line confirmation in the comment. Otherwise, either synchronize persistAppliedIndexFile() on appliedIndexFileLock or use a unique temp name. 2. appliedIndexByDb grows unbounded; dropped DBs are never evicted 3. Per-apply allocation on the hot path 4. Test gap: the snapshot-install path is untested 5. Doc drift (trivial) None of these block the fix - (1) is the only one I would want explicitly confirmed before merge. Good, well-documented work overall. Note: I was unable to run the ha-raft test suite in this review environment (build commands were not permitted), so this is a static review; the PR reports the suite passing. |
… test full-install path - Synchronize writePersistedAppliedIndex / writePersistedAppliedIndexForAllDatabases on appliedIndexFileLock so the apply thread and the snapshot-install thread never race on the in-memory state or the shared applied-index.tmp file (gemini + claude). - Evict a dropped database from the per-database map on DROP_DATABASE_ENTRY so the map and persisted JSON do not grow for the node lifetime (claude). - Add a regression test for the full state-machine install path (records the snapshot index for every present database) and one for per-database round-trip across restart (claude). - Document that the per-apply JSON allocation is dominated by the atomic-rename I/O that already ran every apply (claude).
Review: per-database Raft applied-index tracking (#4824)Solid, well-reasoned fix. The core problem is clearly articulated and the chosen approach (strict per-database evidence, no global fallback for the per-database decision) is the right call - falling back to the global value is exactly what produced the original defect. The lazy-load + in-memory cache + atomic-rename write keeps the hot apply path honest, and the inline comments document the why unusually well. Tests cover the regression, the legitimate skip, legacy migration, round-trip, and the full-install path. A few points worth discussing: 1. Legacy-file first-restart behavior (please confirm acceptable)Every existing deployment upgrades with a legacy plain-number file, which by design yields The PR says re-verification is "idempotent (a fingerprint match returns immediately)" - but that only holds when the local fingerprint still matches. The scenario the skip was originally introduced for (per the existing comment: "the local database has since been forward-replicated past the baseline") is precisely the case where local The window is bounded (the bootstrap entry must still be above the latest snapshot at upgrade time, which is mostly an issue for recently-formed / low-snapshot clusters), so this may well be acceptable as a one-time upgrade cost. But it's a behavioral regression relative to the old global-skip, and at minimum it would help to call it out explicitly in the doc/changelog so operators aren't alarmed by a burst of SEVERE "Refusing to overwrite local data" lines on the first restart after upgrade. Worth confirming the 2. DROP path issues two file writesIn Minor / positive
Nice work overall - the only thing I'd want resolved before merge is an explicit answer on the legacy-upgrade behavior in (1). |
…behavior - Fold the DROP_DATABASE_ENTRY global advance and per-database eviction into one synchronised write (writePersistedAppliedIndexDroppingDatabase), removing the previous two-write crash window that could leave a stale per-database entry for a dropped database (claude). - Document the one-time legacy-upgrade re-verification behavior in applyBootstrapFingerprintEntry and the tracking doc: a legacy plain-number file has no per-database breakdown, so bootstrap entries still above the latest snapshot re-verify once after upgrade - bounded and safe (match returns immediately; locally-fresher hits the refuse-overwrite guard; behind re-installs from leader) (claude).
Review: fix(#4824) make Raft applied-index tracking per-database-awareNicely scoped fix. The root-cause analysis is correct: a single global scalar consumed by a per-database decision ( A few observations, mostly minor: 1.
|
…ction test - ensureAppliedIndexLoaded no longer latches the cache as loaded when the file path is unresolvable (server not wired); a later call retries so a then-available persisted file is never masked (claude). - Add a direct test for the single-write DROP eviction path (global advances, per-db entry removed). - Note the graceful downgrade path and that globalAppliedIndex mirrors lastAppliedIndex; remove em dashes from the tracking doc (claude).
Review: fix(#4824) per-database Raft applied-index trackingReviewed the full change ( What's solid
Minor points (non-blocking)
PerformancePer-apply cost grows from writing a single number to serializing a small JSON doc, but the dominant cost ( SecurityNo concerns - no sensitive data, file stays under Nice work overall; the fix is minimal, correct, and unusually well-documented. The points above are polish, not blockers. Reviewed by Claude Code. |
- Soften the globalAppliedIndex/lastAppliedIndex comment: they are seeded independently and can briefly differ after reinitialize() (no snapshot) before the next apply reconverges them (claude). - Note that a corrupt applied-index file leaving the global at -1 also suppresses reinitialize()'s snapshot-gap download path - a deliberate, pre-existing coupling, not a regression (claude). - Add writePreservesOtherDatabasesEntriesOnDisk: a single-database write loads-before-mutate so an unrelated database's persisted entry survives the rewrite (claude).
|
Review: PR #4847 - per-database Raft applied-index tracking Reviewed the diff against ArcadeStateMachine.java, the new test, and the surrounding apply/reinitialize paths. Overall this is a well-reasoned, well-tested fix for a real correctness bug (issue 4824). The root-cause analysis is accurate and the approach (strict per-database evidence, idempotent re-verification fallback) is the right one. STRENGTHS
SUGGESTIONS / MINOR POINTS (non-blocking)
PERFORMANCE SECURITY Nice work - the fix is correct and the test suite gives good confidence. The only thing I would actually act on before merge is point (1) (the doc process metadata); the rest is optional polish. |
Rewrite ArcadeStateMachineAppliedIndexPerDatabaseTest to use a real, unstarted ArcadeDBServer with real LocalDatabase collaborators registered via registerDatabase() instead of a Mockito mock. A small CountingServer subclass tallies single-arg getDatabase() calls to observe whether the bootstrap verification path was reached, replacing the verify(...) assertions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review - PR #4847: per-database Raft applied-index trackingReviewed Strengths
Minor observations (none blocking)
VerificationStatically reviewed against the LGTM with the minor notes above as optional follow-ups. |
Address review notes: the round-trip test exercises the same read/write code on both ends, so add an explicit assertion of the persisted JSON document shape, plus a corrupt-file -> -1 degradation test covering the coupling reinitialize()'s snapshot-gap check relies on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the careful review. Disposition of the four notes: 4. Test gaps (a + b) - done (8248b99). Added 3. Downgrade note - already covered. 2. Lock spans file I/O - leaving as-is, per your read: single-threaded Ratis apply + the rare snapshot-install thread (which runs while applies are paused) means contention is effectively nil. 1. |
Review: PR #4847 - per-database Raft applied-index trackingThorough, well-scoped fix for #4824. The core change is correct: the bootstrap replay-skip now consults a per-database value with no global fallback, so a co-located database can no longer suppress another database's bootstrap verification. The docs, commit history, and test suite are unusually complete (regression + round-trip + corrupt-file degradation + DROP eviction + on-disk JSON shape pinning). Below are the findings. Should fix - empty-string database name pollutes the per-database map
Suggested fix: treat empty as absent everywhere if (dbName != null && !dbName.isEmpty())
appliedIndexByDb.put(dbName, index);and the symmetric guard in Minor
Nits
Nice work overall - the empty-string guard is the one item I'd want addressed before merge. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4847 +/- ##
============================================
- Coverage 65.72% 65.25% -0.47%
- Complexity 772 790 +18
============================================
Files 1681 1681
Lines 133643 133711 +68
Branches 28534 28551 +17
============================================
- Hits 87834 87259 -575
- Misses 33727 34479 +752
+ Partials 12082 11973 -109 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Closes #4824
Summary
ArcadeStateMachinemultiplexes every database onto one Raft group but persisted a single global.raft/applied-indexscalar that is advanced on every applied entry regardless of which database it targeted. The per-database bootstrap replay-skip inapplyBootstrapFingerprintEntry("this database's baseline was already applied in a prior session, skip verification") consulted that global value. A co-located database advancing the shared log past another database'sBOOTSTRAP_FINGERPRINT_ENTRYindex would therefore silently suppress verification of a database that was never bootstrapped - the "value that mixes databases" defect described in the issue.This makes applied-index tracking per-database-aware (suggested fix option 2):
globalRaft-log position plus adbmap ofdatabase name -> highest applied Raft index. A legacy plain-number file is read as theglobalvalue with an empty per-database map (honest: no per-database evidence).applyTransactionrecords the applied index against the database the entry targeted as well as the global position.applyBootstrapFingerprintEntrynow consults this database's own applied index (strict, no global fallback): it skips verification only when there is positive per-database evidence. Absent that, it re-verifies, which is idempotent (a fingerprint match returns immediately).reinitialize()'s snapshot-gap check keeps using the global value: it compares against the inherently global Ratis snapshot index, so its behaviour is unchanged.ConcurrentHashMap) keeps the hot apply path cheap - the file is parsed once lazily and serialised on each write, the same single atomic-rename write as before.Test plan
ArcadeStateMachineAppliedIndexPerDatabaseTest.bootstrapSkipNotTriggeredByAnotherDatabasesAppliedIndex- regression: a co-located database's high applied index must not skip another database's bootstrap verification (verified failing before the fix, passing after).bootstrapSkipHonorsPerDatabaseAppliedIndex- the legitimate per-database replay-skip on restart is preserved.legacyPlainNumberFileReadAsGlobalNotPerDatabase- a legacy plain-number file is honoured as the global value and yields no per-database evidence.perDatabaseValuesRoundTripAcrossRestart- per-database and global values round-trip through the file and recover in a fresh state machine.ArcadeStateMachine*Test,SnapshotInstaller*Test,WaitForApplyTest(0 failures / 0 errors).🤖 Generated with Claude Code