Don't collect rows a save is still in the middle of writing - #1993
Conversation
ServerDocumentManager.saveBioModel is not one transaction. Each child --
geometry, math description, model, simulation context, simulation -- is
committed by its own DBTopLevel.insertVersionable call, and the
vc_biomodelsimcontext / vc_biomodelsim link rows are written in a later
transaction. For the few hundred milliseconds in between, the child is
committed and referenced by nothing, which is exactly the definition the
cleanup sweep uses to decide a row is garbage.
Prod has lost that race twice in the last two weeks. Both times the
health check's own save was the victim, and both times a cleanup sweep on
a *different* site sharing the same Oracle instance was mid-run:
2026-08-14 20:06:12 INSERT INTO vc_biomodelsimcontext ... 322090117
ORA-02291 (VCELL.SYS_C008211) parent key not found
2026-08-07 03:06:28 INSERT INTO vc_simcontext ...
ORA-02291 (VCELL.SYS_C008289) parent key not found
Issue #1961 is the same race seen from the sweep's side, failing with
ORA-02292 (child record found) when the save wins instead.
Require a row to have been unreferenced for CLEANUP_MIN_AGE_HOURS before
collecting it, on all five deletes and on the report queries that must
stay consistent with them (including the vc_simdelfromdisk insert-select,
which feeds disk cleanup). Garbage an hour old is still garbage.
The guard goes in cleanRemoveUnreferencedSimulations rather than in
getSelectUnreferencedSimKeySQL, which SimulationDispatcher also uses to
abort active jobs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYdenYzMw35USHDQEATGVq
The sweep is assembled by string concatenation and only ever runs in the db service against production Oracle, so a dialect slip in it is invisible until it aborts a whole cleanup run in production. Nothing exercised it. DatabaseCleanupSqlDialectTest (Fast, no database) drives cleanupDatabase() through a Connection that records statements instead of executing them, and holds the invariant that no unreferenced-row delete -- nor the report query or vc_simdelfromdisk hand-off that has to match it -- goes out without the age guard, in either dialect. vc_softwareversion is the documented exception. DatabaseCleanupSqlTest (@QuarkusTest) runs the real sweep against the testcontainers PostgreSQL with the real VCell schema, and asserts the guard's actual behaviour: an orphan two hours old is collected, a row a save is still writing survives. With the guard removed it fails on exactly that second assertion. Oracle was verified by hand against gvenzl/oracle-free 23ai: SELECT (SYSDATE - INTERVAL '1' HOUR) FROM DUAL returns one hour earlier, all 15 generated statements execute, and the old/fresh orphan pair behaves as it does on PostgreSQL. Left out of CI rather than adding a ~2 GB image pull to every run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdenYzMw35USHDQEATGVq
|
Added dialect coverage — the sweep had none.
Oracle was verified by hand against plus all 15 generated Oracle statements executed against a stub schema (no I left the Oracle container out of CI rather than add a ~2 GB image pull to every run — happy to wire it in as a |
The sweep only ever runs against production Oracle, so PostgreSQL coverage in the fast lane leaves the dialect that matters unchecked. Oracle needs its own ~2 GB image, which does not belong on every push -- so it goes where the heavy suites already live: a regression group, run by the merge queue before anything lands and again nightly. DatabaseCleanupOracleSqlTest boots gvenzl/oracle-free (23ai) on a stub schema and asserts three things: every statement cleanupDatabase() issues is accepted by Oracle, SYSDATE - INTERVAL '1' HOUR evaluates to exactly one hour back, and an orphan two hours old is collected while a row a save is still writing is spared. With the guard removed it fails on that last assertion. A stub schema rather than the real one because what is under test is Oracle's acceptance of these statements, not the schema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HYdenYzMw35USHDQEATGVq
|
Oracle is now gated rather than spot-checked.
Verified end to end on this branch — 55s of that is the image pull plus Oracle boot on the runner — the cheapest group in the suite by some margin, and it costs the fast lane nothing. It runs in the merge queue before anything lands, nightly, and is selectable from the "Run workflow" dropdown. Fast lane still green on the same commit: build, Fast-core, Fast-other, Quarkus, CodeQL. Coverage now stands as: PostgreSQL on every push (real schema, real sweep), Oracle on the merge queue and nightly (real Oracle), and a database-free invariant test in |
|
Filed #1994 for the broader version of this: the database layer branches on Two findings from this PR that make it cheaper than it sounds: |
Fixes #1992. Should also close #1961, which is the same race seen from the sweep's side.
The race
DBBackupAndClean.cleanupDatabase— run every 15 minutes byDatabaseServer.DatabaseCleanupThread— collects rows that no document references. But "unreferenced" does not mean "garbage", becauseServerDocumentManager.saveBioModelis not one transaction: each child (geometry, math description, model, simulation context, simulation) is committed by its ownDBTopLevel.insertVersionablecall, and thevc_biomodelsimcontext/vc_biomodelsimlink rows are only written in a later transaction.For the few hundred milliseconds in between, the child is committed and referenced by nothing — indistinguishable, to these queries, from an orphan left behind by a delete. Collecting it there breaks the save in progress with
ORA-02291: parent key not found; when the race goes the other way the sweep aborts instead withORA-02292: child record found(#1961).Prod lost that race twice in the last 14 days of Loki retention, and a cleanup sweep was in flight for both:
The 2026-08-14 one raised the Better Stack
VCell Release Health - Simalert. Both predate or are unrelated to any recent change here —1a27cd646a(8.0.24.01) removed the cross-site amplification by giving prod sole ownership of the sweep, but not the race itself.The change
Require a row to have been unreferenced for
CLEANUP_MIN_AGE_HOURS(1) before collecting it:Applied to all five unreferenced-row deletes and to the report queries that must stay consistent with them — including the
vc_simdelfromdiskinsert-select, which would otherwise queue simulations for disk deletion that theDELETEno longer removes.Two deliberate boundaries:
cleanRemoveUnreferencedSimulations, not in the sharedgetSelectUnreferencedSimKeySQL.SimulationDispatcher:636uses that query to abort active jobs; its behaviour is unchanged.cleanRemoveUnReferencedSotwareVersionsis left alone.vc_softwareversionrows are written in the same transaction as their versionable (insertVersionableInit), so there is no window, and the table has noversionDate.Garbage an hour old is still garbage; a row a few hundred milliseconds old is a save in flight.
Verification
mvn compile -pl vcell-server -amclean.cleanupDatabasethrough ajava.lang.reflect.ProxystubConnectionto capture every statement it emits in both dialects. The guard is present on all five deletes and their matching selects, withSYSDATEfor Oracle andCURRENT_TIMESTAMPfor Postgres.postgres:15-alpineon a stub schema — all accepted.Not verified: execution against Oracle.
SYSDATE - INTERVAL '1' HOURis checked by construction only.🤖 Generated with Claude Code
https://claude.ai/code/session_01HYdenYzMw35USHDQEATGVq