Skip to content

Gemma2 delete experiments fix - #1682

Merged
ppavlidis merged 10 commits into
phase2-acl-migratefrom
gemma2-deleteExperiments-fix
Aug 9, 2026
Merged

Gemma2 delete experiments fix#1682
ppavlidis merged 10 commits into
phase2-acl-migratefrom
gemma2-deleteExperiments-fix

Conversation

@oganm

@oganm oganm commented Aug 8, 2026

Copy link
Copy Markdown
Member

No description provided.

oganm added 10 commits August 4, 2026 22:47
…ackport

AbstractCLI imported
org.ocpsoft.prettytime.shade.edu.emory.mathcs.backport.java.util.Collections --
a JDK 1.4-era backport that prettytime-nlp carries inside its uber-jar, almost
certainly an IDE auto-import picking the wrong candidate. Its emptyList()
returns a raw List, which is why the call site needed a //noinspection
unchecked; with java.util.Collections the inference works and the suppression
goes away.
removeAllSingleCellDataVectors streamed ee.getSingleCellExpressionDataVectors()
to collect the distinct quantitation types, which forced the lazy collection to
initialize. That selects every vector's DATA and DATA_INDICES blob along with
its eager join graph. On GSE277430 -- 25,050 vectors spanning 333,570 cells --
it exhausted a 30 GB heap.

The OutOfMemoryError then aborted mid-resultset and left the connection's
protocol stream desynced, so the subsequent rollback parsed a reply against a
stale buffer and threw ArrayIndexOutOfBoundsException, which Connector/J wrapped
as SQLState S1000. Spring reported that as "Application exception overridden by
rollback exception", discarding the OOM, and the retry advice then re-ran the
delete on the broken connection. The visible symptom was therefore an
unexplained "arraycopy: last source index 16 out of bounds for byte[14]" with no
trace of the real cause.

Resolve the QTs with a projection query when the collection is not already
loaded, exactly as removeAllRawDataVectors has always done. Forcing
initialization also made the Hibernate.isInitialized() check further down
unconditionally true, defeating the eviction guard it fronts.

The collection is still initialized once, by super.remove(ee)'s cascade walk,
but that happens after the bulk delete so it selects zero rows and pulls no
blobs.

Test: removeWithUninitializedSingleCellDataVectors covers the branch that runs
in production, which no existing test reached -- every other fixture here builds
its experiment in-session, so only the already-initialized branch ran and the
new HQL would have gone unvalidated. It asserts through Hibernate statistics
that no vector entity is loaded; an emptiness assertion cannot distinguish the
two paths, since the broken one loads the collection and then clears it.
Verified to fail (1 vector loaded, expected 0) with the fix reverted.
…entity

AclObjectIdentity is @immutable on this branch, so Hibernate silently discards
aoi.setParentObject(...) -- no exception, no UPDATE. All four child-parent lint
fixes recorded fixed=true before any flush, so the linter reported repairs it had
not made. A run against prod on 2026-08-06 reported 548 parent assignments and
wrote none of them: the 16 subsets blocking the deletion of GSE273690 came back
with parent_object still NULL, and the deletion stayed denied.

Route the writes through aclService.readAclById + setParent + updateAcl, the same
sequence BaseAclAdvice uses when it wires a child's parent. That reaches
JdbcMutableAclService, which writes by JDBC and evicts the ACL cache. The two
SecuredNotChild sites were converted earlier for exactly this reason and carry a
javadoc describing the trap; these four were missed.

hotfix-1.32.8 is not affected. It builds against gsec 0.0.22, whose
AclObjectIdentity.hbm.xml declares no mutable="false", so the entity mutation
persists there. The defect arrived with the canonical-schema migration, which
replaced gsec's mutable ACL entity with a JPA one annotated @immutable while
carrying the linter over unchanged.

