Class CleanupCampaignService

java.lang.Object
org.exoplatform.addons.documents.cleanup.service.CleanupCampaignService

@Service public class CleanupCampaignService extends Object
Cleanup campaigns lifecycle: dry-run launch, publication (single active campaign platform-wide), grace-deadline locking, execution trigger, cancellation, user exemptions ('keep'/'un-keep'), candidate freshness refresh, campaign comparison, report retention and CSV archiving.
  • Field Details

    • FILE_NAMESPACE

      public static final String FILE_NAMESPACE
      File-store namespace the archived CSV reports live in.

      It MUST be registered with NameSpaceService before a file is written under it: DataStorage#create looks the namespace up by name and dereferences the result without a guard, so an unregistered name fails with a raw NullPointerException wrapped in a FileStorageException — 'Error while writing file cleanup-campaign-N.csv', once per retention tick, forever. Every archive attempt failed that way, which also meant the retention job never dropped a single item row: it archives BEFORE purging, deliberately, and the archive never succeeded. See ensureFileNamespace().

      See Also:
    • NOT_OWNER_FAILURE_CODE

      public static final String NOT_OWNER_FAILURE_CODE
      Localizable message code reported for an ACL refusal in a bulk outcome, in place of the IllegalAccessException raw English message.
      See Also:
    • MAX_NAME_LENGTH

      public static final int MAX_NAME_LENGTH
      Maximum campaign name length, mirroring the NAME column of DOCUMENTS_CLEANUP_CAMPAIGN — NVARCHAR(250) NOT NULL. Nothing used to check it, so a longer name reached the INSERT and failed with a raw database error instead of a 400 carrying a message code the console can localize.
      See Also:
    • RETRYABLE_FAILURE_REASONS

      public static final Set<String> RETRYABLE_FAILURE_REASONS
      Failure message codes a retry may re-attempt. TRANSIENT causes only: a JCR read that failed once may succeed now, a delete that hit a locked node may not, an unexpected error is by definition unclassified. Deliberately ABSENT: cleanup.referentialIntegrity (another node points at the file — the repository will refuse identically) and cleanup.notVersionable (the node has no version history — it never will grow one by itself). Re-running those is guaranteed wasted work on possibly hundreds of thousands of rows.

      Public because the REST layer flags each grouped failure with it and the tests assert against it: what is retryable is a SERVER decision, and the client must never be the authority on it.

    • GRACE_EDITABLE_STATES

      public static final Set<CleanupCampaignState> GRACE_EDITABLE_STATES
      States in which the GRACE PERIOD of a campaign may still be edited. The grace period is the ONE parameter that is not a candidacy criterion — it is read in a single place, to derive the lock date from the publication date — so editing it can never invalidate the dry-run the administrator is reviewing, and the guard is deliberately narrower than 'any state' rather than absent.

      LOCKED is REJECTED on purpose: extending the grace of a locked campaign would mean going back to PUBLISHED, an edge that does NOT exist in CleanupCampaignLifecycle.ALLOWED_TRANSITIONS — and exiting PUBLISHED unregisters the freshness observation listener, so a reverse edge would have to re-register it. Out of scope; do NOT 'fix' this casually. EXECUTING, COMPLETED and CANCELLED are rejected because the grace period is MEANINGLESS once the purge ran.

      Public because the tests and any future caller must share this rule rather than restate it.

    • MAX_RETRY_ATTEMPTS

      public static final long MAX_RETRY_ATTEMPTS
      RETRIES an item may spend AFTER its initial purge attempt — three of them, so a doomed item is purge-attempted four times in all. The initial attempt spends no attempt: only the requeue increments the counter, and the requeue filter is attemptCount < 3.

      Deliberately NOT the same arithmetic as CleanupScanService#MAX_SCAN_UNIT_ATTEMPTS, which bounds THREE walks in total: a scan unit spends an attempt on its very first walk, because the coordinator claiming a unit is what increments it. Same value, same purpose — bounding a retry so it cannot loop forever — counted from a different origin, and neither is a typo of the other.

      Bounds the whole item retry mechanism: past it, a failure has proved itself deterministic whatever its code says, and an administrator re-clicking Retry must not be able to loop on it forever.

      See Also:
  • Constructor Details

    • CleanupCampaignService

      public CleanupCampaignService()
  • Method Details

    • shutdown

      @PreDestroy public void shutdown()
    • init

      @PostConstruct public void init()
    • resumeStalledWorkers

      public void resumeStalledWorkers()
      Resumes the workers of the campaigns left DRY_RUN_RUNNING or EXECUTING without a live worker — the dry-run scan resumes from its persisted path checkpoint, the purge is naturally resumable (it iterates the remaining CANDIDATE items). Called at startup recovery AND on every watchdog tick, so a worker thread that died mid-run is re-launched without a JVM restart. Safe to call unconditionally: the workers' running-campaign guard (the id is removed in a finally block even on fatal error) makes this a no-op while a campaign's worker is alive.
    • getDefaultParams

      public CleanupParams getDefaultParams()
      Returns:
      the platform default campaign parameters
    • getCampaigns

      public List<CleanupCampaign> getCampaigns()
      Returns:
      most recent campaigns, with their item aggregates — computed by a single grouped query for the whole list, never per campaign
    • getCampaign

      public CleanupCampaign getCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Parameters:
      campaignId - campaign identifier
      Returns:
      the campaign with its item aggregates
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • createCampaign

      public CleanupCampaign createCampaign(String name, CleanupParams overrides)
      Creates a campaign snapshotting the effective parameters, then launches its dry-run scan.
      Parameters:
      name - campaign name, mandatory
      overrides - partial parameter overrides, null fields defaulted
      Returns:
      the created campaign
      Throws:
      IllegalArgumentException - "cleanup.nameMandatory", "cleanup.nameTooLong" (past MAX_NAME_LENGTH characters), "cleanup.invalidPeriodMonths" (must be >= 1), "cleanup.invalidMinFileSize" (must be >= 0), "cleanup.invalidGraceDays" (must be >= 0 — ZERO IS a valid grace period, elapsing at publication), "cleanup.invalidMaxVersionsPerFile" (must be >= 1)
    • deleteCampaign

      public void deleteCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Deletes a campaign and everything hanging off it: its item rows, its scan unit rows and its archived CSV.

      Allowed from DELETABLE_STATES only — a draft or a discarded simulation, where nothing was promised to a user and nothing was destroyed. Anything else answers cleanup.invalidState: a running campaign has a worker writing the very rows this would drop, a PUBLISHED or LOCKED one has told its users a date and collected their decisions, and a COMPLETED one is the record of an irreversible deletion (see DELETABLE_STATES). Cancel first, then delete, is the path for the non-terminal ones.

      The JCR exemption mixins are NOT touched, and that is the important half: a user's "keep" is a standing decision on their own file, deliberately durable in JCR and deliberately outliving the campaign that collected it. Deleting a campaign must never silently un-keep a file — the next campaign has to see those decisions again.

      Parameters:
      campaignId - campaign identifier
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • deleteCampaignRowsTransactional

      public void deleteCampaignRowsTransactional(long campaignId)
      Drops the report and scan unit rows of an already-deleted campaign, off the request thread.

      WHY IT IS NOT ON THE REQUEST THREAD: a SIMULATED campaign is the one carrying a FULL report — nothing has been purged yet, so its item table is at its maximum, hundreds of thousands of rows on the corpus this feature exists for. No REST call may depend on work whose duration grows with the corpus (a reverse proxy will cut it), which is why creation and execution answer 202 and hand their progress to CometD. Deleting is the third such operation, and the only one that used to answer synchronously.

      The DELETE still answers 204 and not 202, because unlike those two nothing about the campaign is still pending: the campaign row is gone before the answer, so the resource really is deleted. What runs here is the collection of rows nothing can reach any more.

      A JVM death in this window leaves those rows behind — visible to nobody, and holding the very space this feature reclaims — so they are swept at startup (sweepOrphanRows()) rather than left to accumulate. That is also why the campaign row is deleted BEFORE these and not after: an orphaned item row is recoverable garbage, whereas a campaign whose report is half gone is a row an administrator can still publish.

      Parameters:
      campaignId - campaign identifier
    • sweepOrphanRows

      public void sweepOrphanRows()
      Drops the item and scan unit rows whose campaign row no longer exists.

      Their only source is a JVM death in the middle of a delete (see deleteCampaignRowsTransactional(long)), which is rare — and exactly why it must be swept rather than watched for: nothing else would ever notice rows that no query can reach, in a feature whose entire purpose is reclaiming space. Logged at WARN naming the campaigns, never silently.

    • updateCampaign

      public CleanupCampaign updateCampaign(long campaignId, String name, Integer graceDays) throws org.exoplatform.commons.exception.ObjectNotFoundException
      PARTIAL update of a campaign's editable attributes: its name, its grace period, or both. A null argument means 'leave that attribute unchanged', so the two fields are strictly independent — and their guards are per FIELD, not per request.

      The NAME is editable in ANY state, terminal ones included: it is pure METADATA, nothing keys off it and no lifecycle transition is involved, so correcting the label of an already-completed report is a legitimate need. Names are not unique (creation doesn't check either), so no uniqueness constraint is enforced. It goes through the very SAME validation as the creation path (validateName(String)): the two cannot diverge, and neither can let a name longer than the NAME column reach the database.

      The GRACE PERIOD is state-guarded (GRACE_EDITABLE_STATES) and validated by the very same bound check as the creation path (validateGraceDays(Integer)), so the two cannot diverge either — ZERO stays valid on both. Editing it can never invalidate the dry-run the administrator is reviewing: it is NOT a candidacy criterion, the scan selecting on the period, the minimum file size and the excluded paths alone.

      Once the campaign is PUBLISHED the grace period may only be EXTENDED, never shortened (architect decision W22) — see applyGraceDays(org.exoplatform.addons.documents.cleanup.model.CleanupCampaign, java.lang.Integer). Before publication it is free in both directions, nothing having been promised yet.

      On a PUBLISHED campaign — and ONLY there — the lock date is recomputed from the PUBLICATION date, never from now: anchoring on now would slide the deadline forward on every save, so saving the same value twice would push it out twice. Everything downstream reads the lock date (the end users' remaining time included, through their summary's deadline), so that one field propagates the new deadline with NO second code path.

      A recomputed deadline landing in the PAST is allowed and is not an error: it closes the review window immediately (cleanup.reviewClosed) and the grace-deadline cron locks the campaign at its next tick — exactly what already happens with a zero grace period, tolerated for up to the cron period (see checkReviewWindowOpen(CleanupCampaign)). This method deliberately performs NO transition of its own: the single PUBLISHED to LOCKED authority stays lockExpiredPublishedCampaign() and the manual execution trigger.

      Persisted as a TARGETED write of the two or three columns this operation owns (CleanupCampaignStorage.updateEditableAttributes(long, java.lang.String, java.lang.Integer, java.lang.Long)), never as a whole-row save of the snapshot read above: the name is editable in EVERY state, so this write races the two schedule-driven writers of the same row — the workers' progress updates and the grace-deadline cron — and a read-modify-write would silently undo theirs.

      Parameters:
      campaignId - campaign identifier
      name - new campaign name, null to leave it unchanged — trimmed before being persisted
      graceDays - new grace period in days, null to leave it unchanged — 0 is a MEANINGFUL value, not an absent one
      Returns:
      the updated campaign, with its item aggregates
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - "cleanup.campaignNotFound" when the campaign doesn't exist
      IllegalArgumentException - "cleanup.nothingToUpdate" when both arguments are null, "cleanup.nameMandatory" when the name is blank, "cleanup.nameTooLong" past MAX_NAME_LENGTH characters, "cleanup.invalidState" when the grace period is edited outside GRACE_EDITABLE_STATES, "cleanup.invalidGraceDays" when it is negative, "cleanup.graceDaysCannotBeReduced" when it is LOWERED on a PUBLISHED campaign
    • publishCampaign

      public CleanupCampaign publishCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Publishes a SIMULATED campaign, starting its grace period. At most one campaign can be PUBLISHED/LOCKED/EXECUTING platform-wide.
      Parameters:
      campaignId - campaign identifier
      Returns:
      the published campaign
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • executeCampaign

      public CleanupCampaign executeCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Triggers the batched purge of a LOCKED campaign. A PUBLISHED campaign whose grace deadline elapsed is first locked through the regular lifecycle transition (the same code path as lockExpiredPublishedCampaign(), including the observation listener unregistration), then executed; before the deadline the execution is rejected.
      Parameters:
      campaignId - campaign identifier
      Returns:
      the campaign, now EXECUTING
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
      IllegalArgumentException - "cleanup.graceNotElapsed" when the campaign is PUBLISHED and its grace deadline hasn't elapsed yet
    • retryCampaign

      public CleanupCampaign retryCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Re-attempts the RETRYABLE failed items of a COMPLETED campaign: no new scan, no new grace period, no new campaign. The requeued items go back to CANDIDATE and the campaign re-enters EXECUTING, so the very same worker processes them — and the worker needs NO change for that, its per-item revalidation against JCR immediately before deleting being what makes a requeued item safe (an item since exempted, modified or deleted is spared on its own).

      Lives HERE and not in CleanupExecutionService, which owns the worker, because everything specific to a retry is business this Service already owns: the platform-wide single-active-campaign invariant and the very publishLock that makes its check-then-act atomic, plus the retryable allowlist. The execution part is then delegated to CleanupExecutionService.startExecution(long) verbatim — the requeue having just made the CANDIDATE count equal to the requeued count, its denominator reset, its lifecycle transition and its worker launch are exactly what a retry needs, and duplicating them here would be a second code path to keep in sync.

      A REFUSAL never starts a run: not COMPLETED, another campaign active, or nothing at all to retry all leave the campaign untouched.

      Parameters:
      campaignId - campaign identifier
      Returns:
      the campaign, now EXECUTING again, with its item aggregates
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - "cleanup.campaignNotFound" when the campaign doesn't exist
      IllegalArgumentException - "cleanup.invalidState" when the campaign isn't COMPLETED, "cleanup.campaignAlreadyActive" when another campaign is PUBLISHED/LOCKED/EXECUTING, "cleanup.noRetryableFailures" when no item qualifies
    • getCampaignFailures

      public List<CleanupFailureGroup> getCampaignFailures(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Grouped failures of a campaign: one entry per distinct failure message code, its SKIPPED item count, and whether a retry would re-attempt it — the flag read from RETRYABLE_FAILURE_REASONS, so the console never has to hold its own copy of that rule.
      Parameters:
      campaignId - campaign identifier
      Returns:
      the groups, EMPTY when the campaign has no failed item — or when the retention job already archived and purged its item rows, the grouped counts being computed over those rows and NOT snapshotted in the campaign summary
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • getCampaignScanFailures

      public List<CleanupFailureGroup> getCampaignScanFailures(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Grouped failures of a campaign's dry-run SCAN: one entry per distinct failure message code, with the number of SUBTREES that carry it. The unit-level twin of getCampaignFailures(long), answering the same shape so the console renders both through one block.

      A dry run used to report SIMULATED at 100% over a report silently missing whole subtrees — the only trace being a log line no administrator can read. This is that trace, made readable: whoever is about to publish the report sees how much of the tree it does NOT cover.

      Delegates to CleanupScanService, which owns the scan and its unit rows, after resolving the campaign here so an unknown id answers 404 exactly like every other campaign endpoint.

      Parameters:
      campaignId - campaign identifier
      Returns:
      the groups, EMPTY when the campaign's scan covered the whole tree — which is the case of every campaign scanned before this bound existed
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • getCampaignScanUnitProgress

      public CleanupScanUnitProgress getCampaignScanUnitProgress(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Per-unit breakdown of a campaign's dry run, resolving the campaign here so an unknown id answers 404 like every other campaign endpoint.
      Parameters:
      campaignId - campaign identifier
      Returns:
      the state counts, the deepest attempt spent, and the units in flight
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • cancelCampaign

      public void cancelCampaign(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Cancels a campaign from any non-terminal state.
      Parameters:
      campaignId - campaign identifier
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • lockExpiredPublishedCampaign

      public void lockExpiredPublishedCampaign()
      Scheduled-glue entry point: flips the PUBLISHED campaign to LOCKED once its grace deadline elapsed.
    • keepItem

      public void keepItem(long itemId, String username) throws org.exoplatform.commons.exception.ObjectNotFoundException, IllegalAccessException
      User decision to keep a candidate file: checks ownership (own file, or manager of the owning space), adds the exemption mixin and marks the item EXEMPTED.

      The ownership check runs FIRST, before any campaign-state or item-state check: probing item ids must never tell a non-owner whether an item exists nor which state its campaign is in.

      Parameters:
      itemId - campaign item identifier
      username - user requesting to keep the file
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the item or its node doesn't exist
      IllegalAccessException - when the user doesn't own the file
      IllegalArgumentException - "cleanup.campaignNotPublished" when the campaign isn't PUBLISHED, "cleanup.reviewClosed" once the grace deadline elapsed, "cleanup.itemNotCandidate" when the item isn't decidable
      IllegalStateException - on a (possibly transient) JCR write failure, the item state left untouched so the keep can be retried
    • keepItems

      public CleanupBulkResult keepItems(List<Long> itemIds, String username)
      Bulk variant of keepItem(long, String), continuing past individual failures and REPORTING them: a caller that answered a blanket success would tell the user their files are kept while none of them is.
      Parameters:
      itemIds - campaign item identifiers
      username - user requesting to keep the files
      Returns:
      per-item outcomes: how many succeeded, and which ones failed with which reason
    • unkeepItem

      public void unkeepItem(long itemId, String username) throws org.exoplatform.commons.exception.ObjectNotFoundException, IllegalAccessException
      User decision to un-keep a previously kept file (undo of keepItem(long, String)): checks ownership (own file, or manager of the owning space), removes the exemption mixin, then revalidates the node through the shared revalidation mapping — still qualifying goes back to CANDIDATE, modified meanwhile to SPARED_BY_MODIFICATION, disappeared to GONE. Only allowed while the review window is open (campaign PUBLISHED and grace deadline not elapsed yet).

      The ownership check runs FIRST, before any campaign-state or item-state check: probing item ids must never tell a non-owner whether an item exists nor which state its campaign is in.

      Parameters:
      itemId - campaign item identifier
      username - user requesting to un-keep the file
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the item or its node doesn't exist
      IllegalAccessException - when the user doesn't own the file
      IllegalArgumentException - "cleanup.reviewClosed" when the campaign isn't PUBLISHED anymore or its grace deadline elapsed, "cleanup.itemNotKept" when the item isn't EXEMPTED
      IllegalStateException - on a (possibly transient) JCR write failure, the item state left untouched so the un-keep can be retried
    • unkeepItems

      public CleanupBulkResult unkeepItems(List<Long> itemIds, String username)
      Bulk variant of unkeepItem(long, String), continuing past individual failures and REPORTING them (see keepItems(List, String)).
      Parameters:
      itemIds - campaign item identifiers
      username - user requesting to un-keep the files
      Returns:
      per-item outcomes: how many succeeded, and which ones failed with which reason
    • refreshCandidate

      public void refreshCandidate(String itemPath, String eventType)
      Freshness refresh (UX only, not the correctness guarantee), called by the JCR observation glue while a campaign is PUBLISHED. The event path is matched in BOTH directions so every JCR event shape refreshes the right items: a PROPERTY_CHANGED path (e.g. /Users/.../file.pdf/jcr:content/jcr:data) matches the file item ABOVE it (ancestor-chain exact match), a folder removal/move matches the candidate items BELOW it (escaped prefix LIKE), and an exact node event matches the item itself.
      Parameters:
      itemPath - JCR event path (node or property path)
      eventType - JCR event type name
    • getCampaignItems

      public org.springframework.data.domain.Page<CleanupCampaignItem> getCampaignItems(long campaignId, Long ownerIdentityId, CleanupItemState state, CleanupAction action, Long minSize, String search, org.springframework.data.domain.Pageable pageable) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Campaign items with optional filters, for the admin console.
      Parameters:
      campaignId - campaign identifier
      ownerIdentityId - optional owner filter
      state - optional item state filter
      action - optional action filter
      minSize - optional minimal content size filter
      search - optional case-insensitive search on the item path (covers the file name and its folders alike)
      pageable - page, size and sort
      Returns:
      page of items
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign doesn't exist
    • compareCampaigns

      public CleanupComparison compareCampaigns(long baseCampaignId, long otherCampaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Delta between two campaigns' candidate sets, matched by node uuid. The three buckets are computed set-based by the database (three aggregate queries hitting the (CAMPAIGN_ID, NODE_UUID) unique index): neither campaign's candidate set is ever loaded in memory, so the endpoint stays bounded whatever the campaign size.
      Parameters:
      baseCampaignId - base campaign identifier
      otherCampaignId - compared campaign identifier
      Returns:
      comparison counters and bytes
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when either campaign doesn't exist
    • getMyItems

      public org.springframework.data.domain.Page<CleanupCampaignItem> getMyItems(String username, String search, org.springframework.data.domain.Pageable pageable) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Items of the currently relevant campaign owned by the user (own files and files of spaces they manage). The paging AND the ordering are the caller's (the REST layer validates the requested sort field against its allowlist and appends the stable tiebreaker), so this method never imposes an order of its own.
      Parameters:
      username - user
      search - optional case-insensitive search on the item path (covers the file name and its folders alike)
      pageable - page, size and sort
      Returns:
      page of items
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when no relevant campaign exists
    • getMyItemsSummary

      public CleanupUserSummary getMyItemsSummary(String username) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Per-user summary of the currently relevant campaign.
      Parameters:
      username - user
      Returns:
      summary (candidates/kept during grace, outcome once completed)
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when no relevant campaign exists, or when the relevant campaign does not concern this user at all — no candidate, nothing kept, nothing ever purged for them. The spec's REST table pins 404 on "no campaign concerns the user", and the header button hides on it (its fetch treats any error as 'nothing to review'), so an unaffected user is told so by the CONTRACT rather than by a 200 full of zeros the client has to interpret
    • checkArchiveAvailable

      public void checkArchiveAvailable(long campaignId) throws org.exoplatform.commons.exception.ObjectNotFoundException
      Availability check of a campaign's CSV report, to run BEFORE a single byte is streamed: once the response body started flowing the HTTP status is already committed, so the not-found outcome has to be settled here. Only metadata is read (never the archive content).
      Parameters:
      campaignId - campaign identifier
      Throws:
      org.exoplatform.commons.exception.ObjectNotFoundException - when the campaign or its report doesn't exist anymore
    • writeArchiveCsv

      public void writeArchiveCsv(long campaignId, OutputStream outputStream) throws IOException
      Streams the CSV report of a campaign: generated page by page while item detail is retained (each page is flushed, so the first rows reach the client immediately and the whole report is never materialized in memory), copied from the FileService archive afterwards. Availability must have been settled by checkArchiveAvailable(long) first.
      Parameters:
      campaignId - campaign identifier
      outputStream - stream to write the CSV to, left open for the container to close
      Throws:
      IOException - when writing to the client stream fails
    • applyRetention

      public void applyRetention()
      Scheduled-glue entry point: keeps item detail for the last N terminal campaigns only; older reports are archived as CSV (FileService) then their item detail is purged. Campaign headers are retained forever.
    • getMaxScanThreads

      public int getMaxScanThreads()
      Returns:
      the highest reader-thread count a campaign may request, so the creation form can bound its input on the SAME number the server validates against — a form inventing its own bound is a form that disagrees with the server the day one of them changes