Class CleanupScanService
CleanupConstants.SPLIT_SCAN_ROOTS), several reader threads walk their
own unit each, and ONE writer thread persists everything they stream through a
bounded queue. Nothing is ever deleted here.
THE INVARIANT of the whole design is ONLY THE WRITER WRITES while readers are
alive. It was first stated as "only the writer TOUCHES JPA", which was WRONG,
and the mistake cost a Tribe-Dev dry run: a reader calls no cleanup storage
bean but CleanupJcrStorage.scanRoot(java.lang.String, java.lang.String, int, org.exoplatform.addons.documents.cleanup.model.CleanupParams, org.exoplatform.addons.documents.cleanup.storage.CleanupJcrStorage.ScanBatchConsumer) — yet scanRoot resolves each
CANDIDATE's owner through Social (getOwnerIdentityFromNodePath ->
SpaceService / IdentityManager), and a cache MISS there runs a
JPA query. On a reader thread with no container transaction that query failed
with Session/EntityManager is closed; the per-node handler inside
scanRoot swallowed it as a WARN, the unit still finished DONE, and every
candidate whose space was not already cache-warm was silently dropped from the
report. "No call to OUR storage" is not "no JPA": a Service of another domain
is free to hit its own DAO, and this one does.
So each reader now opens its OWN read-only container transaction
(readUnitTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit)), and the invariant that actually holds — the
one worth defending, because it is what keeps write ordering and the
checkpoint consistent — is about WRITES:
- a reader never calls a cleanup storage bean other than
CleanupJcrStorage.scanRoot(java.lang.String, java.lang.String, int, org.exoplatform.addons.documents.cleanup.model.CleanupParams, org.exoplatform.addons.documents.cleanup.storage.CleanupJcrStorage.ScanBatchConsumer), and never WRITES anything anywhere: it posts envelopes on the queue and reads volatile flags. Its transaction exists only so the reads scanRoot makes THROUGH ANOTHER DOMAIN have a session to run in - the coordinator writes only BEFORE the readers start (planning, per-unit totals, claiming the units) and AFTER they all finished (the terminal transition) — never in between
- the terminal state of a unit (DONE / FAILED) is therefore NOT written by the reader that finished it: the reader posts it as the last envelope of its unit and the WRITER records it
- the abort flag itself is refreshed from the database by the writer, so
even the
isAbortedcampaign re-read stays on the writer's thread; the readers poll the volatile flag it publishes
TERMINATION — what is guaranteed, and by WHICH mechanism. Nothing here bounds the DURATION of a scan: a dry-run over the target corpus spans hours to DAYS, is interrupted and resumed, and no mechanism below may ever cut a run short merely because it took long. What is bounded is BLOCKING WITHOUT PROGRESS, by four independent mechanisms, each covering what the previous one cannot:
- a reader blocked on a full queue re-checks the stop flags between two
bounded offers (
QUEUE_TIMEOUT_MILLIS), so it stops as soon as the writer raised one — the fast path, and it covers only the failures that DO raise a flag - a writer thread that VANISHED raises no flag, so the coordinator does not
INFER it from a timer: it observes
Thread#isAlive()on the writer thread itself, at every slice of the reader wait (awaitReaders(java.util.concurrent.ExecutorService, org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, java.lang.Thread)). A dead writer stops the readers immediately, with nothing presumed - a writer that is ALIVE but permanently wedged (blocked on a database lock,
say) is caught by an INACTIVITY watchdog: nothing drained for
writerInactivityMillisWHILE batches wait in the queue. A silence bound and not a duration cap, and it can only fire while the writer has work it is not taking — a healthy scan, however long, never reaches it - the writer is told to stop by TWO independent signals, deliberately: the
poison pill, delivered UNCONDITIONALLY (see
postPoisonPill(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun)), andScanRun#isStopped(), which the drain loop exits on even if no pill ever lands. Neither depends on the other
finally) so the watchdog can relaunch the worker.
A FAILED UNIT IS RETRIED, BOUNDEDLY. A unit whose walk failed is not terminal:
getUnitsToProcess hands it back to the next run, so the campaign is
deliberately left DRY_RUN_RUNNING and the watchdog
(CleanupCampaignService#resumeStalledWorkers) re-walks it — a transient
JCR failure heals itself with no human in the loop. The bound is
MAX_SCAN_UNIT_ATTEMPTS, and it bounds the WALK and not merely a
counter: past it the subtree has proved unreadable, the unit is SETTLED-failed,
CleanupScanUnitStorage#getUnitsToProcess stops handing it out — so no
later run claims it, spends an attempt on it or re-walks it — and it stops
holding the dry-run back. The report is then marked INCOMPLETE rather than
silently partial (see completeCampaign(long, long)).
A campaign whose EVERY unit settled-failed (a JCR outage fails them all) is therefore not a spin either: its work list is empty, every watchdog tick is a cheap no-op that walks nothing, and the campaign stays visibly stuck in DRY_RUN_RUNNING — refused as a simulation, loudly logged, but never re-walked.
LEGACY CHECKPOINT — a campaign interrupted under the old SEQUENTIAL scheme
carries a CHECKPOINT_PATH on its campaign row and has no unit row. On
resume it is re-planned into units and that legacy checkpoint is IGNORED, so
the scan re-walks the tree under the new partitioning. Deliberate and safe:
saveCandidates de-duplicates on the campaign+nodeUuid unique
constraint, so the replay costs work and can never duplicate a row.
-
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final longFAILED walks a scan unit may spend IN TOTAL, the first walk included — THREE walks that each reached a FAILED outcome, not one walk plus three retries. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionprotected voiddrainQueue(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Writer body — the ONLY component writing what the scan produces.voiddrainQueueTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Transactional entry point of the WRITER thread, self-invoked from the runnable started byscanUnits(long, org.exoplatform.addons.documents.cleanup.model.CleanupParams, java.util.List<org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit>, java.util.concurrent.ExecutorService, int, long, long): the project weaves@ContainerTransactionalwith AspectJ, which advises self-calls too — the same propertyexecutorService.execute(() -> scanTransactional(id))already relies on.getScanFailures(CleanupCampaign campaign) Grouped failures of a campaign's SCAN, one entry per distinct failure message code with the number of subtrees that carry it — the unit-level twin ofCleanupCampaignService#getCampaignFailures, so the console renders both through the same block.getScanUnitProgress(CleanupCampaign campaign) Per-unit breakdown of a campaign's dry run.protected voidpostPoisonPill(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Hands the writer its terminal signal, UNCONDITIONALLY.protected voidreadUnit(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run, CleanupScanUnit unit) Reader body: walks ONE unit and posts everything it finds on the queue, WRITING nothing (it reads, through Social, to resolve owners — seereadUnitTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit)).voidreadUnitTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run, CleanupScanUnit unit) Transactional entry point of a READER thread, self-invoked from the runnable submitted byscanUnits(long, org.exoplatform.addons.documents.cleanup.model.CleanupParams, java.util.List<org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit>, java.util.concurrent.ExecutorService, int, long, long)— same AspectJ self-call property asdrainQueueTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun).protected voidThe COORDINATOR's own seam, third of the family for the same reason the reader's is separate from the writer's: it commits once per run — right before the terminal transition, so that write never flows through a persistence context as old as the run itself — and sharing the writer's seam would make every assertion on the writer's per-batch commit count silently include it.protected voidThe READER's twin ofrestartTransaction(), kept as a separate seam on purpose rather than reusing that one: the two commit on different threads for different reasons — the writer to make its batch durable, a reader only to let go of a pooled connection before it blocks (seecommitThenPost(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanBatch)) — and one shared seam would make every assertion on the writer's commit count silently depend on how many batches the readers happened to emit.protected voidCommits the current batch and opens a fresh transaction.protected voidscan(long campaignId) Scan coordinator, running as system (no conversation state needed), in four phases: PLAN the units, ESTIMATE their sizes in parallel, SCAN them with readers + one writer, then COMPLETE.voidscanTransactional(long campaignId) Transactional entry point of the COORDINATOR: planning, per-unit estimation and the terminal transition run in ONE container transaction, exactly like the execution purge (CleanupExecutionService.executeCampaignTransactional(long)).voidshutdown()voidstartScan(long campaignId) Starts (or resumes after a restart) the dry-run scan of a campaign.
-
Field Details
-
MAX_SCAN_UNIT_ATTEMPTS
public static final long MAX_SCAN_UNIT_ATTEMPTSFAILED walks a scan unit may spend IN TOTAL, the first walk included — THREE walks that each reached a FAILED outcome, not one walk plus three retries. Bounds the whole unit retry: past the third recorded failure the subtree has proved unreadable whatever the cause was, and the watchdog must not re-walk it on every tick until the end of time.An attempt is spent when the WRITER RECORDS the FAILED outcome (
CleanupScanUnitStorage#updateUnitFailure), never by the coordinator claiming the unit — the claim is a pure RUNNING flip. Spending it at claim time conflated interruptions with failures: a JVM restart (the EXPECTED event of a days-long scan) claimed every remaining unit again, including the hundreds the reader pool never reached, so two interruptions left every unit one real failure away from being settled with ZERO retries — the opposite of what this bound promises. A walk that never reaches an outcome (interrupt, abandoned reader, dead writer) now costs nothing, exactly like an interrupted purge item.- See Also:
-
-
Constructor Details
-
CleanupScanService
public CleanupScanService()
-
-
Method Details
-
shutdown
@PreDestroy public void shutdown() -
startScan
public void startScan(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException Starts (or resumes after a restart) the dry-run scan of a campaign.- Parameters:
campaignId- campaign identifier- Throws:
org.exoplatform.commons.exception.ObjectNotFoundException- when the campaign doesn't exist
-
scanTransactional
public void scanTransactional(long campaignId) Transactional entry point of the COORDINATOR: planning, per-unit estimation and the terminal transition run in ONE container transaction, exactly like the execution purge (CleanupExecutionService.executeCampaignTransactional(long)). The streamed candidate rows are NOT written here — they are written by the writer thread, which opens its own transaction (seedrainQueueTransactional(ScanRun)) and commits per batch.It is a thin wrapper on purpose: the annotation is woven around THIS method, so the tests drive
scan(long)directly and never boot a container to exercise the worker body — the annotation itself is pinned by reflection inCleanupScanServiceTest.- Parameters:
campaignId- campaign identifier
-
scan
protected void scan(long campaignId) Scan coordinator, running as system (no conversation state needed), in four phases: PLAN the units, ESTIMATE their sizes in parallel, SCAN them with readers + one writer, then COMPLETE. Visible for tests, which invoke it directly instead of going through the transactional wrapper above. -
readUnitTransactional
public void readUnitTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run, CleanupScanUnit unit) Transactional entry point of a READER thread, self-invoked from the runnable submitted byscanUnits(long, org.exoplatform.addons.documents.cleanup.model.CleanupParams, java.util.List<org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit>, java.util.concurrent.ExecutorService, int, long, long)— same AspectJ self-call property asdrainQueueTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun).A reader needs a container transaction even though it writes NOTHING, because
CleanupJcrStorage.scanRoot(java.lang.String, java.lang.String, int, org.exoplatform.addons.documents.cleanup.model.CleanupParams, org.exoplatform.addons.documents.cleanup.storage.CleanupJcrStorage.ScanBatchConsumer)resolves every candidate's owner through Social and a cache miss there is a JPA read (see the class comment for the failure this cost us). The transaction is therefore READ-ONLY, which is what makesrestartReaderTransaction()safe to fail: a reader has no write to lose at commit.- Parameters:
run- state of the current scan rununit- the unit this reader walks
-
readUnit
protected void readUnit(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run, CleanupScanUnit unit) Reader body: walks ONE unit and posts everything it finds on the queue, WRITING nothing (it reads, through Social, to resolve owners — seereadUnitTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit)). The unit's ownLAST_SCANNED_PATHis the resume position (null when it was never started).A JCR
Sessionis NOT thread-safe, which is why per-thread sessions are MANDATORY here:scanRootopens its own system session per call and holds it for its whole lazy walk, so every reader gets its own — never change that to share one.A failure marks THIS unit failed and nothing else: one unreadable subtree must not deny the whole simulation. The failure is posted, not written — only the writer writes.
-
drainQueueTransactional
public void drainQueueTransactional(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Transactional entry point of the WRITER thread, self-invoked from the runnable started byscanUnits(long, org.exoplatform.addons.documents.cleanup.model.CleanupParams, java.util.List<org.exoplatform.addons.documents.cleanup.model.CleanupScanUnit>, java.util.concurrent.ExecutorService, int, long, long): the project weaves@ContainerTransactionalwith AspectJ, which advises self-calls too — the same propertyexecutorService.execute(() -> scanTransactional(id))already relies on.- Parameters:
run- state of the current scan run
-
drainQueue
protected void drainQueue(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Writer body — the ONLY component writing what the scan produces. Drains the queue until the poison pill, and per batch: persists the candidates, checkpoints THE UNIT THE BATCH CAME FROM, recomputes the aggregate progress and its ETA, pushes the progress event, then commits.It exits on FOUR signals, and the first two are deliberately redundant: the poison pill,
isStopped()— the run's own stop flags, so the writer stops even if no pill ever lands on a full queue — the abort re-read, and a failure. The pill alone was not enough: the coordinator raisedwriterFailedto unblock the readers,postthen refused to enqueue the pill precisely BECAUSE the run was stopped, and this loop, which consulted the pill and the abort but never the stop flags, saw a campaign still legitimately DRY_RUN_RUNNING and looped forever — leaking the thread and the container transaction its entry point had opened.Every drained envelope also stamps
ScanRun#markDrained(), the marker the coordinator's inactivity watchdog reads (awaitReaders(java.util.concurrent.ExecutorService, org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, java.lang.Thread)): draining IS the progress signal of this design, and a writer that stops stamping it while batches wait for it is a wedged writer.The aggregate numerator is kept IN MEMORY (seeded from the persisted per-unit counts) rather than re-summed per batch: the sum is already known exactly, and a database aggregate per batch would be pure overhead on a multi-million-node walk.
Visible for tests, which drive it directly so the woven aspect above never boots a container.
-
getScanFailures
Grouped failures of a campaign's SCAN, one entry per distinct failure message code with the number of subtrees that carry it — the unit-level twin ofCleanupCampaignService#getCampaignFailures, so the console renders both through the same block.Gated on the campaign's own INCOMPLETE verdict, and NOT on the live unit rows: a unit that failed while attempts remain is a transient failure the watchdog is already re-walking, and surfacing it would report a subtree as missing from a report that is not even finished. Only a scan the coordinator RECORDED as incomplete has anything to show here.
Every group is
retryable = false, which is the honest answer: these subtrees exhaustedMAX_SCAN_UNIT_ATTEMPTSwalks, and there is deliberately no console retry for them — re-running the scan of a campaign that already left DRY_RUN_RUNNING is not an edge this lifecycle has.- Parameters:
campaign- campaign whose scan failures are wanted- Returns:
- the groups, EMPTY when the campaign's scan covered the whole tree
-
getScanUnitProgress
Per-unit breakdown of a campaign's dry run.Deliberately NOT gated on anything, unlike
getScanFailures(org.exoplatform.addons.documents.cleanup.model.CleanupCampaign): that one reports subtrees definitively missing from a finished report, so it must wait for the verdict; this one exists to be read WHILE the scan runs, and gating it would remove it exactly when it is needed. A scan whose node percentage sits at 100% with the campaign still DRY_RUN_RUNNING is either re-walking a unit or held open by one, and only these counts tell an administrator which.- Parameters:
campaign- campaign whose unit breakdown is wanted- Returns:
- the breakdown, zeroed for a campaign whose units were never planned
-
postPoisonPill
protected void postPoisonPill(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun run) Hands the writer its terminal signal, UNCONDITIONALLY. It must not go throughpost(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanBatch), which gives up as soon asisStopped()answers true: the flag raised to unblock the readers was then the very flag that kept the writer from ever being told to stop — the pill was never enqueued, and the writer spun on an empty queue forever, holding the container transaction its@ContainerTransactionalentry point had opened. One leaked transaction per killed scan.The queue MAY BE FULL here, so the delivery must not block forever either. Two cases, and they are not symmetric:
- the run is STOPPED: the queued batches are abandoned whatever happens — nothing will checkpoint them, and their nodes are simply re-walked on the next resume — so the queue is CLEARED to make room. Dropping work that is already forfeit is the cheap half of the trade
- the run is NOT stopped: those batches are real work the writer is still
expected to persist, so they are never dropped. Every reader has finished by
now, so the queue can only SHRINK — one drained envelope is all the pill
needs — and the offers are therefore retried for as long as the writer keeps
DRAINING, on the very definition of wedged the watchdog uses
(
isWriterWedged(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun)) rather than on a deadline of its own. A healthy but slow writer is never given up on; a wedged one is, and the drain loop'sisStopped()exit stops it instead (seedrainQueue(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun))
- Parameters:
run- state of the current scan run
-
restartTransaction
protected void restartTransaction()Commits the current batch and opens a fresh transaction. Extracted as a single call so the worker tests — which drive the writer body directly, with no PortalContainer booted — can neutralize it, exactly likeCleanupExecutionService.restartTransaction(). -
restartReaderTransaction
protected void restartReaderTransaction()The READER's twin ofrestartTransaction(), kept as a separate seam on purpose rather than reusing that one: the two commit on different threads for different reasons — the writer to make its batch durable, a reader only to let go of a pooled connection before it blocks (seecommitThenPost(org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanRun, org.exoplatform.addons.documents.cleanup.service.CleanupScanService.ScanBatch)) — and one shared seam would make every assertion on the writer's commit count silently depend on how many batches the readers happened to emit. -
restartCoordinatorTransaction
protected void restartCoordinatorTransaction()The COORDINATOR's own seam, third of the family for the same reason the reader's is separate from the writer's: it commits once per run — right before the terminal transition, so that write never flows through a persistence context as old as the run itself — and sharing the writer's seam would make every assertion on the writer's per-batch commit count silently include it.
-