Not yet verified end to end, and deliberately shipped without a test rather than
with a misleading one. BaseDatabaseTest5 wires AclServiceImpl/AclDaoImpl -- the
Hibernate-backed stack GemmaAclConfiguration describes as "no longer wired in" --
so a test written against that context exercises a different implementation than
the one this fixes, and fails for reasons unrelated to the change. The existing
AclDaoImplParentPersistenceTest covers precisely this invariant and is @disabled
for a wiring reason, which is how the regression reached production. Verification
is a lintAcls --apply-fixes run against the real wiring plus a parent_object check
in acl_object_identity; a meaningful regression test needs the test context to
wire JdbcMutableAclService first.
…n audit

Follow-up measurements on the deletion remediation plan.

The canonical-store drift is not a fixed backlog: re-measured after deleting
GSE306819 and cleaning its ACLs, legacy orphans were 0 while canonical orphans
went 44 -> 46 in ~14 hours, neither of them ours. A phase2 deletion leaks both
stores, so a deletion that cleaned legacy but not canonical can only have come
from 1.32.x. Reconciliation therefore has to handle the continuous case.

New section 4.1 answers the question of whether single-cell components are more
loosely attached than the rest. They are, but the weak edge is backstopped:
everything below a dimension is FK-enforced RESTRICT, and no dimension is
orphaned database-wide. The genuinely weak edge is dimension -> experiment, which
has no FK at all; a surviving dimension is caught by the bio-assay FK and turns
into a loud failure rather than a silent orphan. That surfaced defect 8: phase2
resolves dimensions through the SINGLE_CELL_DIMENSION_EXPERIMENT cache that
1.32.x does not maintain, so 17 named experiments currently cannot be deleted on
this branch, and hotfix-1.32.8's vector-derived query does not have that gap.
…CL check

Defect 9 and a new section 4.2. Deleting GSE273690 was denied before any data was
touched: ExpressionExperimentWriteServiceImpl removes subsets one at a time
through an ACL-secured call, and phase2 had no ACL for the experiment's 16
subsets. That is defect 6 escalating from orphan rows to blocked operations, and
it identifies the risk class -- aggregated or preprocessed datasets, 56 of them
today. Missing analysis ACLs do not block deletion, since analyses are removed by
experiment-scoped calls.

The repair tool then reported 548 parent assignments and wrote none of them,
leaving identities with entries_inheriting=1, no parent and no ACEs, which still
denies. Fixed in cc5b515 and verified: all 16 subsets now carry their parent.

Also recorded why this was invisible to the suite -- every enabled linter test
runs applyFixes(false), the one that applies fixes asserts nothing, and the test
targeting this exact invariant is @disabled -- and that ACL tests exercise
AclServiceImpl/AclDaoImpl while runtime uses JdbcMutableAclService, so a passing
ACL test says little about deployed behaviour.

New section 6.1 gives the pre-deletion check for experiments with subsets, so the
next one does not fail the same way.
AclEventListenerConfig and AuditTrailEventListenerConfig are @configuration
classes that exist only for the side effect in afterPropertiesSet(): registering
Hibernate event listeners. Nothing injects them.

LazyInitByDefaultPostProcessor, registered in CliComponentScanConfig at
HIGHEST_PRECEDENCE, marks every non-infrastructure bean definition lazy-init.
Both configs were therefore defined but never instantiated in CLI contexts, and
their listeners never registered -- with no error and no warning.

ACL maintenance has been off since 21e4fc4 (2026-05-18), which disabled the
AOP advice and handed the job to AclEventListener. The listener covers both
POST_INSERT and POST_DELETE, so both directions were lost:

  - deletes left the whole ACL tree behind (115/342/281 rows removed by hand for
    GSE277430, GSE306819, GSE273690)
  - inserts created no ACL at all: 76 experiments, IDs 92410-93196, contiguous,
    nothing older

The insert side is worse. With no acl_object_identity row hasPermission fails for
every user including admins, and AFTER_ACL_READ_QUIET turns that into a silent
null, so the experiment is indistinguishable from one that does not exist. That
is why viewChangelog could not locate EE 92838, which is present and populated.

