Skip to content

fix(#4939,#4941,#4956): DatabaseContext lifecycle - abandoned-tx rollback on dead-thread sweep, virtual-thread-correct liveness, thread-safe context maps - #5060

Merged
lvca merged 13 commits into
mainfrom
fix/4939-4941-4956-dbcontext-lifecycle
Jul 7, 2026
Merged

lvca merged 13 commits into
mainfrom
fix/4939-4941-4956-dbcontext-lifecycle

Conversation

@lvca

@lvca lvca commented Jul 6, 2026 •

Copy link
Copy Markdown
Member

Part of the 2026-07 engine audit follow-up (#4962), db-lifecycle group.

Per-issue verdicts

#4941 - explicit file locks leaked forever by dead threads: FIXED

Re-verified on current main: cleanupDeadThreads() only pruned the CONTEXTS map, so a thread dying with an open transaction holding explicit locks left the LockManager files owned by the dead Thread object; every later commit touching them timed out until restart. The sweep now rolls back the abandoned transactions (releasing the locks via reset()). Prerequisite fix: TransactionContext.getRequester() captures the requester once at first use (always lock-acquisition time on the owner thread) instead of re-resolving Thread.currentThread() at release time - the old behavior made any cross-thread unlock (sweep, closeInternal) throw LockException and leak. LockManager explicitly supports acquire-on-one-thread/release-on-another for equal requesters.

#4956 - getAllStackTraces() wrong for virtual threads + safepoint spike: FIXED

Re-verified: live virtual threads (e.g. GraphAnalyticalView build workers mid-transaction) were pruned as dead, and every 1000th init() paid a global-safepoint all-stacks capture. Each CONTEXTS entry now carries a WeakReference<Thread> checked with isAlive() - O(entries), safepoint-free, correct for platform and virtual threads, no pinning of terminated threads. The three GAV async tasks (build, rebuild, compaction) now call removeCurrentThreadContexts() in their finally blocks so thread-per-task contexts no longer accumulate until the next sweep.

#4939 - cross-thread mutation of non-thread-safe context state: PARTIALLY REPRODUCIBLE, the real part FIXED

The map race is real: removeAllContexts() (close/drop on another thread) mutated per-thread plain HashMaps concurrently with the owner's init(). Fix: per-thread maps are now ConcurrentHashMap, and removeAllContexts no longer prunes foreign CONTEXTS registry entries (that removal raced the owner's init() re-registration and could unregister a live context forever). The reported mid-commit interleaving (close rolling back a tx while its owner is inside commit1stPhase) is NOT reproducible on current main: commit() runs entirely under the database read lock while closeInternal's rollbacks run under the write lock of the same RWLockContext, so they cannot overlap. The pre-existing cross-thread rollback in closeInternal now also releases foreign locks correctly: guaranteed for terminated owners (isAlive happens-before) and, with the volatile requester from review round 3, for live owners whose locks were acquired before the close.

Test evidence (red-first)

New regression suite DatabaseContextLifecycleTest (5 tests). Run against the old behavior (test hooks kept, behavioral hunks reverted), 4 of 5 fail exactly as the audit predicted:

  • deadPlatformThreadWithExplicitLockIsSweptAndLocksReleased: LockTimeoutException: Timeout on locking file 1 (Product_0.1.65536.v0.bucket) during commit (fileIds=[1], timeout=2000ms
  • deadVirtualThreadWithExplicitLockIsSweptAndLocksReleased: same LockTimeoutException
  • liveVirtualThreadContextSurvivesSweep: live virtual thread's entry pruned
  • gavAsyncBuildUnregistersWorkerContext: dead GAV worker's entry lingered after awaitReady()
  • concurrentInitAndRemoveAllContextsDoNotCorruptContexts: contract test for the [tx] Cross-thread mutation of non-thread-safe transaction state on close/kill #4939 race (pure data race, no deterministic repro - stated honestly; green on both, guards the contract)

All green with the fix. Focused suites green: com.arcadedb.database.** (102 tests incl. async), ExplicitLockingTransactionTest, LocalTransactionExplicitLockRetryTest, GraphAnalyticalViewTest (126), GraphAnalyticalViewRegistryTest.

Conflict risks with sibling audit branches

  • docs/release-26.7.2.md (expected, every audit group appends here)
  • engine/src/main/java/com/arcadedb/database/TransactionContext.java (10-line change to getRequester(); tx/MVCC-group branches may touch this file)
  • engine/src/main/java/com/arcadedb/graph/olap/GraphAnalyticalView.java (3 one-line finally additions)

Second verification pass (2026-07-06)

The whole group was independently re-verified from scratch against current main before the follow-up commit: the regression suite was re-run red-first against the unfixed code and reproduced all four deterministic failures exactly (2x LockTimeoutException lock leak for #4941 on both platform and virtual threads, live-virtual-thread context pruned for #4956, GAV worker context leak). Follow-up commit: the sweep's rollback now skips INACTIVE transactions (mirrors closeInternal; every context holds at least one inactive TransactionContext, rolling those back was pointless work), plus a stale comment fix. Focused suites re-run on the final tree, all green: DatabaseContextLifecycleTest (5), com.arcadedb.database.** (108 tests, 37 classes), ExplicitLockingTransactionTest (5), LocalTransactionExplicitLockRetryTest (1), com.arcadedb.graph.olap.** (208 tests).

… rolls back abandoned txs, virtual-thread-correct liveness, thread-safe context maps

Three findings from the 2026-07 engine audit (tracking issue #4962), db-lifecycle group.

#4941 - Dead-thread cleanup discarded transactions without rollback; explicit file
locks leaked forever. A thread that died with an open transaction holding explicit
locks (acquireLock().type(X).lock()) had its CONTEXTS entry pruned by the periodic
sweep, dropping the only reference to explicitLockedFiles while the LockManager
entries stayed owned by the dead Thread object. Every later commit touching those
files failed with LockTimeoutException until restart. The sweep now rolls back the
abandoned transactions (releasing the locks through reset()) before dropping the
entry. The cross-thread unlock works because TransactionContext.getRequester() now
captures the requester ONCE, lazily, at first use - which is always lock-acquisition
time on the owning thread - instead of re-resolving Thread.currentThread() at
release time (which returned the sweeping/closing thread and made the unlock throw
LockException, leaking the locks). The LockManager explicitly supports
acquire-on-one-thread/release-on-another for equal requesters. This also makes the
pre-existing cross-thread rollback in LocalDatabase.closeInternal release foreign
contexts' locks correctly.

#4956 - Dead-thread detection used Thread.getAllStackTraces(): wrong for virtual
threads and a safepoint hazard. getAllStackTraces() returns platform threads only,
so a LIVE virtual thread's context entry (e.g. a GraphAnalyticalView build worker
inside its transaction) was pruned as dead while running; combined with the #4941
fix that would have force-rolled-back a live transaction. It also captures the full
stack of every platform thread at a global safepoint, injecting a multi-ms latency
spike into whichever request was the 1000th init(). Each CONTEXTS entry now carries
a WeakReference<Thread>; liveness is Thread.isAlive() on it - O(entries),
safepoint-free, correct for both thread kinds, and the weak reference does not pin
terminated threads. The three GraphAnalyticalView async tasks (build, rebuild,
compaction) now call removeCurrentThreadContexts() in their finally blocks, so
their thread-per-task virtual-thread contexts (each pinning Binary temp buffers)
no longer accumulate until the next sweep.

#4939 - Cross-thread mutation of non-thread-safe context state on close/kill.
removeAllContexts() (database close/drop on another thread) mutated the per-thread
plain HashMaps concurrently with the owner thread's init()/get()/put(): entries
could be lost or the table corrupted. The per-thread map is now a ConcurrentHashMap,
and removeAllContexts no longer prunes foreign CONTEXTS registry entries (that
removal raced the owner's init() re-registration and could unregister a live
context forever); empty entries are pruned by the owner or by the dead-thread
sweep. The reported mid-commit interleaving (closeInternal rolling back a tx while
its owner is inside commit1stPhase) is NOT reproducible on current main: commit()
runs entirely under the database read lock while closeInternal's rollbacks run
under the write lock of the same RWLockContext, so they cannot overlap; verdict
partially-reproducible, only the map race was real.

Red-first evidence (regression test DatabaseContextLifecycleTest, run against the
old behavior with only the test hooks kept):
- deadPlatformThreadWithExplicitLockIsSweptAndLocksReleased: FAILED with
  LockTimeoutException "Timeout on locking file 1 (Product_0.1.65536.v0.bucket)
  during commit (fileIds=[1], timeout=2000ms" (#4941)
- deadVirtualThreadWithExplicitLockIsSweptAndLocksReleased: FAILED with the same
  LockTimeoutException (#4941/#4956)
- liveVirtualThreadContextSurvivesSweep: FAILED - live virtual thread's entry
  pruned by the getAllStackTraces() sweep (#4956)
- gavAsyncBuildUnregistersWorkerContext: FAILED - dead GAV worker's context entry
  lingered after awaitReady() (#4956)
- concurrentInitAndRemoveAllContextsDoNotCorruptContexts: contract test for #4939;
  the HashMap corruption is a pure data race with no deterministic repro, so this
  hammers the two paths concurrently and asserts the owner never loses its context
  (green on both, documents the contract)

All 5 tests pass with the fix. Focused suites green: com.arcadedb.database.**
(102 tests incl. async), ExplicitLockingTransactionTest,
LocalTransactionExplicitLockRetryTest, GraphAnalyticalViewTest (126),
GraphAnalyticalViewRegistryTest.
@mergify

mergify Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 high

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
ErrorProne 1 high

View in Codacy

🟢 Metrics 5 complexity

Metric Results
Complexity 5

View in Codacy

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request hardens the thread-context lifecycle in ArcadeDB by introducing a periodic dead-thread sweep that rolls back abandoned transactions, replacing Thread.getAllStackTraces() with WeakReference to support virtual threads, and using ConcurrentHashMap to prevent data races. The review feedback is highly valuable and identifies several critical concurrency and safety issues: a potential race condition in cleanupDeadThreads() when thread IDs are reused, thread-safety issues in rollbackAbandonedTransactions() during concurrent database closing, memory visibility concerns with the non-volatile requester field, and exception safety risks in GraphAnalyticalView where a failure in context removal could leak permits and hang latches.

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.

Comment on lines +226 to +234
for (final Iterator<ThreadContexts> it = CONTEXTS.values().iterator(); it.hasNext(); ) {
final ThreadContexts threadContexts = it.next();
final Thread owner = threadContexts.owner.get();
if (owner == null || !owner.isAlive()) {
it.remove();
for (final DatabaseContextTL tl : threadContexts.contexts.values())
rollbackAbandonedTransactions(tl);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In cleanupDeadThreads(), iterating over CONTEXTS.values() and calling it.remove() can lead to a race condition if a thread ID is reused. In Java's ConcurrentHashMap, Iterator.remove() on the values iterator removes the mapping for the key unconditionally. If a new thread has reused the same thread ID and overwritten the entry in CONTEXTS with its own ThreadContexts before it.remove() is called, the new thread's active context will be accidentally removed from the registry. This can be prevented by iterating over CONTEXTS.entrySet() and using the atomic, conditional CONTEXTS.remove(key, value) method.

    for (final Map.Entry<Long, ThreadContexts> entry : CONTEXTS.entrySet()) {
      final ThreadContexts threadContexts = entry.getValue();
      final Thread owner = threadContexts.owner.get();
      if (owner == null || !owner.isAlive()) {
        if (CONTEXTS.remove(entry.getKey(), threadContexts)) {
          for (final DatabaseContextTL tl : threadContexts.contexts.values())
            rollbackAbandonedTransactions(tl);
        }
      }
    }

Comment on lines +244 to +253
private static void rollbackAbandonedTransactions(final DatabaseContextTL tl) {
for (int i = tl.transactions.size() - 1; i > -1; --i) {
try {
tl.transactions.get(i).rollback();
} catch (final Throwable e) {
// IGNORE ERRORS: BEST-EFFORT CLEANUP OF TRANSACTIONS ABANDONED BY A DEAD THREAD
}
}
tl.transactions.clear();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Since cleanupDeadThreads() runs concurrently with database closing/dropping (which calls removeAllContexts() and rolls back the returned contexts), multiple threads can concurrently access and modify the non-thread-safe ArrayList tl.transactions and call rollback() on the same TransactionContext instances. To prevent ConcurrentModificationException and race conditions during concurrent rollbacks, we should synchronize on tl and clear the transactions list atomically before rolling them back.

  private static void rollbackAbandonedTransactions(final DatabaseContextTL tl) {
    final List<TransactionContext> txs;
    synchronized (tl) {
      if (tl.transactions.isEmpty())
        return;
      txs = new ArrayList<>(tl.transactions);
      tl.transactions.clear();
    }
    for (int i = txs.size() - 1; i > -1; --i) {
      try {
        txs.get(i).rollback();
      } catch (final Throwable e) {
        // IGNORE ERRORS: BEST-EFFORT CLEANUP OF TRANSACTIONS ABANDONED BY A DEAD THREAD
      }
    }
  }

Comment on lines 557 to 567
private Object getRequester() {
return requester != null ? requester : Thread.currentThread();
// CAPTURE THE REQUESTER LAZILY AT FIRST USE, WHICH IS ALWAYS LOCK-ACQUISITION TIME ON THE OWNING THREAD.
// RESOLVING Thread.currentThread() ON EVERY CALL RETURNED THE WRONG REQUESTER WHEN THE LOCKS WERE RELEASED
// BY ANOTHER THREAD (DATABASE CLOSE ROLLING BACK FOREIGN CONTEXTS, OR THE DEAD-THREAD SWEEP ROLLING BACK AN
// ABANDONED TRANSACTION): THE UNLOCK FAILED WITH LockException AND THE FILE LOCKS LEAKED FOREVER (#4941).
// THE LockManager EXPLICITLY SUPPORTS ACQUIRE-ON-ONE-THREAD/RELEASE-ON-ANOTHER AS LONG AS THE REQUESTER
// OBJECT IS THE SAME
if (requester == null)
requester = Thread.currentThread();
return requester;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The requester field is a non-volatile field. When getRequester() is called concurrently or across different threads (e.g., by the owner thread during lock acquisition, and later by the sweeper or close thread during rollback), there is no guarantee of memory visibility. The sweeper thread might see requester as null and overwrite it with its own thread object, causing subsequent unlocks to fail with LockException and leak file locks. Making getRequester() synchronized ensures proper memory visibility and thread-safe initialization of the requester.

Suggested change
private Object getRequester() {
return requester != null ? requester : Thread.currentThread();
// CAPTURE THE REQUESTER LAZILY AT FIRST USE, WHICH IS ALWAYS LOCK-ACQUISITION TIME ON THE OWNING THREAD.
// RESOLVING Thread.currentThread() ON EVERY CALL RETURNED THE WRONG REQUESTER WHEN THE LOCKS WERE RELEASED
// BY ANOTHER THREAD (DATABASE CLOSE ROLLING BACK FOREIGN CONTEXTS, OR THE DEAD-THREAD SWEEP ROLLING BACK AN
// ABANDONED TRANSACTION): THE UNLOCK FAILED WITH LockException AND THE FILE LOCKS LEAKED FOREVER (#4941).
// THE LockManager EXPLICITLY SUPPORTS ACQUIRE-ON-ONE-THREAD/RELEASE-ON-ANOTHER AS LONG AS THE REQUESTER
// OBJECT IS THE SAME
if (requester == null)
requester = Thread.currentThread();
return requester;
}
private synchronized Object getRequester() {
// CAPTURE THE REQUESTER LAZILY AT FIRST USE, WHICH IS ALWAYS LOCK-ACQUISITION TIME ON THE OWNING THREAD.
// RESOLVING Thread.currentThread() ON EVERY CALL RETURNED THE WRONG REQUESTER WHEN THE LOCKS WERE RELEASED
// BY ANOTHER THREAD (DATABASE CLOSE ROLLING BACK FOREIGN CONTEXTS, OR THE DEAD-THREAD SWEEP ROLLING BACK AN
// ABANDONED TRANSACTION): THE UNLOCK FAILED WITH LockException AND THE FILE LOCKS LEAKED FOREVER (#4941).
// THE LockManager EXPLICITLY SUPPORTS ACQUIRE-ON-ONE-THREAD/RELEASE-ON-ANOTHER AS LONG AS THE REQUESTER
// OBJECT IS THE SAME
if (requester == null)
requester = Thread.currentThread();
return requester;
}

Comment on lines +355 to 361
// #4956: this virtual thread dies right after the task, but its DatabaseContext entry (pinning Binary
// temp buffers) would otherwise linger until the next periodic sweep. Remove it before the latch is
// released so callers observing readiness see a clean state.
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
BUILD_PERMITS.release();
buildQueued.set(false);
latch.countDown();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If DatabaseContext.INSTANCE.removeCurrentThreadContexts() throws any unexpected runtime exception, the subsequent critical cleanup statements (BUILD_PERMITS.release(), buildQueued.set(false), and latch.countDown()) will be skipped. This would leak the permit and permanently hang any threads waiting on the latch. To prevent this, wrap the context removal in a try-catch block.

Suggested change
// #4956: this virtual thread dies right after the task, but its DatabaseContext entry (pinning Binary
// temp buffers) would otherwise linger until the next periodic sweep. Remove it before the latch is
// released so callers observing readiness see a clean state.
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
BUILD_PERMITS.release();
buildQueued.set(false);
latch.countDown();
try {
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
} catch (final Throwable t) {
LogManager.instance().log(this, Level.WARNING, "Failed to remove current thread contexts", t);
}
BUILD_PERMITS.release();
buildQueued.set(false);
latch.countDown();

Comment on lines +1332 to 1336
// #4956: drop this worker's DatabaseContext entry (see buildAsync)
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
BUILD_PERMITS.release();
compacting.set(false);
latch.countDown();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If DatabaseContext.INSTANCE.removeCurrentThreadContexts() throws any unexpected runtime exception, the subsequent critical cleanup statements (BUILD_PERMITS.release(), compacting.set(false), and latch.countDown()) will be skipped. This would leak the permit and permanently hang any threads waiting on the latch. To prevent this, wrap the context removal in a try-catch block.

Suggested change
// #4956: drop this worker's DatabaseContext entry (see buildAsync)
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
BUILD_PERMITS.release();
compacting.set(false);
latch.countDown();
try {
DatabaseContext.INSTANCE.removeCurrentThreadContexts();
} catch (final Throwable t) {
LogManager.instance().log(this, Level.WARNING, "Failed to remove current thread contexts", t);
}
BUILD_PERMITS.release();
compacting.set(false);
latch.countDown();

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Thorough, well-scoped work. The three fixes are logically independent but genuinely related, the code comments explain the why (not just the what), and the red-first test evidence in the PR description is the kind of rigor that makes a lifecycle/concurrency change reviewable. I verified the claims against LockManager, TransactionContext, and both LocalDatabase.closeInternal paths - they hold up. Findings below are ordered by importance; none are blockers.

Correctness - verified sound

  • Cross-thread unlock via requester capture ([tx] Dead-thread cleanup discards transactions without rollback; explicit file locks leak forever #4941). Confirmed against LockManager.unlock() (utility/LockManager.java:188): the owner check is rl.owner.equals(requester), keyed on the requester object, not the calling thread. Capturing requester once at first use (always lock-acquisition time, since every getRequester() call site other than acquisition is guarded by a non-null explicitLockedFiles/lockedFiles) is correct, and composes cleanly with the existing PostBeginHandler.setRequester(session.id) HTTP path. Good fix.
  • Weak-ref liveness ([engine] DatabaseContext dead-thread cleanup uses getAllStackTraces: wrong for virtual threads + periodic safepoint latency spike #4956) is the right replacement for getAllStackTraces(): O(entries), safepoint-free, correct for virtual threads. Worth noting for reviewers that a WeakReference<Thread> clearing for a "live" thread is not actually a hazard - a live platform thread is always strongly reachable from the JVM thread list, and a virtual thread that has become weakly-unreachable can never be resumed, so treating it as dead is the correct outcome, not a false positive. A one-line comment to preempt that question could help.
  • ConcurrentHashMap per-thread map + no foreign pruning ([tx] Cross-thread mutation of non-thread-safe transaction state on close/kill #4939). The core race (owner init()/map.put vs. foreign removeAllContexts/map.remove) is genuinely closed by the concurrent map, and dropping the empty-entry pruning removes the unregister-a-live-context race. Correct.

Points worth a look

  1. Double-rollback race between the sweep and closeInternal (low risk, worth acknowledging). cleanupDeadThreads() runs from init() on any thread and is not under the database RW lock, while closeInternal (LocalDatabase.java:2088) also rolls back foreign contexts via removeAllContexts. If a thread dies mid-transaction on db X while another thread closes db X and a third triggers the sweep, both paths can observe the same DatabaseContextTL (the ConcurrentHashMap.values() iterator is weakly consistent, so the sweep may still see a value removeAllContexts is removing) and call tx.rollback() on the same TransactionContext concurrently. In practice benign - both callers swallow Throwable, and LockManager.unlock is a no-op when owner == null/removed - but the narrow window where a third transaction reacquires the same file between the two unlocks would make the stale unlock throw LockException (swallowed). The "nobody else can touch these transactions" comment on rollbackAbandonedTransactions slightly overstates the guarantee; "tolerated (best-effort, error-swallowing)" is more accurate than "impossible".

  2. removeAllContexts no longer prunes empty entries. Correct for the race, but a long-lived pooled/platform thread whose map is emptied by a foreign close keeps an empty ThreadContexts (empty ConcurrentHashMap) registered until it dies (sweep) or re-init()s. Bounded by live-thread count, so effectively harmless, and init() overwrites it via the identity check - it self-heals in the common case. Just a small steady-state footprint note.

  3. GAV applyDelta cleanup on reused pool threads. The three removeCurrentThreadContexts() additions are in finally/last-statement position, correct for run-to-completion async tasks. Since DatabaseAsyncExecutor threads are pooled/reused, please confirm no enclosing per-thread context is expected to survive across tasks on the same worker (the green GraphAnalyticalViewTest run suggests not, but it is the one spot where "drop the whole thread's context" could surprise a future batching caller).

Minor / nits

  • isThreadRegistered / registeredThreadIds are test-only hooks in production code. Acceptable and the Javadoc says so; the visible-for-testing intent is only in prose.
  • rollbackAbandonedTransactions catches Throwable and silently ignores. Consistent with surrounding best-effort style, but a Level.FINE log (as init()'s rollback loop and closeInternal do) would help diagnose a stuck-lock report in the field without adding noise.
  • Test comments are excellent (the honest "no deterministic repro" note on the [tx] Cross-thread mutation of non-thread-safe transaction state on close/kill #4939 contract test is the right call). Consider @Tag("slow") on concurrentInitAndRemoveAllContextsDoNotCorruptContexts (20k iterations + spin-loop) only if it adds noticeable CI time; likely sub-second, so optional.

Test coverage

Strong. Red-first evidence for 4 of 5 tests, platform + virtual thread variants for the lock-leak path, a live-virtual-thread survival test, and the GAV worker-unregister assertion. The one gap (by nature, not omission) is the double-rollback race in point 1, which - like the #4939 interleaving - has no deterministic repro.

Overall: solid, careful fix with good justification and coverage. The items above are refinements/acknowledgements, not blockers.

…ead sweep rollback

Second independent verification pass over the group's fix (all three findings
re-confirmed against current main with the same red-first evidence: the two
explicit-lock leak tests fail with LockTimeoutException, the live virtual
thread's context is pruned, and the GAV worker context lingers - all on the
unfixed code; 5/5 green with the fix).

Delta from the review: rollbackAbandonedTransactions now calls rollback() only
on ACTIVE transactions, mirroring closeInternal's pattern - every context
carries at least one (usually INACTIVE) TransactionContext, and rolling those
back was pointless reset() work on the sweeping thread. Also tightened a stale
comment in init() and constant alignment.

Focused suites re-run on this exact tree, all green: DatabaseContextLifecycleTest
(5), com.arcadedb.database.** (108, 37 classes), ExplicitLockingTransactionTest
(5), LocalTransactionExplicitLockRetryTest (1), com.arcadedb.graph.olap.** (208).
@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (issues 4939, 4941, 4956)

Solid, well-reasoned PR. I traced the fix end-to-end against LockManager, TransactionContext, and the GAV executor and the core mechanics hold up. Notes below, none blocking.

Correctness - verified

  • 4941 requester capture is the right pivot. LockManager keys ownership by REQUESTER.equals() and unlock() throws LockException on a non-owner (LockManager.java:188). Since lock acquisition always flows through getRequester() (tryLockFiles/lockFilesInOrder), the lazy capture freezes the owner Thread at acquisition time, so the cross-thread release from the sweep matches. Correct.
  • 4956 liveness check. Thread.isAlive() on a WeakReference<Thread> is virtual-thread-correct and drops the getAllStackTraces() safepoint. Confirmed the GAV executor is newThreadPerTaskExecutor(Thread.ofVirtual()...) (GraphAnalyticalView.java:100), so each worker thread genuinely dies after its task and the removeCurrentThreadContexts() in the finally blocks is the right cleanup point. The applyDelta placement (last statement, after the possible re-entrant applyDelta(forced)) is correct.
  • Memory visibility of the sweep read is subtle but sound: owner.isAlive() == false establishes a happens-before with the dead thread final actions (JLS 17.4.5), which covers the non-volatile reads of requester and tl.transactions. Worth a one-line comment noting that reliance, since it is the only thing making the non-volatile requester read safe. (The owner.get() == null GC-d-thread branch does not get that formal edge, but a Thread is strongly reachable while alive, so by the time the weakref clears the thread is long dead - fine in practice.)

Minor points

  1. getRequester() now has a side effect (mutation), creating a latent ordering hazard with setRequester(). In PostBeginHandler the order is begin -> setRequester(session.id) -> first lock, so nothing calls getRequester() before the session id is set and all is well. But if a future path acquired a lock (capturing Thread into requester) before setRequester() overwrote it with the session id, the acquire/release requesters would diverge and unlock() would throw LockException. A short comment on setRequester() stating it must precede the first lock acquisition would guard that invariant.

  2. Sweep latency moved, not just removed. The unlucky 1000th init() caller now runs rollbackAbandonedTransactions() synchronously for every dead thread found (record reload(), file unlocks). Almost certainly cheaper than the old global safepoint, and dead threads with abandoned active txs are rare, but the cost is now proportional to accumulated dead-thread work rather than a fixed safepoint - worth being aware of.

  3. concurrentInitAndRemoveAllContextsDoNotCorruptContexts is a weak exercise of the race it targets. It hammers removeAllContexts("not-existing-database-path"), so the inner contexts.remove(otherName) never matches and never mutates the map the owner is using; it mainly exercises concurrent iteration of the CONTEXTS map. Passing the owner actual database path to the close thread would exercise the ConcurrentHashMap-vs-plain-HashMap fix directly (the plain HashMap is the thing that could corrupt under a concurrent remove + owner put). The author already flags honestly that the corruption has no deterministic repro, so this is a suggestion, not a defect.

Style / conventions

  • Comment style, final usage, and AssertJ assertThat(...).isX() all match the repo conventions in CLAUDE.md.
  • removeAllContexts intentionally no longer prunes emptied entries; the trade-off (a live-but-idle thread can leave an empty ThreadContexts lingering until it dies or re-inits) is small and clearly documented in the code comment.

Test coverage

Good red-first regression suite covering both thread kinds, the live-virtual-thread survival case, and GAV worker unregistration. The explicit-lock-leak tests are deterministic (explicit cleanupDeadThreads() call), which is the right call over relying on the 1000-init counter.

Nice work - the reasoning in the PR body matches what the code actually does, and the fix targets the real root cause (requester identity) rather than the symptom.

@codecov

codecov Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.57143% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.56%. Comparing base (813cf4b) to head (c3761d4).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
...in/java/com/arcadedb/database/DatabaseContext.java 87.93% 4 Missing and 3 partials ⚠️
...main/java/com/arcadedb/database/LocalDatabase.java 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5060      +/-   ##
============================================
- Coverage     65.60%   65.56%   -0.04%     
+ Complexity      842      841       -1     
============================================
  Files          1687     1687              
  Lines        135246   135295      +49     
  Branches      28941    28952      +11     
============================================
- Hits          88723    88710      -13     
- Misses        34415    34475      +60     
- Partials      12108    12110       +2     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Both #5060 and the merged #5061 reworked TransactionContext.getRequester
for different, compatible reasons; the composition preserves both:

- #4959 (#5061, merged): PUBLIC visibility, and an explicit requester set
  via setRequester (e.g. a server-side session) is always the lock
  identity - executeLockingFiles locks on the transaction's behalf.
- #4941 (this PR): when NO explicit requester exists, the owning thread is
  captured ONCE at first use (lock-acquisition time on the owner) instead
  of re-resolving Thread.currentThread() per call - so a cross-thread
  release (dead-thread sweep, close rolling back foreign contexts) unlocks
  with the same requester object that acquired, and the file locks no
  longer leak. setRequester still wins whether it runs before or after the
  lazy capture (it assigns the field directly).

Release-notes conflict resolved as the additive union.

Union battery green: DatabaseContextLifecycleTest 5/5 (this PR's sweep and
liveness tests), Issue4959TransactionCleanupsTest 3/3 (the merged
requester-based locking), Issue4940Phase2FailureRollbackTest 3/3,
WalCommitOrderingTest 5/5, ExplicitLockingTransactionTest 5/5,
LocalTransactionExplicitLockRetryTest, TransactionIsolationContractTest
2/2.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: DatabaseContext lifecycle hardening (issues 4939, 4941, 4956)

Thorough, well-reasoned change with excellent red-first test evidence and honest per-issue verdicts (including the "partially reproducible" call on 4939). The design is sound. Below are observations, none of them blockers.

Correctness (the tricky parts hold up)

  • Cross-thread lock release via lazy getRequester(): capturing requester at first use (lock-acquisition time on the owner) and reusing it for the sweep/close unlock is the right fix. I checked the call sites: every unlock (reset(), commit2ndPhase, rollback) is preceded by an acquire (tryLockFiles in lockFilesInOrder) on the same owner thread, so the field is always seeded with the correct identity before any foreign-thread unlock. reset() does not null requester, which is correct here since the TransactionContext is pinned to one threads DatabaseContextTL.

  • Memory visibility of requester across the dead-thread boundary: this is actually safe by the JMM. cleanupDeadThreads() gates the rollback on owner.isAlive() == false, and JLS 17.4.4 specifies that the final action of a terminated thread synchronizes-with another thread detecting termination via isAlive() (or join()). So the sweeping thread is guaranteed to see the dead owners non-volatile requester write. Worth a one-line comment noting this, because it is non-obvious and a future refactor could easily break the guarantee (e.g., replacing the isAlive() check with a bare WeakReference staleness check). The owner == null branch does not carry the same formal happens-before, but a GC-cleared Thread is definitively long-terminated, so it is fine in practice.

  • Skip-inactive optimization (patch 2) is safe: explicit and MVCC locks are always released and explicitLockedFiles/lockedFiles nulled when status transitions to INACTIVE (reset()), so an inactive tx can never still own a file lock. Good call mirroring closeInternal.

Things worth considering

  1. The sweep now does real work synchronously on a random callers init(). Every 1000th init() now potentially runs full rollback() (callbacks, WAL, lock release) for an abandoned foreign transaction, instead of the old cheap set operation. The net latency profile is very likely better than the removed getAllStackTraces() global safepoint, but it is no longer bounded/constant: a dead thread that abandoned N nested transactions makes some unlucky request pay for all N rollbacks inline. Since this is a safety-net path this is probably acceptable, but a brief note in the Javadoc that the sweep can now block the triggering thread would help operators reason about a tail-latency blip. Consider whether a reentrancy guard is warranted, since rollback() re-enters DatabaseContext on the sweeping thread.

  2. removeAllContexts no longer prunes empty entries for live threads. The race fix is correct (pruning raced the owners init() re-registration). The tradeoff is that a long-lived pooled/platform thread whose only open database is then closed keeps an empty-map ThreadContexts entry in CONTEXTS until it either calls removeContext/removeCurrentThreadContexts or dies. It is bounded by live-thread count and each entry is tiny, so not a real leak, but the code comment claims empty entries are pruned by the owner which only holds if that thread touches the context API again. Minor: the comment slightly over-promises.

Test coverage

Strong. Deterministic repros for the two lock-leak cases and the live/dead virtual-thread cases, driven by direct cleanupDeadThreads() calls rather than the 1000-init counter (good, avoids flakiness). The concurrentInitAndRemoveAllContextsDoNotCorruptContexts soak test is honestly labeled as a contract test with no deterministic repro; 20k iterations is a reasonable hammer.

Style / conventions

  • Matches surrounding code (uppercase inline comments, final on locals/params, AssertJ assertThat(...).isTrue()).
  • Package-private test hooks (isThreadRegistered, registeredThreadIds, cleanupDeadThreads) are clearly annotated as test-only; slightly widens the API surface but reasonable.
  • The thread-per-task virtual-thread executor (newThreadPerTaskExecutor(Thread.ofVirtual())) confirms the GAV removeCurrentThreadContexts() calls are safe: each worker is a one-shot thread, so there is no risk of clearing a pooled threads contexts for a different database.

Overall: solid, defensible fix with good evidence. My only real asks are the two documentation notes (JMM isAlive() synchronizes-with rationale on the requester read, and the sweep-does-inline-rollback latency note). Nice work.

Review round 1 responses, verified against the merged branch state:

- Taken (upgraded from the reviewer's 'consider a reentrancy guard'): pure
  re-entrancy was already safe (the entry is unlinked before the rollback loop
  and the CONTEXTS traversal is weakly consistent), but verifying it surfaced a
  real race: two threads landing on the periodic init() boundary together both
  sweep, and values().iterator() gives no atomic claim, so both could roll back
  the same abandoned transaction (double unlock, concurrent record reloads on
  shared caches). cleanupDeadThreads() now claims each dead entry atomically
  via CONTEXTS.remove(key, value) (thread ids are never reused) and only the
  claimant rolls back. Red-first: the new
  concurrentSweepsRollBackAbandonedTransactionsExactlyOnce contract test
  observed a double rollback on its first run against the old code.
- Taken (doc): JMM rationale on the requester read across the dead-thread
  boundary - the isAlive() == false check synchronizes-with the dead owner's
  final action (JLS 17.4.4), so the sweeper sees the non-volatile requester
  write; comment warns against replacing it with a bare staleness check.
- Taken (doc): cleanupDeadThreads() Javadoc now states the rollbacks run
  inline on the triggering thread (tail-latency note for operators) and why
  the re-entrant path is safe.
- Taken (comment accuracy): removeAllContexts() no longer over-promises -
  an empty entry lingers until the owner touches the context API again or
  dies; tiny and bounded by live-thread count.
- Acknowledged, no change: lazy getRequester() seeding order at unlock sites,
  skip-inactive sweep optimization, and test-hook surface are as analyzed.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Strong, well-reasoned PR. The root-cause analysis is excellent, the red-first regression suite is convincing, and the inline documentation (JMM rationale for the isAlive() read, the atomic-claim comment, tail-latency note) is exactly the kind of context that keeps this code maintainable. The three fixes are individually sound:

Main concern: sweeper-vs-closeInternal can still double-roll-back the same abandoned tx

Commit 3 added an atomic claim (CONTEXTS.remove(key, value)) so two sweepers never roll back the same abandoned transaction twice. But the identical "double unlock / concurrent record reloads on shared caches" hazard exists between the sweep and LocalDatabase.closeInternal, and that path is not covered by the claim:

  • closeInternal (LocalDatabase.java:2113-2124) calls removeAllContexts(databasePath) and rolls back the returned DatabaseContextTLs under the DB write lock.
  • cleanupDeadThreads() rolls back the same dead thread's tl with no DB lock.

removeAllContexts removes the db key from the inner contexts map, while the sweep reaches the same tl via threadContexts.contexts.values() (weakly-consistent iteration). If a thread died with an open tx on database X and X is closing/dropping concurrently with the 1000th-init() sweep, both paths can obtain the same tl and:

  1. Concurrently call get(i) / clear() on tl.transactions - a plain ArrayList (DatabaseContext.java:292), so an unsynchronized data race.
  2. Concurrently tx.rollback() the same TransactionContext.

In practice this is largely benign - LockManager.unlock() tolerates a repeated same-requester release, and both call sites swallow Throwable - so worst case is a swallowed LockException on partial unlock or a spurious AIOOBE. But it is the same defect class the PR just went out of its way to eliminate for sweeper-vs-sweeper, and the PR body's isolation argument ("commit under read lock, close under write lock, cannot overlap") explicitly does not extend to the sweep, which takes no DB lock. Consider having the sweep claim each tl the same way closeInternal does (e.g. threadContexts.contexts.remove(dbKey, tl) before rolling back) so exactly-one path wins per tl, or acknowledge the residual race in the Javadoc as you did for the others.

Minor

  • removeContext() (DatabaseContext.java:137) still does an unconditional CONTEXTS.remove(Thread.currentThread().threadId()) rather than the value-keyed remove used in the sweep. It only runs on the owner thread so it is correct, but it is inconsistent with the new claim discipline. Low priority.
  • The inline rollback on the triggering request thread (tail-latency blip) is documented and acceptable for a safety-net path; no change needed, just flagging for operators.

Tests

Good coverage: platform/virtual dead-thread lock release, live-virtual-thread survival, GAV worker unregistration, the concurrent-init contract test, and the exactly-once sweeper race. The one gap is a test for the sweeper-vs-closeInternal interleaving described above.

Nice work overall - the analysis quality here is high.

…e rollback

Review round 2 responses, verified against LocalDatabase.closeInternal:

- Taken (real fix, reviewer's preferred resolution): the round-1 atomic claim
  covered sweeper-vs-sweeper only. closeInternal claims each tl of the closing
  database via removeAllContexts()'s remove(databaseName) and rolls it back
  under the DB write lock, while the sweep reached the same tl through
  threadContexts.contexts.values() with no claim and no DB lock - both paths
  could hold the same tl and concurrently roll back / mutate the plain
  ArrayList. The sweep now additionally claims each per-database context with
  a value-keyed remove (contexts.remove(dbKey, tl)); removeAllContexts()'s
  keyed remove already acts as the claim on the close side, so exactly one
  path wins per tl with no new locks. rollbackAbandonedTransactions() Javadoc
  updated: exclusivity now rests on the two-level claim, not just owner death.
- Test: sweepAndDatabaseCloseRollBackAbandonedTransactionsExactlyOnce,
  a 100-round hammer racing cleanupDeadThreads() against a faithful
  closeInternal simulation (removeAllContexts + rollback of returned
  contexts). Honest labeling: the pre-fix window is a sub-microsecond
  iteration crossover (both paths traverse CONTEXTS in the same order) and
  never went red locally even hammered (300+ rounds); it is an exactly-once
  contract test - post-fix the invariant is guaranteed by construction.
  CountingTransaction stub factored out and shared with the round-1 test.
- Taken (minor, documented rather than changed): removeContext()'s and
  removeCurrentThreadContexts()'s unconditional CONTEXTS.remove is safe
  because it runs on the owner thread, thread ids are never reused, only the
  owner registers under its own id and the sweep only claims dead threads;
  value-keying would add a CONTEXTS.get for no correctness gain. Comments
  added at both sites.
- Acknowledged, no change: inline-rollback tail latency already documented in
  round 1; benign worst-case analysis of the race (LockManager tolerates the
  repeated release) matches ours, fixed anyway as the same defect class.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Thorough, well-documented work. The three findings are real, the fixes are minimal and targeted, and the regression suite is genuinely red-first for the deterministic cases. The two-level atomic-claim (CONTEXTS.remove(key, value) for sweeper-vs-sweeper, plus per-database contexts.remove(dbKey, tl) for sweep-vs-closeInternal) is a clean solution to the exactly-once requirement without introducing new locks. A few observations, mostly nuances rather than blockers.

Correctness

1. The "closeInternal now releases foreign locks correctly" claim only holds for dead owners. The JMM argument documented in cleanupDeadThreads() is sound: isAlive() == false synchronizes-with the dead owner's final action (JLS 17.4.4), so the sweeper is guaranteed to observe the non-volatile requester write. But LocalDatabase.closeInternal() rolls back foreign contexts via removeAllContexts() while the owning thread may still be alive (close/drop racing a live thread that acquired explicit locks outside a commit). In that path there is no happens-before edge to the owner's lazy requester write:

  • Owner getRequester() writes requester = T_owner, then publishes the lock into LockManager's CHM.
  • Closer getRequester() reads requester before it touches the LockManager CHM in unlockFilesInOrder, so the CHM's HB edge does not cover the earlier field read.
  • The closer can therefore read a stale null, re-capture T_closer, and the explicit-lock unlock keyed by T_closer fails - the exact leak being fixed.

This is not a regression (the old Thread.currentThread() behavior was unconditionally wrong here too, so this is strictly better), and the window is narrow. But the PR body / release note state closeInternal now releases foreign locks "correctly" without qualification. Consider softening that to "correctly when the owner has terminated" (the guaranteed case), or noting the live-owner close as best-effort. Marking requester volatile would close the gap cheaply if you want the guarantee to extend to the live-owner path.

2. Reentrant full sweep. cleanupDeadThreads() rolls back inline, and a rollback can re-enter init() (record reloads resolving the sweeper's own context). If that init() happens to be the 1000th, INIT_CALL_COUNTER % CLEANUP_INTERVAL == 0 triggers a nested cleanupDeadThreads(). The weakly-consistent entrySet() iterator and the atomic claim make this safe (a nested sweep can't double-roll-back a claimed entry), so this is correct as written - but the class Javadoc only documents reentrancy safety for the CONTEXTS traversal, not the possibility of a nested full sweep re-triggered by the counter. A one-line note would help the next reader.

Design / performance

  • Replacing Thread.getAllStackTraces() (global safepoint, platform-only) with a per-entry WeakReference<Thread> + isAlive() is the right call - O(entries), safepoint-free, virtual-thread-correct, no pinning. Good.
  • The init() fast-path (registered.contexts != map guard to skip the put + WeakReference allocation on the steady-state path) is a nice touch given init() is hot.
  • GAV workers are newThreadPerTaskExecutor virtual threads that die per task, so removeCurrentThreadContexts() in the finally blocks is appropriate and the "last statement, applyDelta may re-register" ordering note is correct.
  • The owner == null (GC-cleared) branch honestly flags that it lacks the formal HB guarantee; the pragmatic "GC-cleared thread is long terminated" stance is reasonable.

Tests

Strong coverage. The honesty about which tests are true red-first repros vs. exactly-once contract tests (sweepAndDatabaseCloseRollBackAbandonedTransactionsExactlyOnce, concurrentInit...) is appreciated and matches reality - the data races have no deterministic repro. CountingTransaction is a clean, storage-free stub. One suggestion: the two exactly-once contract tests guarantee the invariant post-fix by construction but, as noted, never went red pre-fix, so they won't catch a future regression that reintroduces the race in a way the construction no longer prevents. Not actionable now, just be aware they're contract assertions rather than race detectors.

Style / conventions

  • Comments are heavy (all-caps blocks) but consistent with the surrounding file's existing style, and the JMM rationale genuinely earns its length.
  • No System.out, final used throughout, AssertJ assertThat(...).isFalse() style matches CLAUDE.md.

Nothing here blocks merge. The main thing worth a second look is qualifying the closeInternal "correctly releases foreign locks" claim for the live-owner case (and deciding whether volatile requester is worth it to make that path guaranteed rather than best-effort).

…tee to live-owner closes

The round's JMM analysis was exact: the dead-owner path is covered by the
isAlive() happens-before (JLS 17.4.4), but closeInternal can roll back a
LIVE owner's context, where no such edge existed - the closer could read a
stale null requester, re-capture itself and key the unlock wrong, leaking
the very locks #4941 fixes. Not a regression (the old per-call
Thread.currentThread() was unconditionally wrong there), but the reviewer's
cheap closing move is right: requester is volatile, so a live owner's lazy
capture (written at lock-acquisition time, well before any close) is
visible to the closing thread. Reasoning documented on the field.

Also taken: the class doc now notes that a rollback can re-enter init() and
trigger a NESTED full sweep on the 1000th call - safe for the same reasons
(weakly-consistent traversal + the atomic claim), documented so the
recursion is expected rather than surprising.

Acknowledged: the exactly-once tests are contract assertions, not race
detectors - already labeled as such in round 2.

Focused suites green: lifecycle 7/7, explicit locking, tx cleanups, lock
retry.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review - PR #5060 (DatabaseContext lifecycle hardening)

Reviewed the full diff (DatabaseContext, TransactionContext, GraphAnalyticalView, the new regression suite) and cross-checked the call sites in LocalDatabase, LocalTransactionExplicitLock and LockManager. Overall this is high-quality, unusually well-documented work: the three fixes are cohesive, the concurrency reasoning (JLS 17.4.4 happens-before via isAlive(), the two-level atomic claim, weakly-consistent traversal) is sound, and the red-first regression suite is a real asset. A few things worth discussing before merge.

Correctness - looks solid

  • Virtual-thread liveness via WeakReference<Thread> + isAlive() is the right call: a running/parked live thread is always strongly reachable (through its carrier or whatever synchronizer it blocks on), so owner.get() never returns null for a live thread and there are no false-positive prunes. O(entries) and safepoint-free - a clear improvement over getAllStackTraces().
  • Exactly-once rollback between the sweep and closeInternal: the sweep's value-keyed contexts.remove(key, value) vs removeAllContexts's single-arg contexts.remove(databaseName) are both atomic on the same ConcurrentHashMap, so exactly one wins. Correct, and the sweeper-vs-sweeper CONTEXTS.remove(key, value) claim is the same pattern.

1. getRequester() is now a mutating getter with a load-bearing, fragile invariant

The lazy capture (if (requester == null) requester = Thread.currentThread();) is correct only because "the first call is always on the owner thread at lock-acquisition time." I traced the callers (explicitLock/lockFilesInOrder -> tryLockFiles(getRequester()), executeLockingFiles, the commit/rollback unlock paths) and the invariant holds today - every first call happens on the owner before any cross-thread unlock. But it's fragile:

Consider making the invariant harder to break - e.g. an explicit captureRequester() called from the acquisition path, or at minimum a prominent // INVARIANT: banner on the field (the rationale is currently only in the getter Javadoc). A getter that mutates shared state is a footgun for the next maintainer.

2. Test isolation - gavAsyncBuildUnregistersWorkerContext may be flaky

CONTEXTS and INIT_CALL_COUNTER are JVM-wide statics. This test snapshots registeredThreadIds() before/after and asserts the diff is empty:
```java
final Set leaked = new HashSet<>(DatabaseContext.registeredThreadIds());
leaked.removeAll(before);
assertThat(leaked).isEmpty();
```
If any unrelated thread in the same JVM registers a context between the two snapshots (parallel test execution within a fork, a background async worker, another DB), leaked becomes non-empty and the test fails spuriously. The other tests key their assertions to a specific worker.threadId(), which is robust; this one is the outlier. Consider filtering the diff to the GAV worker thread(s) specifically, or confirm the suite always runs fork-per-class. Same shared-static concern applies to the two exactly-once contract tests, though those are also thread-id-keyed.

3. Inline rollback latency on the sweep (documented, but worth a second look)

The Javadoc is admirably honest that the unlucky request hitting the 1000th init() now pays the full rollback cost (callbacks, page reloads, lock release) of every abandoned transaction, plus possible nested re-entry. That's a real, if rare, tail-latency regression on a user-facing path. Since a DatabaseAsyncExecutor already exists per-database, did you consider offloading the abandoned-tx rollback to it rather than running it inline on whichever request tripped the counter? Not blocking - just flagging that "safety-net cleanup on a user request thread" is the kind of thing that surprises operators under load.

4. Minor

  • LocalDatabase.closeInternal around line 359 (the open-failure / lock-cleanup path) calls removeAllContexts(databasePath) without rolling back the returned contexts, unlike the main path around line 2113. Pre-existing and out of scope, but with the new "don't prune CONTEXTS entries" behavior it's worth confirming that path can't strand an abandoned tx that now only the periodic sweep will reclaim.
  • The WeakReference allocation is correctly avoided on the hot init() path when the same map is already registered (identity check) - nice attention to GC pressure, in line with the repo's performance mantra.

Tests

Good coverage: dead platform + virtual thread lock release, live-virtual survival, GAV worker unregistration, and two exactly-once contract tests. The red-first evidence in the PR description (4/5 failing against reverted behavior) is exactly what CLAUDE.md asks for. The one gap is that the two pure-race contract tests can't fail deterministically (acknowledged honestly) - they guard the invariant rather than reproduce the bug, which is the best achievable here.

Nice work overall. The main ask is hardening the getRequester() invariant (#1) and tightening the GAV test assertion (#2).

lvca added 2 commits July 6, 2026 22:59
… test, sweep-offload declined, kill() path documented

Review round 4 responses, each point verified against the code first:

1. Taken (structural fix): the mutating lazy getter is split. captureRequester()
   (package-private, so only same-package acquisition code can capture) performs
   the lazy owner-thread capture and is called from exactly the two acquisition
   sites verified in earlier rounds: lockFilesInOrder's tryLockFiles and
   LocalDatabase.executeLockingFiles (which runs on the tx owner thread by
   construction, getTransactionIfExists is thread-local). getRequester() is now
   a PURE volatile read used by every release site (reset, kill, the commit
   unlocks, the failed-attempt unlock inside lockFilesInOrder - all post-capture
   by construction), falling back to Thread.currentThread() only when null,
   which at release time means no file lock was ever acquired, so the fallback
   is inert. An INVARIANT banner on the field documents the two-writer
   discipline (setRequester always wins, capture is owner-only) and why the
   non-atomic check-then-set is safe only under it. The #4959 composition is
   preserved unchanged. Behavior-preserving refactor guarded by the existing
   red-first suite (dead platform/virtual thread lock release); the residual
   lost-update race the old getter allowed (two concurrent first calls) needs
   an in-getter interleaving no test can force deterministically - the split
   removes it by construction.

2. Taken: gavAsyncBuildUnregistersWorkerContext no longer diffs JVM-wide id
   snapshots. GAV workers are named (gav-worker-N factory); the assertion is
   now keyed to that prefix via a new isThreadRegisteredWithNamePrefix test
   hook, replacing the broader registeredThreadIds hook (net test-surface
   reduction). Red-first: with main's GraphAnalyticalView (no unregister
   calls) the keyed assertion fails as expected; green with the fix.

3. Declined (async offload of the sweep rollbacks), reasoning documented in
   the sweep Javadoc: DatabaseAsyncExecutor is per-database and may itself be
   closing or saturated exactly when the sweep runs (the sweep reclaims
   contexts for ANY database, including ones mid-close), and queueing the
   rollback behind a stalled async queue delays the lock release the sweep
   exists to guarantee. The sweep is a rare safety net; revisit only with a
   dedicated JVM-wide cleanup executor if the inline cost shows in practice.

4. Verified and documented: the line-359 removeAllContexts-without-rollback
   site is LocalDatabase.kill(), the TEST-ONLY JVM-crash simulation (not an
   open-failure path). Contexts unlinked there are indeed unreachable to the
   sweep afterwards, but nothing can leak: file locks live in the instance's
   per-database TransactionManager LockManager, which transactionManager.kill()
   closes before the removeAllContexts call, and a reopened database starts
   with a fresh LockManager. Call-site comment added. No live-thread escape:
   a live thread's next operation on the killed database fails or re-inits
   against the new instance.

Also fixed in passing: the sweep's JMM comment still claimed requester is
non-volatile (stale since round 3); reworded to note the dead-owner guarantee
does not depend on the volatile.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: PR #5060 - DatabaseContext lifecycle hardening

Reviewed the diff against main. This is a genuinely high-quality, thoroughly reasoned change - the JMM analysis (JLS 17.4.4 happens-before for the dead-owner path, volatile requester for the live-owner close path), the two-level atomic claim (CONTEXTS entry + per-database key), and the red-first regression evidence are all convincing. The captureRequester()/getRequester() split cleanly resolves the acquisition-vs-release identity problem and composes correctly with the merged #4959 setRequester path. No blocking issues found. Below are observations, mostly minor.

Strengths

Observations / minor points

  1. Inline rollback tail latency (already documented, worth calling out operationally). The sweep now performs real work (tx.rollback() -> page reloads, callbacks, lock release) inline on whichever request hits the 1000th init(). Under a workload that abandons many transactions, this is a latency spike on an unrelated query. The Javadoc documents this and the (reasonable) rejection of async offload. This is the main operational trade-off - fine as a rare safety net, but if abandonment ever becomes common, a dedicated JVM-wide cleanup executor is the escape hatch already noted in the comments.

  2. Ordering inconsistency across the three GAV finally blocks. buildAsync and the rebuild path call removeCurrentThreadContexts() first (before latch.countDown()/taskCompleted()), while the compaction path calls it last (after taskCompleted() and applyDelta(forced)). Both placements are individually justified (the compaction one because applyDelta runs synchronously and can re-register a context on the same thread), but the divergence plus differing comments could confuse a future maintainer. Consider a one-line cross-reference on why the compaction ordering differs - not a correctness issue.

  3. Thread-ID-non-reuse assumption. Several comments lean on "thread ids are never reused" (the unconditional CONTEXTS.remove(threadId()) in removeContext/removeCurrentThreadContexts). This holds on Java 21 HotSpot (monotonic id counter) and the project mandates Java 21+, and the sweep additionally value-keys its removes, so correctness is backstopped there. The owner-side unconditional removes rely purely on non-reuse, but since only the live owner removes under its own id and two live threads can't share an id, the window is safe. Worth being aware this is a HotSpot implementation guarantee, not a JLS one.

  4. Lingering empty CONTEXTS entries for pooled threads. Documented and bounded by live-thread count, so acceptable. For virtual-thread-per-task callers that die without calling removeCurrentThreadContexts(), their transaction contexts (and any pinned Binary buffers) stay strongly-held via CONTEXTS until the next sweep - the GAV fix addresses the known offenders, but the general pattern remains a "safety net catches it eventually" situation. Fine given the sweep, just noting the residual.

  5. Test robustness (not a bug). concurrentSweepsRollBackAbandonedTransactionsExactlyOnce and the close-race test share the JVM-global INIT_CALL_COUNTER; a worker init() could coincidentally trip the 1000-boundary and fire an extra real sweep mid-test. Because the two-level claim guarantees exactly-once regardless of which sweeper wins, the rollbacks == 1 assertions still hold, so this is robust rather than flaky - good.

Style / conventions

  • Follows the repo conventions well: final on params/locals, imported class names (no FQNs), Apache headers, assertThat(...).isXxx() AssertJ style, no leftover System.out. The new tests aren't @Tag-annotated, which is correct since they run in well under a second each.
  • No new dependencies; docs updated in release-26.7.2.md.

Overall: LGTM. The change is careful, correct as far as I can determine, and the multi-round review history is reflected in genuinely improved code rather than just comments.

…ntee provenance

Round 5 found no blocking issues. Two one-line notes taken: the compaction
finally block now states why its unregister ordering deliberately differs
from buildAsync/rebuild (applyDelta runs synchronously after
taskCompleted() and can re-register on the same thread), and the
thread-id-non-reuse comment names its provenance (HotSpot monotonic
counter on Java 21+, an implementation guarantee rather than JLS, with the
sweep's value-keyed removes as the backstop). Points 1, 4 and 5 were the
reviewer's own confirmations of documented trade-offs.

Lifecycle suite green.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Thorough, well-reasoned PR. I traced the requester acquisition/release invariant, the two-level atomic claim in the sweep, and the virtual-thread liveness change end-to-end and the core logic holds up. The red-first test evidence and the design-rationale comments make this easy to verify. A few observations, none blocking.

Strengths

  • Correct root-cause fix for [tx] Dead-thread cleanup discards transactions without rollback; explicit file locks leak forever #4941. Every lock acquisition site now captures the requester on the owner thread (lockFilesInOrder, LocalDatabase.executeLockingFiles -> both tryLockFiles callers), and every release site uses the pure getRequester(). I confirmed these are the only two acquisition sites in engine/ and server/, so the single-writer discipline on requester is actually upheld, not just asserted.
  • Virtual-thread liveness is a real improvement: WeakReference<Thread> + isAlive() is O(1)/safepoint-free and correct for both thread kinds, versus the getAllStackTraces() global-safepoint capture that also silently pruned live virtual threads.
  • Exactly-once rollback via the CONTEXTS-entry claim (remove(key, value)) plus the per-database value-keyed claim against closeInternal removeAllContexts() is the right pattern, and the memory-visibility argument (JLS 17.4.4 for dead owners, volatile for the live-owner close path) is sound.
  • threadId() never being reused holds on Java 21 (unlike the old getId() contract), so the unconditional owner-thread removes in removeContext/removeCurrentThreadContexts are safe as documented.

Observability - worth addressing (medium)
rollbackAbandonedTransactions swallows every Throwable silently (IGNORE ERRORS) and cleanupDeadThreads logs nothing when it finds and rolls back abandoned work. The entire premise of #4941 is that a leak stayed invisible until a restart; a fully silent auto-cleanup risks re-hiding the underlying defect (the caller that leaks threads mid-transaction). Consider a FINE/WARNING log when the sweep rolls back an abandoned transaction, and at least FINE on a swallowed rollback error. This matches the project operator-visibility convention (throttled WARNINGs on anomalous engine conditions in CLAUDE.md) and would let operators diagnose the thread leak rather than just paper over it. DatabaseContext currently has no logging at all.

Test coverage (minor)
concurrentInitAndRemoveAllContextsDoNotCorruptContexts hammers removeAllContexts("not-existing-database-path") - a key that is never present in the owner map - so the concurrent path only exercises CONTEXTS.values() iteration plus a no-op remove(). It never actually races removal of the same database key the owner is inserting/reading, which is the heart of the #4939 fix. Pointing the closer at the owner real database path (or a second disposable database) would exercise the concurrent same-key mutation the contract test claims to cover. As written it is weaker than its comment implies.

Style (minor)

  • Several comments reference PR-review process metadata (round 3, review round 3, SINCE ROUND 3). Valuable now but becomes noise in committed source - consider keeping the invariant text and dropping the review-round chronology before merge.
  • captureRequester() reads the volatile requester twice (the if and the return). Correct under the single-writer discipline, but reading once into a local (as getRequester() already does with captured) is marginally cheaper and mirrors the sibling method.

Question (low)
tx.rollback() runs inline on whichever request thread hit the 1000th init(), and it can be rolling back a transaction for database A while that thread is otherwise operating on database B (reload() etc. touch A structures under A lock manager). Different databases have independent lock managers so a cycle is unlikely, and at the init() entry point the sweeping thread generally holds no page latches yet - but it is worth a sentence in the Javadoc confirming the sweeping thread holds no foreign-database locks when the inline rollback runs, to close the reasoning the way the rest of the method already does so well.

Nice work - the fix is correct and the documentation of the concurrency reasoning is exemplary.

…read in captureRequester

The round independently confirmed the acquisition-site completeness across
engine/ and server/ (the single-writer discipline is upheld, not just
asserted) and found nothing blocking. Both nits taken as asked before
merge: comments keep their invariant substance but drop the review-round
chronology, and captureRequester reads the volatile once into a local,
mirroring getRequester.

Lifecycle 7/7, explicit locking, tx cleanups green.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Thorough, well-reasoned PR. The three findings are distinct and each fix is tight, the concurrency reasoning is documented at the source (not just the PR body), and the regression suite is genuinely red-first for the deterministic cases (2x LockTimeoutException, live-vthread prune, GAV worker leak) with honestly-labeled contract tests for the pure data races. The two-level atomic claim (CONTEXTS.remove(id, ctx) + contexts.remove(dbKey, tl)) closing both sweeper-vs-sweeper and sweeper-vs-closeInternal double-rollback is the right shape, and the lazy captureRequester() / pure getRequester() split correctly enables acquire-on-owner / release-on-sweeper.

A few observations, mostly minor / for awareness:

1. Exactly-once rests on thread-id non-reuse (correctness dependency).
The keyed CONTEXTS.remove(key, value) claim and the removeContext()/removeCurrentThreadContexts() unconditional removes are all justified by "THREAD IDS ARE NEVER REUSED". That holds on JDK 21 (Thread.threadId() is backed by a monotonic counter, unlike the old getId() which explicitly permitted reuse), so it is correct here - but the whole exactly-once argument silently depends on it. Worth a one-line note pinning the guarantee to threadId() semantics so a future JDK-version bump or a getId() refactor does not quietly break it.

2. requester is now volatile, read on the commit unlock hot path.
getRequester() is called at four unlock sites in TransactionContext (reset/kill/commit unlocks). Making the field volatile adds an acquiring load to each on every commit that touched locks. It is a plain load on x86/AArch64 and the write happens once, so the cost is negligible - flagging only because of the project performance mantra and because the volatile is needed solely for the live-owner close path (the dead-owner path already gets happens-before from isAlive()==false). No action needed, just confirming the tradeoff is intentional.

3. The inline sweep rollback is JVM-wide across databases.
CONTEXTS is keyed by thread id and holds every database per-thread map, so cleanupDeadThreads() triggered by the 1000th init() on database A can roll back abandoned transactions belonging to database B on the unlucky request thread. This is inherent and the offload-to-async rejection is well argued, but from an operability angle it may be worth emitting a throttled log/metric when the sweep actually performs non-trivial rollback work, so a tail-latency blip is attributable rather than mysterious. (Consistent with the pool-saturation throttled-WARNING pattern the codebase already uses.)

4. Test isolation - sweepAndDatabaseCloseRollBackAbandonedTransactionsExactlyOnce.
The simulated closer calls removeAllContexts(internal.getDatabasePath()) on the shared live test database, 100 rounds. That also strips the main thread (and any live worker) real context for that DB each round; it works because contexts re-register lazily, but it reaches into the shared global registry. A dedicated throwaway database path for the workers would isolate the test from the live instance.

5. concurrentInitAndRemoveAllContextsDoNotCorruptContexts exercises iteration, not same-key removal.
The close side hammers removeAllContexts("not-existing-database-path"), which never matches, so contexts.remove(name) always returns null - the test races the owner put against concurrent CONTEXTS.values() iteration but never against a real remove(sameKey) on the owner own map. Since the map is a ConcurrentHashMap now that is safe regardless, but the contract would be demonstrated more strongly by racing removal of a genuinely-present foreign db key on the owner map.

Minor: the owner == null (GC-cleared weak ref) branch is honestly documented as lacking the formal JLS 17.4.4 happens-before that the isAlive()==false branch has - good call-out; no change needed since a collected Thread is necessarily long-terminated.

Nothing here is blocking. The core fixes look correct and the coverage is strong.

…, contract tests isolated from the shared db

Review round 7 responses:

1. Taken (asked twice, now actually in the code): the thread-id non-reuse
   guarantee the exactly-once arguments rest on is pinned at the CONTEXTS
   declaration - on JDK 21+ Thread.threadId() is generated at thread creation
   and never reused, unlike the old Thread.getId() whose contract explicitly
   permitted reuse after termination; do not refactor to getId() or any
   reusable key, re-verify on JDK major bumps. The three dependent comments
   (sweep keyed claim, removeContext, removeCurrentThreadContexts) now
   reference that single provenance note instead of repeating a bare 'never
   reused' assertion.

2. Taken (operability): a sweep that performs real rollback work emits ONE
   WARNING summarizing rolled-back transaction count, dead thread ids and
   databases, so the inline tail-latency blip on the triggering thread is
   attributable; zero-work sweeps (the common case) stay silent and allocate
   nothing. rollbackAbandonedTransactions now returns the active-rollback
   count to feed the summary. Red-first: the new
   sweepEmitsSingleSummaryWarningWhenItRollsBackWork test (captures the
   DatabaseContext JUL logger; the shared test config caps com.arcadedb at
   SEVERE, so the test raises that one logger to WARNING) fails against the
   previous code and passes with the change, covering both the silent
   zero-work sweep and the single-summary contract.

3. Taken (test hardening): sweepAndDatabaseCloseRollBackAbandonedTransactions-
   ExactlyOnce now runs its simulated close against a dedicated throwaway
   database instead of stripping the shared live test database's registry
   entry every round; concurrentInitAndRemoveAllContextsDoNotCorruptContexts
   now races removeAllContexts on a GENUINELY REGISTERED foreign database key
   (re-registered by the owner every iteration), so the same-key remove-vs-put
   contract on the owner's map is actually exercised - the old never-existing
   name only exercised the CONTEXTS iteration.

4. Acknowledged, no change: the volatile requester read on the unlock paths is
   an intentional tradeoff (plain load on x86/AArch64, needed for the
   live-owner close visibility, negligible next to the unlock itself), and the
   owner == null weak-ref branch's informal-but-sound reasoning stands as
   documented.
@codacy-production

codacy-production Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 high

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
ErrorProne 1 high

View in Codacy

🟢 Metrics 5 complexity

Metric Results
Complexity 5

View in Codacy

🟢 Coverage 94.29% diff coverage · -7.26% coverage variation

Metric Results
Coverage variation ✅ -7.26% coverage variation
Diff coverage ✅ 94.29% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (813cf4b) 135246 100868 74.58%
Head commit (c3761d4) 167098 (+31852) 112500 (+11632) 67.33% (-7.26%)

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 (#5060) 70 66 94.29%

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.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code review: DatabaseContext lifecycle (#4939 / #4941 / #4956)

Reviewed the full diff against the current sources. This is a careful, well-reasoned change with excellent root-cause write-ups and strong red-first regression evidence. The core mechanics check out. A few notes below, one of which is worth acting on.

What works well

  • Virtual-thread liveness fix is correct. Replacing Thread.getAllStackTraces() (platform-threads-only, global safepoint) with a per-entry WeakReference<Thread> + isAlive() is the right call: O(entries), safepoint-free, and correct for both thread kinds. A live thread is a GC root, so owner == null genuinely implies a terminated thread; the weak ref also avoids pinning dead threads/stacks. Good.
  • Exactly-once rollback design is sound. The two-level atomic claim (CONTEXTS.remove(key, threadContexts) for the sweeper-vs-sweeper race, then per-database value-keyed contexts.remove(dbKey, tl) for the sweep-vs-closeInternal race) correctly guarantees each abandoned tx is rolled back by exactly one path. The two contract tests exercise both races.
  • volatile requester + happens-before reasoning is correct. Dead-owner reads get visibility from isAlive()==false (JLS 17.4.4); the volatile covers the live-owner closeInternal path where no termination edge exists. Splitting captureRequester() (acquisition-only, owner thread) from a pure getRequester() (release, any thread) is a clean way to enforce the single-writer invariant that keeps LockManager unlock keyed consistently.
  • init() hot path is actually cheaper now. The old code did an unconditional CONTEXTS.put(...) on every init(); the new code does a get() + identity compare and only puts (and allocates the WeakReference) when registration is missing/stale. Net win on the steady-state path.
  • removeAllContexts no longer pruning foreign registry entries correctly closes the live-context-unregistration race, and switching the per-thread map to ConcurrentHashMap fixes the real [tx] Cross-thread mutation of non-thread-safe transaction state on close/kill #4939 data race. The bounded "empty entry lingers until owner touches the API or dies" tradeoff is reasonable and documented.

Worth acting on

  1. Swallowed rollback exceptions defeat the very goal of the fix and leave no trace. In rollbackAbandonedTransactions, a tx.rollback() that throws is silently ignored:
    } catch (final Throwable e) {
      // IGNORE ERRORS: BEST-EFFORT CLEANUP OF TRANSACTIONS ABANDONED BY A DEAD THREAD
    }
    If the rollback throws before releasing the file locks, the exact lock leak [tx] Dead-thread cleanup discards transactions without rollback; explicit file locks leak forever #4941 fixes persists, and now it is invisible (the success-path WARNING was added, but the failure path stays silent). Consider a throttled FINE/WARNING log of the exception here so a stuck lock is diagnosable instead of mysterious. Best-effort semantics can stay; just don't discard the signal.

Minor / optional

  1. Exactly-once correctness leans on Thread.threadId() never being reused. The sweep's claim is defended by value-keyed removes, but removeContext() and removeCurrentThreadContexts() use unconditional CONTEXTS.remove(threadId()) and rely purely on non-reuse. The threadId() javadoc guarantees uniqueness "during its lifetime" and the OpenJDK impl uses a monotonic counter, but the spec wording is arguably weaker than "never reused." The prominent provenance comment + "re-verify on JDK major bumps" note is prudent; just flagging that these two paths have no defense-in-depth if that assumption ever breaks, unlike the sweep.
  2. Comment volume. The inline block comments are exceptionally long (several are multi-paragraph design essays). They match the codebase's ALL-CAPS style, but some of the round-by-round reasoning ("since round 3", "fix(#4939,#4941,#4956): DatabaseContext lifecycle - abandoned-tx rollback on dead-thread sweep, virtual-thread-correct liveness, thread-safe context maps #5060 review") reads more like PR/design-doc history than durable code documentation and will age oddly in the source. Consider trimming the historical narration and keeping the invariant statements. Non-blocking.
  3. sweepEmitsSingleSummaryWarningWhenItRollsBackWork handler isolation. The test attaches a JUL handler to the DatabaseContext logger and asserts hasSize(1). If any other thread's sweep logs a WARNING to that logger concurrently during the test, the count assertion could flake. Low risk in practice (restored in finally, filtered to WARNING), just noting.

Test coverage

Strong. Red-first evidence for the deterministic findings (2x LockTimeoutException lock leak, live-vthread pruning, GAV worker leak), honest labeling of the two data-race contract tests as non-deterministic, and both exactly-once races covered. The CountingTransaction stub keeps the concurrency tests off real storage. Nice.

Overall: solid, safe to merge after considering item (1). Nice work.

lvca added 2 commits July 6, 2026 23:48
…immed

The round's actionable point was exact: a tx.rollback() throwing inside
rollbackAbandonedTransactions was swallowed with a bare IGNORE comment -
but a failed rollback means the abandoned transaction's file locks may
STILL be held, the precise leak this sweep exists to fix, and the operator
had no trace of it. Each failure now logs a WARNING with the cause, the
thread id and the database; the sweep continues with the remaining
transactions (best-effort preserved, silence removed).

Point 2 (threadId() spec wording weaker than 'never reused' for the two
owner-side unconditional removes) stays as flagged residual: the
provenance note carries the re-verify-on-JDK-bump instruction and the
sweep path has value-keyed defense-in-depth; adding a get-then-compare to
the owner paths would buy no correctness under the current guarantee.

Point 3: the round-history narration ('review round N') is trimmed from
the comments, invariant statements kept.

Lifecycle 8/8, explicit locking, tx cleanups green.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: DatabaseContext lifecycle hardening (issues 4939, 4941, 4956)

Reviewed the full combined diff across the 9 commits. This is a high-quality, thoroughly reasoned change - the invariant comments, red-first regression suite, and the exactly-once claim design are exemplary. The core fixes are correct:

  • Issue 4941 (leaked explicit locks): capturing the lock requester once at acquisition time (captureRequester()) and keeping getRequester() a pure read is the right shape. I verified every acquisition site now captures (lockFilesInOrder at TransactionContext.java:1183, LocalDatabase.executeLockingFiles:1824, and the explicit-lock path explicitLock() -> lockFilesInOrder) while every release site (reset, kill, commit unlocks at lines 558/764/818/1055/1197) uses the pure read. Consistent, so cross-thread unlock by the sweep works.
  • Issue 4956 (virtual-thread liveness): WeakReference<Thread> + isAlive() is correct and safepoint-free, and the GAV workers are genuinely per-task virtual threads (Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("gav-worker-", 0))), so removeCurrentThreadContexts() in the finally blocks is safe - no pooled-thread context stripping.
  • Issue 4939 (map race): ConcurrentHashMap per-thread map plus not pruning foreign entries from the closing thread is the right call.

Points worth a second look (none blocking)

  1. Inline rollback on the init() hot path. cleanupDeadThreads() now runs full transaction rollback (callbacks, page reloads, lock release) synchronously on whichever request lands on the 1000th init(). This is well documented and the offload-to-async rejection rationale is sound, but note it can compound: a rollback re-enters init(), which on its own 1000th call triggers a nested sweep. The atomic claim makes that safe for correctness, but in a pathological state (many dead threads each holding heavy abandoned txs) the tail-latency blip could stack. The single summary WARNING makes it attributable, which is the right mitigation. Fine to ship; just flagging the worst case.

  2. Empty-entry retention in removeAllContexts(). Now that the closing thread never prunes CONTEXTS entries, an empty ThreadContexts lingers for any live thread that opened+closed a DB, until that thread next touches the context API or dies. On a server with a large, long-lived platform-thread pool (e.g. the ~500 Undertow workers) that repeatedly opens/closes databases, these empty-map entries accumulate until the owning thread is reused for another context op. Bounded by live-thread count as documented, so not a leak of concern, but on high-thread-count servers with frequent DB open/close churn it is worth being aware of. Consider whether the owner own init() fast-path (which already re-registers) could opportunistically drop a now-empty foreign-DB submap.

  3. requester volatile read on every unlock. Making the field volatile is correct for the live-owner close path; the extra read cost on the release paths is negligible. No action needed, noting for completeness.

Test coverage

Excellent. The red-first evidence, the exactly-once contract tests (concurrentSweepsRollBackAbandonedTransactionsExactlyOnce, sweepAndDatabaseCloseRollBackAbandonedTransactionsExactlyOnce) with the CountingTransaction stub, the operability WARNING assertion, and the concurrent-init hammer test cover the concurrency contracts well. Keying the GAV assertion to the gav-worker- name prefix instead of a JVM-wide snapshot diff is a nice robustness touch. These are concurrency/timing-heavy tests - consider @Tag("slow") if any (e.g. the 100-round close-race test) push CI wall-clock noticeably, per the repo benchmark/slow tagging convention.

Overall: correct, carefully bounded, and well tested. LGTM with the two operational notes above as follow-up considerations rather than blockers.

Reviewed by Claude Code

@lvca
lvca merged commit 58c266d into main Jul 7, 2026
13 of 15 checks passed
@lvca
lvca deleted the fix/4939-4941-4956-dbcontext-lifecycle branch July 7, 2026 03:57
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: DatabaseContext lifecycle hardening (#4939, #4941, #4956)

Reviewed the full diff against the current source. This is a high-quality, carefully-reasoned change: the three findings are real, the fixes are minimal and correct, and the invariants are documented at exactly the places a future maintainer would need them (the threadId() non-reuse provenance note, the requester single-writer discipline, the two-level atomic-claim exactly-once argument). Verified the surrounding call sites myself and they hold up. A few observations below, mostly minor.

Correctness - looks solid

  • captureRequester() vs getRequester() split is correct. All lock acquisition paths (lockFilesInOrder -> tryLockFiles at TransactionContext.java:1183, and LocalDatabase.executeLockingFiles:1824) now capture; all release paths (reset(), the commit unlocks at :558/:764/:818/:1055/:1060/:1197) use the pure read. explicitLockedFiles = lockFilesInOrder(...) at :1131 also routes through captureRequester(), so acquireLock().type(...).lock() correctly pins the requester - which is what lets the dead-thread sweep release those locks cross-thread. rollback() -> reset() releases with getRequester(), so the sweep's tx.rollback() unlocks with the captured identity. Chain verified end-to-end.
  • volatile requester is justified: the live-owner close path has no isAlive() happens-before edge, so a plain read could re-capture the wrong thread. The single-writer discipline (write only via setRequester/captureRequester, both on the owner) makes the non-atomic check-then-set safe. setRequester (session id, from PostBeginHandler:71) always wins because captureRequester only writes when null.
  • Two-level atomic claim (CONTEXTS.remove(key, threadContexts) then per-db contexts.remove(k, v)) gives exactly-once against both concurrent sweepers and closeInternal's removeAllContexts(). The sweep removes the CONTEXTS entry before the closer's weakly-consistent iterator can act on it, and the value-keyed inner remove resolves any residual overlap. Confirmed.
  • GAV workers: the executor is newThreadPerTaskExecutor(Thread.ofVirtual()...), i.e. a fresh virtual thread that dies after each task, so removeCurrentThreadContexts() in the finally blocks is safe (no pooled-thread context wipe). The applyDelta ordering note (unregister last, because applyDelta(forced) can re-touch the DB) is a good catch.

Minor observations

  1. rollbackAbandonedTransactions catches Throwable (DatabaseContext.java:329). This swallows Errors (OOM, StackOverflow) and continues the loop. Defensible for a best-effort safety-net that logs every failure, but the existing init() rollback (lines 100-104) only catches Exception. Worth confirming the widening to Throwable is intentional - continuing a sweep after an OutOfMemoryError is questionable, though logging + proceeding beats leaking the lock.
  2. sweepEmitsSingleSummaryWarningWhenItRollsBackWork relies on JVM-global static state. CONTEXTS and INIT_CALL_COUNTER are static and (with reuseForks=true) persist across test classes in one fork. The test drains dead entries first, but assertThat(warnings).hasSize(1) could in principle catch a second WARNING if a background daemon (async executor, or a stray init crossing the 1000-boundary) triggers a real-work sweep during the window. Low risk given forkCount=1 runs classes sequentially, but it is the one assertion in the suite that could flake. The isThreadRegisteredWithNamePrefix approach for the GAV test nicely sidesteps the analogous problem elsewhere.
  3. Comment density is very high (large uppercase block comments). It matches this file's existing style and the reasoning is genuinely load-bearing, so no change requested - just noting that some multi-paragraph javadoc (e.g. the cleanupDeadThreads "offloading was considered and rejected" paragraph) reads more like a design-doc entry than API docs. Fine to keep given the audit context.

Performance

  • init() hot path is actually improved: the old unconditional CONTEXTS.put(...) is replaced by a get() + identity check that skips the put and the ThreadContexts/WeakReference allocation when already registered. The volatile requester read/write is on lock acquire/release, not per-record, so negligible.
  • The sweep is O(entries) with a cheap isAlive() per entry instead of the old global-safepoint getAllStackTraces() - a clear win that also fixes the virtual-thread correctness bug.

Test coverage

Excellent - red-first regression suite covering platform + virtual dead-thread lock release, live-virtual-thread survival, GAV worker unregistration, both exactly-once contract races, the #4939 data-race contract, and the operability WARNING. The honesty about which tests are deterministic repros vs. contract tests is appreciated.

Overall: LGTM. The catch (Throwable) widening and the single global-state assertion are the only things I'd consider tightening, and neither is blocking.

hoogenbj pushed a commit to Locstat/arcadedb that referenced this pull request Jul 7, 2026
…try, page-stats math, LSM counter drift, context prune)

Four small residuals collected from the 2026-07 audit follow-up PRs
(ArcadeData#5060-ArcadeData#5063), plus a file-enumeration audit reported on the issue.

1. TransactionContext.kill() lock symmetry: kill() (test-only API) released
   explicitLockedFiles but nulled lockedFiles WITHOUT unlocking, so killing a
   transaction caught in COMMIT_1ST_PHASE (where lockFilesInOrder populates
   lockedFiles) leaked the LockManager entries until TransactionManager.kill()
   closed the whole lock manager. Fix mirrors reset()'s release block, using
   the pure getRequester() like every other release path.
   Test (red-first): Issue5067KillLockSymmetryTest drives commit1stPhase(true),
   kills the tx and asserts a foreign requester can lock the bucket file
   (returned NO on timeout before the fix).

2. LocalBucket.updatePageStatistics free-space math: the threshold denominator
   was getPageSize() - contentHeaderSize, the same header overstatement ArcadeData#4958
   fixed in gatherPageStatistics. Both now measure against the usable content
   region (physical size minus BasePage.PAGE_HEADER_SIZE minus the content
   header). Only skews the GATHER_STATS_MIN_SPACE_PERC threshold by the 8-byte
   header, no deterministic public observation point; covered by the existing
   bucket/stats suites.

3. LSMTreeIndexMutable currentMutablePages drift on rollback: createNewPage()
   increments the counter eagerly inside the transaction, so a rollback (e.g.
   a commit failed in 1st phase after applying index changes) discards the
   pages but not the increments; the counter drifted upward for the rest of
   the run (self-correcting only on reload after ArcadeData#5063's onAfterLoad fix) and
   inflated the auto-compaction scheduling check. onAfterCommit now clamps the
   counter to the real page count (the transaction's page counters are already
   published at that point), keeping the drift from scheduling compaction
   early without touching the rollback path.
   Test (red-first): Issue5067MutablePagesCounterDriftTest creates index pages
   via direct mutable-index puts in a rolled-back tx (counter 9 vs 1 real
   page before the fix) and asserts a subsequent commit re-aligns it.

4. DatabaseContext empty-entry retention (appended on the issue from PR ArcadeData#5060
   final review): since the closing thread no longer prunes foreign CONTEXTS
   entries (that removal raced the owner's init() re-registration, the ArcadeData#4939
   fix), an empty per-thread entry lingered for any live thread that
   opened+closed a database, accumulating on large long-lived pools (~500
   Undertow workers) under open/close churn. The periodic sweep (invoked from
   init() every 1000th call) now opportunistically drops live threads' empty
   entries with a claim/re-check protocol: value-keyed remove of the exact
   entry, re-check emptiness, putIfAbsent the same entry back if the owner
   repopulated concurrently. Ordering argument documented inline: the owner's
   map.put always precedes its CONTEXTS registration check in init(), so
   either the re-check sees the repopulation and restores, or the owner sees
   the removal and re-registers; no live registration can be lost. No
   rollbacks run in the new branch, so the two-level claim discipline is
   untouched; the prune is allocation-free.
   Test (red-first): DatabaseContextLifecycleTest#
   sweepPrunesEmptyEntryOfLiveThreadAfterForeignClose, including owner
   re-registration after the prune.

5. Node-local file-enumeration audit (.wal.corrupt evidence files, report
   only): FileManager registration (extension allowlist), full backup and
   snapshot serving (registered ComponentFiles + explicit .ts.sealed), WAL
   adoption (endsWith(".wal") cannot match ".wal.corrupt"), fingerprint
   (suffix allowlist) and the ha-raft checksum (skip-list already extended in
   ArcadeData#4958) are all safe; no code changes needed. Details on the issue.

Tests: the three new red-first tests plus ACIDTransactionTest,
ExplicitLockingTransactionTest, LockTimeoutRetryTest, DatabaseContextLifecycleTest
(9/9), Issue4511/4547 lock-leak tests, TransactionContextRemoveFileTest,
ProtocolContextTest, Issue4958StorageStatsTest, LocalBucketRecordPositionTest,
Issue4960 LSM counter/close tests, ConcurrentCompactionTest,
MultipleDatabasesTest - all green.
oc007us pushed a commit to oc007us/arcadedb-grpc that referenced this pull request Jul 8, 2026
…caffolding trim

Two deferred items from the audit's final review rounds:

- The invalid-MAX_PAGE_RAM ConfigurationException printed the byte value
  with an 'MB' suffix (maxRAM is already multiplied by 1024*1024 when the
  message is built); it now divides back so a bad value reads in the unit
  it names.
- The review-round chronology left in comments across the audit-touched
  files ('#NNNN review round N', 'taken after three rounds', ...) is
  trimmed to plain issue references - the invariant statements stay, the
  PR-history narration goes, as the final ArcadeData#5063/ArcadeData#5060/ArcadeData#5070 rounds asked.

No behavior change beyond the message unit. Lifecycle, commit-ordering,
context-lifecycle and flush-robustness suites green.
robfrank pushed a commit that referenced this pull request Aug 14, 2026
Review round 1 responses, verified against the merged branch state:

- Taken (upgraded from the reviewer's 'consider a reentrancy guard'): pure
  re-entrancy was already safe (the entry is unlinked before the rollback loop
  and the CONTEXTS traversal is weakly consistent), but verifying it surfaced a
  real race: two threads landing on the periodic init() boundary together both
  sweep, and values().iterator() gives no atomic claim, so both could roll back
  the same abandoned transaction (double unlock, concurrent record reloads on
  shared caches). cleanupDeadThreads() now claims each dead entry atomically
  via CONTEXTS.remove(key, value) (thread ids are never reused) and only the
  claimant rolls back. Red-first: the new
  concurrentSweepsRollBackAbandonedTransactionsExactlyOnce contract test
  observed a double rollback on its first run against the old code.
- Taken (doc): JMM rationale on the requester read across the dead-thread
  boundary - the isAlive() == false check synchronizes-with the dead owner's
  final action (JLS 17.4.4), so the sweeper sees the non-volatile requester
  write; comment warns against replacing it with a bare staleness check.
- Taken (doc): cleanupDeadThreads() Javadoc now states the rollbacks run
  inline on the triggering thread (tail-latency note for operators) and why
  the re-entrant path is safe.
- Taken (comment accuracy): removeAllContexts() no longer over-promises -
  an empty entry lingers until the owner touches the context API again or
  dies; tiny and bounded by live-thread count.
- Acknowledged, no change: lazy getRequester() seeding order at unlock sites,
  skip-inactive sweep optimization, and test-hook surface are as analyzed.

(cherry picked from commit 6f17e52)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…e rollback

Review round 2 responses, verified against LocalDatabase.closeInternal:

- Taken (real fix, reviewer's preferred resolution): the round-1 atomic claim
  covered sweeper-vs-sweeper only. closeInternal claims each tl of the closing
  database via removeAllContexts()'s remove(databaseName) and rolls it back
  under the DB write lock, while the sweep reached the same tl through
  threadContexts.contexts.values() with no claim and no DB lock - both paths
  could hold the same tl and concurrently roll back / mutate the plain
  ArrayList. The sweep now additionally claims each per-database context with
  a value-keyed remove (contexts.remove(dbKey, tl)); removeAllContexts()'s
  keyed remove already acts as the claim on the close side, so exactly one
  path wins per tl with no new locks. rollbackAbandonedTransactions() Javadoc
  updated: exclusivity now rests on the two-level claim, not just owner death.
- Test: sweepAndDatabaseCloseRollBackAbandonedTransactionsExactlyOnce,
  a 100-round hammer racing cleanupDeadThreads() against a faithful
  closeInternal simulation (removeAllContexts + rollback of returned
  contexts). Honest labeling: the pre-fix window is a sub-microsecond
  iteration crossover (both paths traverse CONTEXTS in the same order) and
  never went red locally even hammered (300+ rounds); it is an exactly-once
  contract test - post-fix the invariant is guaranteed by construction.
  CountingTransaction stub factored out and shared with the round-1 test.
- Taken (minor, documented rather than changed): removeContext()'s and
  removeCurrentThreadContexts()'s unconditional CONTEXTS.remove is safe
  because it runs on the owner thread, thread ids are never reused, only the
  owner registers under its own id and the sweep only claims dead threads;
  value-keying would add a CONTEXTS.get for no correctness gain. Comments
  added at both sites.
- Acknowledged, no change: inline-rollback tail latency already documented in
  round 1; benign worst-case analysis of the race (LockManager tolerates the
  repeated release) matches ours, fixed anyway as the same defect class.

(cherry picked from commit ba089ef)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…tee to live-owner closes

The round's JMM analysis was exact: the dead-owner path is covered by the
isAlive() happens-before (JLS 17.4.4), but closeInternal can roll back a
LIVE owner's context, where no such edge existed - the closer could read a
stale null requester, re-capture itself and key the unlock wrong, leaking
the very locks #4941 fixes. Not a regression (the old per-call
Thread.currentThread() was unconditionally wrong there), but the reviewer's
cheap closing move is right: requester is volatile, so a live owner's lazy
capture (written at lock-acquisition time, well before any close) is
visible to the closing thread. Reasoning documented on the field.

Also taken: the class doc now notes that a rollback can re-enter init() and
trigger a NESTED full sweep on the 1000th call - safe for the same reasons
(weakly-consistent traversal + the atomic claim), documented so the
recursion is expected rather than surprising.

Acknowledged: the exactly-once tests are contract assertions, not race
detectors - already labeled as such in round 2.

Focused suites green: lifecycle 7/7, explicit locking, tx cleanups, lock
retry.

(cherry picked from commit 7b2d48a)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
… test, sweep-offload declined, kill() path documented

Review round 4 responses, each point verified against the code first:

1. Taken (structural fix): the mutating lazy getter is split. captureRequester()
   (package-private, so only same-package acquisition code can capture) performs
   the lazy owner-thread capture and is called from exactly the two acquisition
   sites verified in earlier rounds: lockFilesInOrder's tryLockFiles and
   LocalDatabase.executeLockingFiles (which runs on the tx owner thread by
   construction, getTransactionIfExists is thread-local). getRequester() is now
   a PURE volatile read used by every release site (reset, kill, the commit
   unlocks, the failed-attempt unlock inside lockFilesInOrder - all post-capture
   by construction), falling back to Thread.currentThread() only when null,
   which at release time means no file lock was ever acquired, so the fallback
   is inert. An INVARIANT banner on the field documents the two-writer
   discipline (setRequester always wins, capture is owner-only) and why the
   non-atomic check-then-set is safe only under it. The #4959 composition is
   preserved unchanged. Behavior-preserving refactor guarded by the existing
   red-first suite (dead platform/virtual thread lock release); the residual
   lost-update race the old getter allowed (two concurrent first calls) needs
   an in-getter interleaving no test can force deterministically - the split
   removes it by construction.

2. Taken: gavAsyncBuildUnregistersWorkerContext no longer diffs JVM-wide id
   snapshots. GAV workers are named (gav-worker-N factory); the assertion is
   now keyed to that prefix via a new isThreadRegisteredWithNamePrefix test
   hook, replacing the broader registeredThreadIds hook (net test-surface
   reduction). Red-first: with main's GraphAnalyticalView (no unregister
   calls) the keyed assertion fails as expected; green with the fix.

3. Declined (async offload of the sweep rollbacks), reasoning documented in
   the sweep Javadoc: DatabaseAsyncExecutor is per-database and may itself be
   closing or saturated exactly when the sweep runs (the sweep reclaims
   contexts for ANY database, including ones mid-close), and queueing the
   rollback behind a stalled async queue delays the lock release the sweep
   exists to guarantee. The sweep is a rare safety net; revisit only with a
   dedicated JVM-wide cleanup executor if the inline cost shows in practice.

4. Verified and documented: the line-359 removeAllContexts-without-rollback
   site is LocalDatabase.kill(), the TEST-ONLY JVM-crash simulation (not an
   open-failure path). Contexts unlinked there are indeed unreachable to the
   sweep afterwards, but nothing can leak: file locks live in the instance's
   per-database TransactionManager LockManager, which transactionManager.kill()
   closes before the removeAllContexts call, and a reopened database starts
   with a fresh LockManager. Call-site comment added. No live-thread escape:
   a live thread's next operation on the killed database fails or re-inits
   against the new instance.

Also fixed in passing: the sweep's JMM comment still claimed requester is
non-volatile (stale since round 3); reworded to note the dead-owner guarantee
does not depend on the volatile.

(cherry picked from commit e1b63e9)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…ntee provenance

Round 5 found no blocking issues. Two one-line notes taken: the compaction
finally block now states why its unregister ordering deliberately differs
from buildAsync/rebuild (applyDelta runs synchronously after
taskCompleted() and can re-register on the same thread), and the
thread-id-non-reuse comment names its provenance (HotSpot monotonic
counter on Java 21+, an implementation guarantee rather than JLS, with the
sweep's value-keyed removes as the backstop). Points 1, 4 and 5 were the
reviewer's own confirmations of documented trade-offs.

Lifecycle suite green.

(cherry picked from commit e88ede6)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…read in captureRequester

The round independently confirmed the acquisition-site completeness across
engine/ and server/ (the single-writer discipline is upheld, not just
asserted) and found nothing blocking. Both nits taken as asked before
merge: comments keep their invariant substance but drop the review-round
chronology, and captureRequester reads the volatile once into a local,
mirroring getRequester.

Lifecycle 7/7, explicit locking, tx cleanups green.

(cherry picked from commit e2a691d)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…, contract tests isolated from the shared db

Review round 7 responses:

1. Taken (asked twice, now actually in the code): the thread-id non-reuse
   guarantee the exactly-once arguments rest on is pinned at the CONTEXTS
   declaration - on JDK 21+ Thread.threadId() is generated at thread creation
   and never reused, unlike the old Thread.getId() whose contract explicitly
   permitted reuse after termination; do not refactor to getId() or any
   reusable key, re-verify on JDK major bumps. The three dependent comments
   (sweep keyed claim, removeContext, removeCurrentThreadContexts) now
   reference that single provenance note instead of repeating a bare 'never
   reused' assertion.

2. Taken (operability): a sweep that performs real rollback work emits ONE
   WARNING summarizing rolled-back transaction count, dead thread ids and
   databases, so the inline tail-latency blip on the triggering thread is
   attributable; zero-work sweeps (the common case) stay silent and allocate
   nothing. rollbackAbandonedTransactions now returns the active-rollback
   count to feed the summary. Red-first: the new
   sweepEmitsSingleSummaryWarningWhenItRollsBackWork test (captures the
   DatabaseContext JUL logger; the shared test config caps com.arcadedb at
   SEVERE, so the test raises that one logger to WARNING) fails against the
   previous code and passes with the change, covering both the silent
   zero-work sweep and the single-summary contract.

3. Taken (test hardening): sweepAndDatabaseCloseRollBackAbandonedTransactions-
   ExactlyOnce now runs its simulated close against a dedicated throwaway
   database instead of stripping the shared live test database's registry
   entry every round; concurrentInitAndRemoveAllContextsDoNotCorruptContexts
   now races removeAllContexts on a GENUINELY REGISTERED foreign database key
   (re-registered by the owner every iteration), so the same-key remove-vs-put
   contract on the owner's map is actually exercised - the old never-existing
   name only exercised the CONTEXTS iteration.

4. Acknowledged, no change: the volatile requester read on the unlock paths is
   an intentional tradeoff (plain load on x86/AArch64, needed for the
   live-owner close visibility, negligible next to the unlock itself), and the
   owner == null weak-ref branch's informal-but-sound reasoning stands as
   documented.

(cherry picked from commit 0ba60d1)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…immed

The round's actionable point was exact: a tx.rollback() throwing inside
rollbackAbandonedTransactions was swallowed with a bare IGNORE comment -
but a failed rollback means the abandoned transaction's file locks may
STILL be held, the precise leak this sweep exists to fix, and the operator
had no trace of it. Each failure now logs a WARNING with the cause, the
thread id and the database; the sweep continues with the remaining
transactions (best-effort preserved, silence removed).

Point 2 (threadId() spec wording weaker than 'never reused' for the two
owner-side unconditional removes) stays as flagged residual: the
provenance note carries the re-verify-on-JDK-bump instruction and the
sweep path has value-keyed defense-in-depth; adding a get-then-compare to
the owner paths would buy no correctness under the current guarantee.

Point 3: the round-history narration ('review round N') is trimmed from
the comments, invariant statements kept.

Lifecycle 8/8, explicit locking, tx cleanups green.

(cherry picked from commit c3761d4)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…caffolding trim

Two deferred items from the audit's final review rounds:

- The invalid-MAX_PAGE_RAM ConfigurationException printed the byte value
  with an 'MB' suffix (maxRAM is already multiplied by 1024*1024 when the
  message is built); it now divides back so a bad value reads in the unit
  it names.
- The review-round chronology left in comments across the audit-touched
  files ('#NNNN review round N', 'taken after three rounds', ...) is
  trimmed to plain issue references - the invariant statements stay, the
  PR-history narration goes, as the final #5063/#5060/#5070 rounds asked.

No behavior change beyond the message unit. Lifecycle, commit-ordering,
context-lifecycle and flush-robustness suites green.

(cherry picked from commit 6c70024)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…stats math, LSM counter drift, context prune)

Four small residuals collected from the 2026-07 audit follow-up PRs
(#5060-#5063), plus a file-enumeration audit reported on the issue.

1. TransactionContext.kill() lock symmetry: kill() (test-only API) released
   explicitLockedFiles but nulled lockedFiles WITHOUT unlocking, so killing a
   transaction caught in COMMIT_1ST_PHASE (where lockFilesInOrder populates
   lockedFiles) leaked the LockManager entries until TransactionManager.kill()
   closed the whole lock manager. Fix mirrors reset()'s release block, using
   the pure getRequester() like every other release path.
   Test (red-first): Issue5067KillLockSymmetryTest drives commit1stPhase(true),
   kills the tx and asserts a foreign requester can lock the bucket file
   (returned NO on timeout before the fix).

2. LocalBucket.updatePageStatistics free-space math: the threshold denominator
   was getPageSize() - contentHeaderSize, the same header overstatement #4958
   fixed in gatherPageStatistics. Both now measure against the usable content
   region (physical size minus BasePage.PAGE_HEADER_SIZE minus the content
   header). Only skews the GATHER_STATS_MIN_SPACE_PERC threshold by the 8-byte
   header, no deterministic public observation point; covered by the existing
   bucket/stats suites.

3. LSMTreeIndexMutable currentMutablePages drift on rollback: createNewPage()
   increments the counter eagerly inside the transaction, so a rollback (e.g.
   a commit failed in 1st phase after applying index changes) discards the
   pages but not the increments; the counter drifted upward for the rest of
   the run (self-correcting only on reload after #5063's onAfterLoad fix) and
   inflated the auto-compaction scheduling check. onAfterCommit now clamps the
   counter to the real page count (the transaction's page counters are already
   published at that point), keeping the drift from scheduling compaction
   early without touching the rollback path.
   Test (red-first): Issue5067MutablePagesCounterDriftTest creates index pages
   via direct mutable-index puts in a rolled-back tx (counter 9 vs 1 real
   page before the fix) and asserts a subsequent commit re-aligns it.

4. DatabaseContext empty-entry retention (appended on the issue from PR #5060
   final review): since the closing thread no longer prunes foreign CONTEXTS
   entries (that removal raced the owner's init() re-registration, the #4939
   fix), an empty per-thread entry lingered for any live thread that
   opened+closed a database, accumulating on large long-lived pools (~500
   Undertow workers) under open/close churn. The periodic sweep (invoked from
   init() every 1000th call) now opportunistically drops live threads' empty
   entries with a claim/re-check protocol: value-keyed remove of the exact
   entry, re-check emptiness, putIfAbsent the same entry back if the owner
   repopulated concurrently. Ordering argument documented inline: the owner's
   map.put always precedes its CONTEXTS registration check in init(), so
   either the re-check sees the repopulation and restores, or the owner sees
   the removal and re-registers; no live registration can be lost. No
   rollbacks run in the new branch, so the two-level claim discipline is
   untouched; the prune is allocation-free.
   Test (red-first): DatabaseContextLifecycleTest#
   sweepPrunesEmptyEntryOfLiveThreadAfterForeignClose, including owner
   re-registration after the prune.

5. Node-local file-enumeration audit (.wal.corrupt evidence files, report
   only): FileManager registration (extension allowlist), full backup and
   snapshot serving (registered ComponentFiles + explicit .ts.sealed), WAL
   adoption (endsWith(".wal") cannot match ".wal.corrupt"), fingerprint
   (suffix allowlist) and the ha-raft checksum (skip-list already extended in
   #4958) are all safe; no code changes needed. Details on the issue.

Tests: the three new red-first tests plus ACIDTransactionTest,
ExplicitLockingTransactionTest, LockTimeoutRetryTest, DatabaseContextLifecycleTest
(9/9), Issue4511/4547 lock-leak tests, TransactionContextRemoveFileTest,
ProtocolContextTest, Issue4958StorageStatsTest, LocalBucketRecordPositionTest,
Issue4960 LSM counter/close tests, ConcurrentCompactionTest,
MultipleDatabasesTest - all green.

(cherry picked from commit 55f3980)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant