Skip to content

Revert "perf(cache): migrate H22 cache async commits to virtual threads" (#35992) - #36900

Merged
fabrizzio-dotCMS merged 2 commits into
mainfrom
issue-36892-revert-h22-virtual-threads
Aug 5, 2026
Merged

Revert "perf(cache): migrate H22 cache async commits to virtual threads" (#35992)#36900
fabrizzio-dotCMS merged 2 commits into
mainfrom
issue-36892-revert-h22-virtual-threads

Conversation

@wezell

@wezell wezell commented Aug 5, 2026

Copy link
Copy Markdown
Member

Reverts #35992.

Original issue: #35991 (Java 25 Performance Improvements)
Startup failure this fixes: #36892

The problem

A customer instance could not complete startup. main parked indefinitely in MVStore.commit under H22Cache.putdoUpsert, driven from InitServlet.init()populateAllVanityURLsCacheESContentFactoryImpl.findContentlets:

"main" ... waiting on condition
   - parking to wait for <0x00000007fe4a3718> (a java.util.concurrent.locks.ReentrantLock$FairSync)
	at org.h2.mvstore.MVStore.commit(MVStore.java:775)
	at org.h2.mvstore.MVStore.beforeWrite(MVStore.java:1273)
	...
	at com.dotmarketing.business.cache.provider.h22.H22Cache.doUpsert(H22Cache.java:650)
	at com.dotmarketing.business.cache.provider.h22.H22Cache.put(H22Cache.java:176)

H22Cache.java:176 is the synchronous branch of put() — the caller was doing the H2 write itself.

Why the migration caused it

#35992 rested on this premise:

JEP 491 means synchronized blocks in Hikari/H2 no longer pin the carrier thread, so the classic "virtual threads + JDBC pinning" concern does not apply.

That is true for synchronized, but it is not the pinning that matters here. Virtual threads do not unmount on file I/O, and embedded H2 is file I/O, not socket I/O. Every commit holding a dbWorkPermits permit pins its carrier for the duration of the MVStore write, so the 5 concurrent writers contend for a carrier pool sized to availableProcessors(). The old model used 5 dedicated platform threads, which are unaffected by carrier availability.

The submission path also changed cost profile: newThreadPerTaskExecutor starts a virtual thread immediately on submit, so a backlogged put became a live thread parked on a semaphore rather than a LinkedBlockingQueue node.

Measurements

3000 upserts of a 4KB payload through 5 concurrent writers, H2 2.2.224, using the same statement shape H22 uses (MERGE INTO … key(cache_id)):

carriers platform pool (old) virtual+semaphore (new) ratio
2 (small container) 7042 puts/sec 20 puts/sec 350x
4 6977 puts/sec 889 puts/sec 7.8x
8 6000 puts/sec 1304 puts/sec 4.6x
24 (default) 7059 puts/sec 1293 puts/sec 5.5x

Platform throughput is flat regardless of carrier count. The virtual-thread path is 5x slower even with 24 carriers, and collapses on a 2-CPU container.

Why that throughput loss becomes a hang

The caller-runs fallback in isAllocationWithinTolerance() is not a regression from #35992 — it dates to e3b58b3694 (2020-09-16) with the same 10000 / 0.98 defaults. It was simply unreachable at ~7000 puts/sec, because the backlog never approached 9800.

At 20–1300 puts/sec it is reached routinely during startup. Once crossed, every caller writes inline, and hundreds of threads queue on H2's single-writer fair ReentrantLock. Throughput drops further, the backlog never drains, and the threshold never clears — it latches rather than sheds.

Scope

  • Pure revert. H22Cache.java is byte-identical to 98f8d66fae^, verified by diff.
  • Nothing landed in the h22 package since the migration, so there was nothing to reconcile.
  • No test changes — h22 unit tests unmodified and passing.

Follow-up (deliberately not in this PR)

Tracked in #36892, kept out to keep the revert clean and reviewable:

  • overflow should shed puts rather than run them on the caller — already safe, since put marks the key in DONT_CACHE_ME before writing and doSelect honors exclude(), so a dropped put reads as a miss
  • deletes must never be shed — a dropped delete resurfaces as stale content once the 20s DONT_CACHE_ME entry expires
  • setQueryTimeout on the fail-safe statements
  • retire cache_h22_async_task_queue / cache_h22_async_tolerance, which no longer describe a real queue

Note for #35991

Any future virtual-thread migration in this codebase should distinguish socket I/O from file I/O. JEP 491 removed synchronized pinning; it did not make file I/O unmount. Workloads that block on the filesystem — embedded H2, local disk, FUSE mounts — still hold their carrier and should stay on platform threads.

Verification

  • ./mvnw test-compile -pl :dotcms-core — clean
  • H22CacheTest — OK (4 tests)
  • Benchmark reproducible standalone against H2 2.2.224

🤖 Generated with Claude Code

This PR fixes: #36892

…ds (#35992)"

This reverts commit 98f8d66.

Virtual threads do not unmount on file I/O, and embedded H2 is file I/O. Each
commit holding a dbWorkPermits permit pins its carrier thread for the duration
of the MVStore write, so the 5 concurrent writers contend for a carrier pool
sized to availableProcessors(). The old model used 5 dedicated platform
threads, which are unaffected by carrier availability.

The submission path also changed cost profile: newThreadPerTaskExecutor starts
a virtual thread immediately on submit, so a backlogged put became a live
thread parked on the semaphore rather than a LinkedBlockingQueue node.

Measured, 3000 upserts of a 4KB payload through 5 concurrent writers against
H2 2.2.224:

  carriers | platform pool | virtual+semaphore | ratio
  ---------+---------------+-------------------+-------
         2 |    7042 p/s   |        20 p/s     |  350x
         4 |    6977 p/s   |       889 p/s     |  7.8x
         8 |    6000 p/s   |      1304 p/s     |  4.6x
        24 |    7059 p/s   |      1293 p/s     |  5.5x

Platform throughput is flat regardless of carrier count; the virtual-thread
path is 5x slower even with 24 carriers and collapses on a 2-CPU container.

That throughput loss is what surfaces the pre-existing caller-runs fallback in
isAllocationWithinTolerance(), which has been in place since 2020 and was
harmless at ~7000 puts/sec. At 20-1300 puts/sec the async backlog reaches the
9800 threshold during startup, callers begin writing inline, and the main
thread parks on the MVStore fair lock — see issue #36892.

Reverting restores the pre-migration behavior exactly; the file is now
byte-identical to 98f8d66^. Hardening the overflow path so it sheds writes
instead of running them on the caller is tracked separately in #36892.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @wezell's task in 1m 46s —— View job


Code Review

Reviewed the pure revert of #35992 plus the new docs. I verified the reverted H22Cache.java against the current tree rather than the pre-migration blob (the migration commit 98f8d66fae isn't present in this shallow CI checkout, so I couldn't independently confirm the byte-identity claim — but the code is internally coherent).

No issues found. This is a clean, well-scoped revert.

What I checked

  • Import hygiene — the migration's Semaphore field (dbWorkPermits), AtomicInteger, RejectedExecutionException, and Executors usages are gone; Semaphore is still imported but legitimately used by building (H22Cache.java:501). The re-added LinkedBlockingQueue, ThreadPoolExecutor, and ThreadFactoryBuilder are all referenced. No dangling or unused imports.
  • Wiring consistencyasyncTaskQueue (H22Cache.java:57) is used by both spawnNewThreadPool() (:117-128) and isAllocationWithinTolerance() (:294), which now reads asyncTaskQueue.size() instead of inFlightTasks.get(). Coherent.
  • Submission pathputAsync/removeAsync (:185, :311) submit directly to the ThreadPoolExecutor again, dropping the submitAsync permit/counter wrapper. Consistent with the pre-migration model.
  • Docsdocs/backend/VIRTUAL_THREADS.md is accurate: socket-vs-file I/O unmounting, carrier pool defaulting to availableProcessors(), and the note that ReentrantLock/Semaphore parks are VT-aware (so the problem is the file syscall, not the lock) are all correct for JEP 491 / Java 25. CLAUDE.md nav link is correctly placed.

One observation (not blocking, and not introduced here)

The restored pool uses an unbounded LinkedBlockingQueue with CallerRunsPolicy (H22Cache.java:119-127). Because the queue is unbounded, the executor never rejects, so CallerRunsPolicy is effectively dead code — the real caller-runs behavior comes from shouldAsync()isAllocationWithinTolerance() running the write inline on the caller (put() sync branch at :175). That inline path on H2's fair single-writer lock is exactly the startup-hang mechanism described in the PR body. This is pre-existing behavior being restored, and the PR explicitly tracks hardening it (shed vs. caller-run, setQueryTimeout, retiring the stale cache_h22_async_* props) in #36892 — correctly kept out to keep the revert reviewable.

The revert restores the known-good behavior; the follow-up scope is the right place for the shedding fix.

· issue-36892-revert-h22-virtual-threads

@wezell wezell added the Team : Platform Platform Team label Aug 5, 2026
@wezell
wezell requested a review from swicken August 5, 2026 19:11
@dotCMS dotCMS deleted a comment from github-actions Bot Aug 5, 2026
Records why the H22 cache migration was reverted so the same reasoning error
does not recur: JEP 491 removed synchronized pinning, but virtual threads
still do not unmount on file I/O, so embedded H2 writers hold their carrier.

Includes the measured throughput table, the carrier-starvation gotcha that
hides on a many-core dev machine, and a pre-migration checklist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the Area : Documentation PR changes documentation files label Aug 5, 2026
@wezell

wezell commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@fabrizzio-dotCMS
fabrizzio-dotCMS added this pull request to the merge queue Aug 5, 2026
@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown

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

  • Queue this pull request

Merged via the queue into main with commit 57ccf6c Aug 5, 2026
71 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the issue-36892-revert-h22-virtual-threads branch August 5, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Documentation PR changes documentation files Team : Platform Platform Team

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

H22 cache sync fallback blocks startup indefinitely on the MVStore fair lock

2 participants