AuditTrailEventListenerConfig was dead the same way, and its AuditAdvice
create/delete advices were deleted in the same C-2 cutover to avoid double
emission, so CLI-run work emitted no CREATE/DELETE audit events either.

Scope is CLI-only: the post-processor is registered nowhere else, so the webapp
was always correct.

Fix is @lazy(false) on both -- the opt-out the post-processor is built around,
already covered by LazyInitByDefaultPostProcessorTest.Bean5.
SideEffectConfigurationEagerTest scans both packages for any @configuration
implementing InitializingBean that lacks it; verified to fail when the annotation
is removed from either config.

Backfilling the 76 experiments with lintAcls is still outstanding. The missing
audit events cannot be reconstructed.
…irement

271 objects repaired: 46 dangling identities removed, 76 created, 152 base ACEs
added. All post-conditions verified read-only -- no experiment lacks an ACL, no
dangling EE-class identity or orphaned ACE remains, and none of the 76 was made
public.

Records the trap that -lintPermissions is opt-in while every other check is
hardcoded true, so omitting it creates ACL identities with no entries: access is
not restored, but the identity check reports clean.
During dual-version operation, 1.32.x has no mapping for phase2's canonical
acl_* tables, so everything prod creates, deletes or re-permissions leaves the
canonical store stale. The canonical store was populated once by the schema
migration (~2026-05-15) and has received nothing since.

lintAcls cannot close this. It enforces existence and default ACEs and grants
anonymous READ only for shouldBePublic = {ExternalDatabase, Protocol}, so it
cannot replicate per-dataset public/private status. Measured on 2026-08-08: 186
experiments public on prod but private in canonical, and 78,643 identities
missing across all classes.

Writes are confined to acl_class / acl_sid / acl_object_identity / acl_entry.
The legacy store and all entity tables are read-only to this script. Verified
that hotfix-1.32.8 has no runtime reference to the canonical tables -- the only
hits are comments and a historical migration that dropped their predecessors.

Design notes:
- ACE comparison is set-based over distinct (sid, principal, mask, granting).
  A count-based diff would flag the ~104 identities where legacy carries
  duplicate ACE rows (EE 1 has IS_AUTHENTICATED_ANONYMOUSLY twice, both at
  ACE_ORDER 2) which the migration correctly collapsed; copying them back would
  also violate UNIQUE(acl_object_identity, ace_order).
- ACEs are replaced wholesale, deduplicated and renumbered, since ace_order must
  stay a contiguous 0-based run. Renumbering is order-safe only because both
  stores hold zero deny ACEs; the script aborts if one ever appears.
- ace_order is computed with a correlated COUNT: no window functions on MySQL
  5.7, and session-variable ordering is not contractual.
- INSERT..SELECT statements carry explicit ORDER BY because prod runs
  binlog_format=STATEMENT and the targets are AUTO_INCREMENT.
- Credentials come from MySQL's login-path store; --apply refuses a '-ro' path.

Dry run by default. Delete this script at cutover.
…ntext

AclAdviceTest.testExpressionExperimentAcls already deletes an experiment and
asserts its ACLs are gone, and it passed for the entire three months the listener
was dead in production. It could not have failed: no test context registers
LazyInitByDefaultPostProcessor, so every test context is eager, the listener
configs always instantiate there, and the listeners always register.

The defect was never in the deletion logic. It was in whether the listener was
wired at all, which depends on the shape of the context rather than on any ACL
code. So the gap was not another deletion assertion but the absence of any test
that exercises a lazy-by-default context -- the only shape in which the bug is
observable.

This builds a context the way CliComponentScanConfig does (lazy PP active,
nothing injecting the config) and asserts both listeners still register on their
event types. Verified to fail with "Wanted but not invoked" on all five
registrations when @lazy(false) is removed from either config.

Complements SideEffectConfigurationEagerTest, which guards the annotation
generically for future side-effect configs; this pins the behaviour of the two
that actually broke.
@ppavlidis
ppavlidis merged commit d099066 into phase2-acl-migrate Aug 9, 2026
2 of 3 checks passed
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.

2 participants