Repository navigation
fix(indexing): hand OpenSearch reindexing to the queue from the JDO transaction's completion callback - #1752
Conversation
…instead of dropping it The queue dedupes by object id, and until now a request for an id whose job was already scheduled or running was silently dropped. That is exactly wrong when the in-flight job loaded the object before the change behind the second request was committed: the dropped request was the one that described the object's final state, and the stale document stuck. addIndexingQueueEntry() now records the newest (class, unindex) for an in-flight id, and every terminal job outcome -- success, genuine failure, retry give-up -- goes through finishIndexingJob(), which runs that remembered request as exactly one follow-up pass while keeping the id queued so concurrent requests still dedupe against it. Hard removal (executor rejection, queue reset) drops the follow-up with the id. Also adds the identity overload addIndexingQueueEntry(String, Class, boolean) for callers that run after their transaction has ended, and names the job Runnable (IndexingJob) so the request it carries is observable in tests. Part of the re-derivation of #1743 on JDO transaction-completion callbacks. Codex (gpt-6-astra) reviewed #1743 and found this bookkeeping sound for synchronous completion; the async deep-index child writes remain out of scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
…anager until its transaction completes postStore fires on JDO flush, not commit. A request raised there is now PARKED in the writing PM's user-object map (PendingBucket) instead of enqueued. The park is released only by the transaction's completion outcome: COMMITTED drains it, ROLLED_BACK drops it, and an outcome we cannot interpret runs index requests (the job re-reads the database, so that is harmless) but never an ambiguous unindex. Nothing else may terminalize a park -- not "commit threw" (DataNucleus leaves the transaction ACTIVE after a NucleusUserException, and the park must stay live with it), not close. Completion installs a fresh park before doing any external work, so the PM's next transaction has somewhere to park however it is begun, and a failure handing one entry to the queue never affects the others. Level-2 eviction uses the DataNucleus internal identity (Persistable.dnGetObjectId), not JDOHelper.getObjectId(): the L2 cache is keyed by the former, so evicting with the javax.jdo.identity object silently misses. And because DataNucleus publishes an object's new state to L2 during preCommit -- before the datastore commit -- the indexing job's reader PM now sets datanucleus.cache.level2.retrieveMode=bypass so it reads the committed row rather than another in-flight transaction's copy. The job's datastore access is behind a small seam (IndexingWork) so a real IndexingJob can run in a unit test and prove it reports every outcome -- success, failure, row-not-visible retry, give-up -- through finishIndexingJob(). Nothing is wired to this yet; the Synchronization that drives completion and the postStore/enqueueAclReindex call-site changes follow in separate commits. Both the identity-key and the preCommit-publication facts come from Codex (gpt-6-astra) reviewing the plan for this change against the pinned DataNucleus 5.2.7 bytecode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
…'s completion callback IndexingTransactionSynchronization is a javax.transaction.Synchronization that DataNucleus invokes from inside Transaction.commit() and rollback(), after its own post-commit work. That callback is the only thing that terminalizes a park: COMMITTED drains it, ROLLEDBACK drops it. Because it hangs off the transaction itself, a raw pm.currentTransaction().commit() is covered exactly like Shepherd.commitDBTransaction(), and Shepherd's commit/rollback/close code is not involved at all -- which is what removes the blast radius the reviewer objected to on #1743. The order inside afterCompletion is fixed: capture and terminalize the park and install its successor first (no external work, cannot throw), then call whatever Synchronization was registered before ours (JDO allows exactly one, so we wrap it; it may close the PM or throw, and neither can lose the snapshot), then drain the snapshot independently of the PM. beforeCompletion propagates the delegate's exception, since that is its chance to veto the commit. IndexingManager.registerSynchronization(pm) installs it once per PM, idempotently. Codex (gpt-6-astra) flagged the delegate-first ordering in the original plan as a way to lose the park; this is the corrected order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
…on once per PersistenceManager, before begin beginDBTransaction() is the only Shepherd lifecycle method this change touches. For a freshly obtained PersistenceManager it now registers the WildbookLifecycleListener, registers the indexing Synchronization, and installs the deferred-indexing park -- and only then activates the transaction. A store callback can only fire once the transaction is active, and by then it needs both the listener and a live park in place. The registrations happen once per PM, tracked by identity. On main every beginDBTransaction() -- which runs again on each updateDBTransaction() -- added another listener, so one store event fanned out to N duplicate callbacks. The ordering lives in a static package-private helper so it is asserted against a mock PersistenceManager without a datastore. commitDBTransaction(), commitDBTransactionWithStatus(), updateDBTransaction(), rollbackDBTransaction(), closeDBTransaction() and rollbackAndClose() are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
…ead of enqueuing on flush WildbookLifecycleListener.postStore(), Encounter.enqueueAclReindex() and MarkedIndividual.enqueueAclReindex() now hand their request to IndexingManager.addPendingEntry() with the object's own PersistenceManager. While that PM has an active transaction the request is parked and released by the transaction's completion callback; a transient object or a caller that has already committed falls through to an immediate enqueue, exactly as before. Encounter.enqueueAclReindexNow() is the escape hatch for the one caller whose open transaction is rolled back on purpose: the background permissions pass reads inside a transaction it discards at the end, so parking there would drop the request. Requests are now keyed by identity, so an object without an id (a fresh Encounter has catalogNumber "" until it is numbered) is no longer turned into a job that can only fail to load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
… are enqueued only after commit
Testcontainers Postgres, real DataNucleus, real Shepherd. Each case modifies an
existing Encounter, flushes, and ends the transaction one way or another; a mocked
IndexingManager records what it was asked to do and whether the writer's
transaction was still active at that moment.
- flush enqueues nothing; Shepherd commit enqueues exactly once, after the
transaction has ended, and a fresh Shepherd then reads the committed value
- a raw pm.currentTransaction().commit() is covered identically
- rollback enqueues nothing
- a second transaction begun RAW on the same PM parks and drains again
- a commit vetoed by a NucleusUserException leaves the transaction active and the
park intact; the retry enqueues once (#1743 terminalized the park on "commit
threw" and would have enqueued every later store pre-commit)
- vetoed commit + rollback, and vetoed commit + close, enqueue nothing
- committed completion evicts the row from level-2 by DataNucleus identity
(asserted on the real Level2Cache, not a mock)
- the indexing job's reader PM has level-2 retrieve mode "bypass"
RED evidence: with the pre-change WildbookLifecycleListener swapped in, 8 of 9
fail; the first with
NoInteractionsWanted ... But found these interactions on mock 'indexingManager':
-> at org.ecocean.WildbookLifecycleListener.postStore(WildbookLifecycleListener.java:72)
i.e. the enqueue at flush time, before commit.
Observed while writing this: pm.close() on the still-active vetoed transaction did
NOT throw in this environment although DataNucleus 5.2.7's default
closeActiveTxAction is "exception"; the test therefore asserts on outcomes (no
enqueue, row unchanged) rather than on close throwing.
Codex (gpt-6-astra) specified this test's shape in its plan review: observe both
enqueue overloads for the RED run, use a one-shot vetoing Synchronization rather
than a duplicate-key insert to fail the commit, begin the second transaction raw,
and read committed content after commit returns instead of inside the callback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
… DB test's oracle and preconditions
Codex (gpt-6-astra) reviewed the branch diff and established that the per-PM
datanucleus.cache.level2.retrieveMode=bypass on the indexing reader is PARTIAL in
DataNucleus 5.2: it governs getObjectById, but lazy field loads and bulk loads still
consult L2 under PMF-wide settings, and the encounter serializer opens its own
Shepherd that reads normally. The underlying hazard -- DataNucleus publishing an
object's new state into the shared L2 during preCommit, before the datastore commit
-- is DataNucleus-wide, affects every reader in Wildbook, and predates this branch.
Closing it needs a dedicated L2-less PMF for indexing reads or a fleet-wide cache
policy decision, both separate changes. The bypass stays (it strictly helps the
primary lookup); the comments now say exactly what it does and does not cover, and
no longer describe eviction as read isolation or handoff as guaranteed delivery.
Shepherd.beginDBTransaction(): a null PersistenceManager from the PMF now logs and
returns instead of recording a "begin" ShepherdState for a transaction that never
began.
IndexingPostCommitHandoffDbTest:
- the row oracle is a raw JDBC SELECT against the container -- no Shepherd, so no
DataNucleus cache can satisfy it
- the close-after-vetoed-commit test asserts what actually happens: the JDO API
adapter defaults closeActiveTxAction to "rollback" (overriding core's
"exception"), so pm.close() rolls back, the park completes through the normal
callback, nothing is enqueued, the row is unchanged
- the level-2 eviction test pins the DataNucleus id before commit so the soft
cache's precondition cannot be collected between warm-up and assertion
completePendingEntries() is package-private: only tests call it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1752 +/- ##
=======================================
Coverage 55.89% 55.89%
=======================================
Files 316 316
Lines 12852 12852
Branches 4172 4172
=======================================
Hits 7184 7184
Misses 5367 5367
Partials 301 301
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
… deterministic
Codex (gpt-6-astra) convergence round: CONVERGED, no Major findings within scope,
scope boundary on partial L2 isolation accepted as legitimate. Its two remaining
Minors:
- Comments and one log line still overclaimed. "Will index it if it exists" and
"only when strictly greater" omitted that the reconciler also re-indexes a
MISSING document; "the next reader goes to the database" ignored level-1 hits
and concurrent repopulation. Reworded to: eviction removes the current shared
entry; the reconciler re-indexes a missing document, or an existing versioned
document whose database version is strictly greater. The DB test's class
comment said "fresh Shepherd" where the oracle is now JDBC.
- DataNucleus 5.2.7 AbstractReferencedLevel2Cache.pin() creates its pinnedIds set
on the first call and skips adding the id, so a single pin before the put did
not guarantee the L2 test's precondition. Pin twice, with the reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex gpt-6-astra <noreply@openai.com>
Conflicts: main (#1785/#1786) replaced Encounter.opensearchIndexPermissions() with the scheduled PermissionsAudit, which writes repairs directly with conditional writes and never uses the indexing queue. Took main's deletion. enqueueAclReindexNow() existed only for that deleted pass, so it and its unit test are removed; ChildReindexTriggerTest's javadoc keeps the parking paragraph and takes main's PermissionsAudit paragraph. Reviewed by Codex (gpt-6.1-sol): converged, no Major findings; its stale test-count wording Minor is fixed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UrbanFercec
left a comment
There was a problem hiding this comment.
Ran it merged into today's main with the Testcontainers tests, all green. Read through the DataNucleus 5.2.7 TransactionImpl too: afterCompletion runs after the L2 put and inactive, so the evict order holds. Two small things inline.
| ShepherdIndexingWork(String objectID) { | ||
| shepherd = new Shepherd("context0"); | ||
| shepherd.setAction("IndexingManager_" + objectID); | ||
| shepherd.beginDBTransaction(); |
There was a problem hiding this comment.
If begin throws anything other than JDOUserException or NPE, say a JDODataStoreException while the DB is down, the constructor throws, work stays null and the finally in run() never closes this Shepherd. Main assigned bgShepherd before begin, so its rollbackAndClose still ran. A try/catch here that closes and rethrows would keep that.
| "Shepherd.beginDBTransaction(): could not obtain a PersistenceManager"); | ||
| return; // nothing began; do not record a "begin" state for it | ||
| } | ||
| listenerRegisteredOn = prepareAndBeginTransaction(pm, listenerRegisteredOn); |
There was a problem hiding this comment.
Passing null instead of listenerRegisteredOn here brings back one listener per begin, and the Indexing*, ShepherdTest and ShepherdBeginRegistrationTest suites all stay green. A test that begins twice on one Shepherd and checks addInstanceLifecycleListener ran once would pin the fan-out fix.
UrbanFercec
left a comment
There was a problem hiding this comment.
Both points are covered now. The failed-begin test catches it when the close in the catch is removed, and the repeated-begin test catches the listener going back to null. Indexing and Shepherd suites pass on today's main.
Re-derivation of #1743 on the JDO transaction's own completion callback, as recommended by the Codex review of that PR. #1743 stays open as the problem record until this lands.
Closes #1696. That report (GiraffeSpotter, React encounter search) is this bug seen from the search page: the Identity filter "Include only encounters with no assigned Individual ID" listed encounters that already had an ID, because the encounter document's
individualIdwas indexed from the pre-commit row on the match-results Confirm path (iaResultsSetID.jsp). That path never bumpsEncounter.modified, so the background reconciler could not repair it and the stale rows persisted. Closes #1548.The bug
WildbookLifecycleListener.postStore()fires on JDO flush, not commit. It enqueued the OpenSearch reindex there, and the indexing job opens its own Shepherd (its own connection) and reloads the object by id. For a row that already exists that reload succeeds and returns pre-commit state, so the job indexed the old values and reported success. Only a brand-new row raisedJDOObjectNotFoundException, which is the one case the retry path handled. The queue's dedupe (if (indexingQueue.contains(id)) return;) then silently dropped the correct post-commit request whenever the pre-commit job was still in flight.Search index stale, no data loss: Postgres is always right. Affected surfaces are the OpenSearch-backed ones, including the annotation index that feeds the match candidate pool.
What changed
The park.
postStore,Encounter.enqueueAclReindex()andMarkedIndividual.enqueueAclReindex()no longer enqueue. They park(id, class, unindex, DataNucleus identity)in the writing PersistenceManager's user-object map. A transient object, or a caller that has already committed, falls through to an immediate enqueue exactly as before.The release. A
javax.transaction.Synchronization(IndexingTransactionSynchronization) is registered once per PM viaTransaction.setSynchronization. DataNucleus calls itsafterCompletion(status)from insidecommit()/rollback()after its own post-commit work. That callback is the only thing that terminalizes a park:STATUS_COMMITTEDdrains it (evict from L2 by DataNucleus identity, then enqueue by identity),STATUS_ROLLEDBACKdrops it, anything else runs index requests (the job re-reads the DB, so that is harmless) and never an ambiguous unindex. Because it hangs off the transaction, a rawpm.currentTransaction().commit()is covered identically.What that removes. No edits to
Shepherd.commitDBTransaction(),commitDBTransactionWithStatus(),updateDBTransaction(),rollbackDBTransaction(),closeDBTransaction()orrollbackAndClose(). No lock around Shepherd's lifecycle. The only Shepherd change isbeginDBTransaction(): for a fresh PM it registers the listener and the Synchronization once (main re-added aWildbookLifecycleListeneron every begin, so one store event fanned out to N callbacks) and installs the park beforeTransaction.begin().The dedupe. A second request for an in-flight id is remembered and runs as exactly one follow-up pass when the job reaches any terminal outcome (success, failure, retry give-up), instead of being dropped.
The reader. The indexing job's PM sets
datanucleus.cache.level2.retrieveMode=bypassfor its primary lookup. See the boundary below for what that does and does not cover.Reader setup cleanup. If the indexing reader's transaction initialization throws,
ShepherdIndexingWorkrolls back and closes its allocated Shepherd before rethrowing the same exception. Regression coverage verifies that the PersistenceManager closes and its Shepherd diagnostic entry is removed. A test through the publicShepherd.beginDBTransaction()method also verifies that repeated begins on the same Shepherd register the lifecycle listener and transaction synchronization only once.Why this is safe where #1743 was not
NucleusUserExceptionso the caller can fix and retry. fix(indexing): hand OpenSearch reindexing to the queue after commit, not on JDO flush #1743 drained on that path, marking the park terminal while the transaction was still open, after which every later store in that transaction enqueued pre-commit again. Here the park stays live until the transaction actually completes. Tested against real DataNucleus with a one-shot vetoing Synchronization.JDOHelper.getObjectId()returns ajavax.jdo.identity.*object, and evicting with it silently misses. This usesPersistable.dnGetObjectId(), asserted against the realLevel2Cache.IndexingManageris logged, not thrown.Boundaries (stated on purpose)
getObjectById; in DataNucleus 5.2 it does not cover lazy field loads, bulk loads, or the second Shepherd the encounter serializer opens. Closing that needs either a dedicated L2-less PMF for indexing reads (a second connection pool per install) or a fleet-wide L2 policy decision. Follow-up, not this PR.IndexingManager, executor rejected) is logged and dropped; there is no durable retry. The reconciler re-indexes only when the DB version is strictly greater than the indexed one.Encounter.modifiedbump from fix(indexing): hand OpenSearch reindexing to the queue after commit, not on JDO flush #1743 is not here. Codex showed the second-resolution setter can decrease the version relative to EncounterForm's millisecond ISO value. A persisted monotonic revision is the right fix; separate PR.postDelete()still unindexes pre-commit. Separate fix.logNestedExceptionsnull-safety refactor from fix(indexing): hand OpenSearch reindexing to the queue after commit, not on JDO flush #1743: unrelated once the drain is out ofcommitDBTransaction().Tests
70 unit tests (no datastore) + 9 Testcontainers tests against real Postgres and DataNucleus 5.2.7, with the row oracle being a raw JDBC
SELECT(no Shepherd, no DataNucleus cache):pm.currentTransaction().commit()behaves identicallypm.close(): under the JDO adapter's defaultcloseActiveTxAction=rollbackthe close rolls back, the park completes through the normal callback, nothing is enqueued, row unchangedbypassRED evidence (Testcontainers test run against the pre-change listener): 8 of 9 fail, the first with
that is, the enqueue at flush time.
Latest full backend run, with the independent membership-fixture correction from #1831 temporarily applied for local validation: 1777 tests, 0 failures, 0 errors, 7 skipped. The final cleanup and public-Shepherd assertions were additionally verified on this branch with
IndexingJobWiringTestandShepherdTest(21 tests, all passed). Claude reviewed the follow-up and found no blocking issues.CI dependency: #1831 fixes the 23 membership-fixture errors already present on
main. Merge it first, then update this branch frommain; its fixture changes are kept out of this PR's follow-up commit.Credits
Written by Claude Fable 5.1 (Claude Code). Codex (
codex-cli 0.153.4,gpt-6-astra, reasoning effort high) reviewed #1743 and proposed this design, then reviewed the plan and the diff for this PR. Its #1743 review found the failed-commit terminalization bug this design removes. Its plan review caught four load-bearing problems before any code was written: the L2 eviction key mismatch, the pre-commit L2 publication, the delegate-first callback order that could lose the park, and the need for per-entry failure isolation with the successor installed first. Its diff review established that the per-PM L2 bypass is partial in DataNucleus 5.2, explained the close-on-active-transaction behavior via the JDO adapter default, and pushed the test oracle to raw JDBC.Review status, stated plainly: Codex's first diff round returned not converged with two Majors, both about the partial L2 bypass. Those are answered with the scope boundary above rather than with code, because the fix they call for (an L2-less PMF or a fleet-wide cache policy) is a separate change with its own blast radius. The convergence round then evaluated the branch against that stated boundary and returned "CONVERGED: no Major findings within scope", accepting the boundary as legitimate ("not a dodge: shared-L2 isolation is a separate, pre-existing problem"). Its two remaining Minors (a DataNucleus
pin()quirk in the L2 test fixture, and four comment phrasings that overclaimed recovery or isolation) are fixed in the last commit. Three Codex rounds in total on this PR: plan, diff, convergence.Earlier rounds of #1743 were reviewed by Codex
gpt-5.6-terra.