Interface CleanupCampaignItemDAO

All Superinterfaces:
org.springframework.data.repository.CrudRepository<CleanupCampaignItemEntity,Long>, org.springframework.data.jpa.repository.JpaRepository<CleanupCampaignItemEntity,Long>, org.springframework.data.repository.ListCrudRepository<CleanupCampaignItemEntity,Long>, org.springframework.data.repository.ListPagingAndSortingRepository<CleanupCampaignItemEntity,Long>, org.springframework.data.repository.PagingAndSortingRepository<CleanupCampaignItemEntity,Long>, org.springframework.data.repository.query.QueryByExampleExecutor<CleanupCampaignItemEntity>, org.springframework.data.repository.Repository<CleanupCampaignItemEntity,Long>

public interface CleanupCampaignItemDAO extends org.springframework.data.jpa.repository.JpaRepository<CleanupCampaignItemEntity,Long>
  • Field Summary

    Fields
    Modifier and Type
    Field
    Description
    static final String
    The same column spliced into JPQL text, i.e.
    static final String
    The persisted reclaimable figure of an item — the bytes the item's OWN action frees: DELETE: the content size PLUS versionsSize.
  • Method Summary

    Modifier and Type
    Method
    Description
    aggregateItemsAbsentFromCampaign(long campaignId, long otherCampaignId)
    Count and reclaimable-bytes sum of the items of campaignId whose node uuid is ABSENT from otherCampaignId.
    aggregateItemsSharedWithCampaign(long campaignId, long otherCampaignId)
    Count and reclaimable-bytes sum of the items of campaignId whose node uuid ALSO belongs to otherCampaignId — the 'persisting' bucket of a campaign comparison, computed set-based by the database (never by loading both candidate sets in memory).
    long
    countByCampaignIdAndOwnerIdentityIdInAndState(long campaignId, List<Long> ownerIdentityIds, String state)
     
    long
    countByCampaignIdAndState(long campaignId, String state)
     
    countFailuresByReason(long campaignId, String state)
    Per-reason item counts of a campaign's failures, in ONE grouped query (rows: failure reason, item count).
    void
    deleteByCampaignId(long campaignId)
    Drops every item row of a campaign in ONE statement.
    boolean
    existsByCampaignId(long campaignId)
     
    Per-campaign, per-state item aggregates of a whole campaigns list in ONE grouped query (rows: campaignId, state, item count, reclaimable bytes sum, reclaimed bytes sum), folded by the Storage layer into one aggregate per campaign — replaces up to 4 aggregate queries PER campaign on the list endpoint.
    findByCampaignIdAndIdGreaterThanOrderByIdAsc(long campaignId, long lastId, org.springframework.data.domain.Pageable pageable)
    KEYSET page of a campaign's items in id order — the CSV export walk.
    findByCampaignIdAndNodeUuidIn(long campaignId, Collection<String> nodeUuids)
     
    findByCampaignIdAndPathTouchedBy(long campaignId, List<String> ancestorPaths, String escapedEventPath)
    Items touched by a JCR event path, in two index-friendly directions (the column always stays on the LEFT of the LIKE, the parameter on the right): items that are the event node or one of its ancestors: exact match against the ancestor chain computed in Java by the Storage layer items below the event node (a deleted/moved FOLDER fires a single JCR event for the top-most node only): prefix LIKE on the event path, with its '_'/'%' wildcards escaped by the Storage layer ('|' escape character), so padded segments like /Users/j___/ never wildcard-match unrelated rows
    org.springframework.data.domain.Page<CleanupCampaignItemEntity>
    findByCampaignIdAndState(long campaignId, String state, org.springframework.data.domain.Pageable pageable)
     
    org.springframework.data.domain.Page<CleanupCampaignItemEntity>
    findByFilters(long campaignId, Long ownerIdentityId, String state, String action, Long minSize, String searchPattern, org.springframework.data.domain.Pageable pageable)
    Campaign items matching every provided filter, each one null-tolerant so a single query serves any combination.
    org.springframework.data.domain.Page<CleanupCampaignItemEntity>
    findByOwnersAndSearch(long campaignId, List<Long> ownerIdentityIds, String searchPattern, org.springframework.data.domain.Pageable pageable)
    The user-review counterpart of findByFilters(long, java.lang.Long, java.lang.String, java.lang.String, java.lang.Long, java.lang.String, org.springframework.data.domain.Pageable): the items of the campaign owned by the given identities (the user plus the spaces they manage), optionally narrowed by the same null-tolerant path search — see that method's javadoc for the pattern contract and the index note.
    findByStateOrderedByReclaimableBytes(long campaignId, String state, Long lastReclaimableBytes, long lastId, org.springframework.data.domain.Pageable pageable)
    KEYSET page of the items of a campaign in a given state, BIGGEST FIRST — the order the purge worker consumes them in.
    Campaign ids that have item rows but NO campaign row — rows nothing can ever read again, every item query being scoped by campaign id.
    findRetryableFailures(long campaignId, String state, Collection<String> failureReasons, long maxAttemptCount, long lastId, org.springframework.data.domain.Pageable pageable)
    KEYSET page of the RETRYABLE failures of a campaign: SKIPPED items whose failure reason belongs to the allowlist the Service owns and whose attempt count is still under the bound.
    long
    sumReclaimableBytesByOwnersAndState(long campaignId, List<Long> ownerIdentityIds, String state)
     
    long
    sumReclaimableBytesByState(long campaignId, String state)
     
    long
    sumReclaimedBytes(long campaignId)
     
    long
    sumReclaimedBytesByOwners(long campaignId, List<Long> ownerIdentityIds)
     

    Methods inherited from interface org.springframework.data.repository.CrudRepository

    count, delete, deleteAll, deleteAll, deleteAllById, deleteById, existsById, findById, save

    Methods inherited from interface org.springframework.data.jpa.repository.JpaRepository

    deleteAllByIdInBatch, deleteAllInBatch, deleteAllInBatch, deleteInBatch, findAll, findAll, flush, getById, getOne, getReferenceById, saveAllAndFlush, saveAndFlush

    Methods inherited from interface org.springframework.data.repository.ListCrudRepository

    findAll, findAllById, saveAll

    Methods inherited from interface org.springframework.data.repository.ListPagingAndSortingRepository

    findAll

    Methods inherited from interface org.springframework.data.repository.PagingAndSortingRepository

    findAll

    Methods inherited from interface org.springframework.data.repository.query.QueryByExampleExecutor

    count, exists, findAll, findBy, findOne
  • Field Details

    • RECLAIMABLE_BYTES_PROPERTY

      static final String RECLAIMABLE_BYTES_PROPERTY
      The persisted reclaimable figure of an item — the bytes the item's OWN action frees:
      • DELETE: the content size PLUS versionsSize. A hard delete destroys the file's whole version history along with its content, and CleanupJcrStorage#deleteNode reports exactly that sum back as reclaimedBytes — summing content alone under-reported every DELETE candidate by the entire weight of its versions, on the very number an administrator publishes a campaign on
      • PURGE_VERSIONS: versionsSize alone, the content being untouched
      This works as a plain sum because versionsSize means 'the version bytes THIS action reclaims', not 'the whole history' nor 'the purgeable subset': the scan writes the whole history on a DELETE row and the removal set on a PURGE_VERSIONS one (see CleanupJcrStorage#toCandidate), so prediction and execution report the same figure.

      IT WAS A JPQL CASE EXPRESSION AND IS NOW A COLUMN, because no index can serve an ORDER BY over an expression: the purge consumes its candidates biggest first, which turned every batch into a filesort over the campaign's remaining rows — 318 ms against 4 ms per batch on 200k rows, tens of thousands of batches per run, and the cost growing with the square of the item count. Exactly the shape keyset paging replaced offset paging to avoid, reintroduced through the ordering. Stored, ONE index (IDX_DOC_CLEANUP_ITEM_RECLAIMABLE) serves the keyset predicate and its order together.

      The denormalization removes a duplicate rather than adding one: the definition lives in CleanupSizeUtil, applied by CleanupCampaignStorage#toEntity on every save, and this column is now the single thing SQL and the in-memory comparator both read — where the CASE expression and the Java method used to mirror each other and could drift.

      Named as the ENTITY ATTRIBUTE here (no query alias), which is also the logical sort key the REST layer accepts (CleanupConstants#RECLAIMABLE_SORT_KEY) — an equality pinned by a test, since it is what lets the review list be ordered by an ordinary property sort instead of an unsafe one.

      See Also:
    • RECLAIMABLE_BYTES

      static final String RECLAIMABLE_BYTES
      The same column spliced into JPQL text, i.e. WITH the query alias — and the alias is why these are two constants rather than one. Spring Data appends the alias itself to a safe Sort property, so handing it i.reclaimableBytes renders i.i.reclaimableBytes and the query does not parse; JPQL text, conversely, needs the alias. Derived by concatenation so the column can only be renamed in one place (and so both stay compile-time constants, which the switch over sort keys requires).
      See Also:
  • Method Details

    • findByCampaignIdAndIdGreaterThanOrderByIdAsc

      List<CleanupCampaignItemEntity> findByCampaignIdAndIdGreaterThanOrderByIdAsc(long campaignId, long lastId, org.springframework.data.domain.Pageable pageable)
      KEYSET page of a campaign's items in id order — the CSV export walk. A Page-returning offset walk here cost one count(*) per page and a linearly deepening OFFSET scan: on a million-row report that is a thousand count queries over the very table the purge keyset-pages to avoid scanning — this is its export sibling, id-keyset because the export order is the id order and nothing mutates the rows underneath it.
    • findByCampaignIdAndState

      org.springframework.data.domain.Page<CleanupCampaignItemEntity> findByCampaignIdAndState(long campaignId, String state, org.springframework.data.domain.Pageable pageable)
    • findByStateOrderedByReclaimableBytes

      @Query("SELECT i FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND i.state = :state AND (:lastReclaimableBytes IS NULL OR i.reclaimableBytes < :lastReclaimableBytes OR (i.reclaimableBytes = :lastReclaimableBytes AND i.id > :lastId)) ORDER BY i.reclaimableBytes DESC, i.id ASC") List<CleanupCampaignItemEntity> findByStateOrderedByReclaimableBytes(@Param("campaignId") long campaignId, @Param("state") String state, @Param("lastReclaimableBytes") Long lastReclaimableBytes, @Param("lastId") long lastId, org.springframework.data.domain.Pageable pageable)
      KEYSET page of the items of a campaign in a given state, BIGGEST FIRST — the order the purge worker consumes them in.

      Ordered by what each row actually frees (RECLAIMABLE_BYTES, the one definition, so this can never disagree with the report the administrator published on) and NOT by id. A purge of a 14-year corpus runs for hours, and the id order is the order the scan happened to walk the tree in — so the space came back in no particular order and an administrator watching 4 GB of a promised 234 GB could not tell whether the big wins were still ahead. This front-loads them: the first batches free the most, which is also what makes stopping early a decision rather than a gamble.

      THE CURSOR IS COMPOSITE and it has to be: ordering by a computed value means the id alone no longer identifies a position. It is (reclaimableBytes DESC, id ASC) — strictly monotonic over a total order, so the worker cannot revisit a row within a run, which is the property the id-ascending keyset was there for (an offset page-0 re-read relies on every processed item leaving the state, so one item that can never be persisted feeds it back forever). The tie branch on id is what makes the order total: without it a block of equal-sized rows spanning a batch boundary would repeat some rows and drop others.

      SAFE because the key cannot move under the cursor mid-run: the values it is computed from (action, fileSize, versionsSize) are only ever refreshed on the item being processed, which then leaves the CANDIDATE state; and the freshness listener that could refresh a pending one is unregistered before the campaign reaches EXECUTING. A row whose save FAILED keeps its stored key, and the cursor is already past it.

      Parameters:
      campaignId - campaign identifier
      state - item state
      lastReclaimableBytes - reclaimable bytes of the last row seen, NULL to start from the biggest
      lastId - id of the last row seen, breaking ties on equal sizes
      pageable - page size
      Returns:
      the next items, biggest first, empty when the state is exhausted
    • findRetryableFailures

      @Query("SELECT i FROM CleanupCampaignItem i\nWHERE i.campaignId = :campaignId\nAND i.state = :state\nAND i.failureReason IN :failureReasons\nAND i.attemptCount < :maxAttemptCount\nAND i.id > :lastId\n") List<CleanupCampaignItemEntity> findRetryableFailures(@Param("campaignId") long campaignId, @Param("state") String state, @Param("failureReasons") Collection<String> failureReasons, @Param("maxAttemptCount") long maxAttemptCount, @Param("lastId") long lastId, org.springframework.data.domain.Pageable pageable)
      KEYSET page of the RETRYABLE failures of a campaign: SKIPPED items whose failure reason belongs to the allowlist the Service owns and whose attempt count is still under the bound. Keyset-paged for the same reason as above, and one that bites harder here: the requeue MUTATES the very state the filter matches on, so an offset page would skip rows as the result set shrinks underneath it.
    • countFailuresByReason

      @Query("SELECT i.failureReason, COUNT(i) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND i.state = :state GROUP BY i.failureReason") List<Object[]> countFailuresByReason(@Param("campaignId") long campaignId, @Param("state") String state)
      Per-reason item counts of a campaign's failures, in ONE grouped query (rows: failure reason, item count). Never by loading the SKIPPED rows: a campaign can hold hundreds of thousands of them, and the console only ever displays the handful of distinct reasons behind them.
    • findByCampaignIdAndNodeUuidIn

      List<CleanupCampaignItemEntity> findByCampaignIdAndNodeUuidIn(long campaignId, Collection<String> nodeUuids)
    • findByFilters

      @Query("SELECT i FROM CleanupCampaignItem i\nWHERE i.campaignId = :campaignId\nAND (:ownerIdentityId IS NULL OR i.ownerIdentityId = :ownerIdentityId)\nAND (:state IS NULL OR i.state = :state)\nAND (:action IS NULL OR i.action = :action)\nAND (:minSize IS NULL OR i.fileSize >= :minSize)\nAND (:searchPattern IS NULL OR LOWER(i.path) LIKE :searchPattern ESCAPE \'|\')\n") org.springframework.data.domain.Page<CleanupCampaignItemEntity> findByFilters(@Param("campaignId") long campaignId, @Param("ownerIdentityId") Long ownerIdentityId, @Param("state") String state, @Param("action") String action, @Param("minSize") Long minSize, @Param("searchPattern") String searchPattern, org.springframework.data.domain.Pageable pageable)
      Campaign items matching every provided filter, each one null-tolerant so a single query serves any combination.

      searchPattern is a case-insensitive contains match on the item PATH. The item table has NO name column: a CleanupCampaignItem#getName() is derived from the path's last segment, so matching the path covers the file name AND the folders above it — which is what a cleanup reviewer looks for. The pattern is built by the Storage layer (lower-cased, wildcards escaped with the '|' escape character, wrapped in '%'), never by the caller.

      The leading '%' makes the search unindexable: it scans the campaign's rows, bounded by the CAMPAIGN_ID index (and, for the user-side query below, by OWNER_IDENTITY_ID too).

    • findByOwnersAndSearch

      @Query("SELECT i FROM CleanupCampaignItem i\nWHERE i.campaignId = :campaignId\nAND i.ownerIdentityId IN :ownerIdentityIds\nAND (:searchPattern IS NULL OR LOWER(i.path) LIKE :searchPattern ESCAPE \'|\')\n") org.springframework.data.domain.Page<CleanupCampaignItemEntity> findByOwnersAndSearch(@Param("campaignId") long campaignId, @Param("ownerIdentityIds") List<Long> ownerIdentityIds, @Param("searchPattern") String searchPattern, org.springframework.data.domain.Pageable pageable)
      The user-review counterpart of findByFilters(long, java.lang.Long, java.lang.String, java.lang.String, java.lang.Long, java.lang.String, org.springframework.data.domain.Pageable): the items of the campaign owned by the given identities (the user plus the spaces they manage), optionally narrowed by the same null-tolerant path search — see that method's javadoc for the pattern contract and the index note.
    • findByCampaignIdAndPathTouchedBy

      @Query("SELECT i FROM CleanupCampaignItem i\nWHERE i.campaignId = :campaignId\nAND (i.path IN :ancestorPaths\n OR i.path LIKE CONCAT(:escapedEventPath, \'/%\') ESCAPE \'|\')\n") List<CleanupCampaignItemEntity> findByCampaignIdAndPathTouchedBy(@Param("campaignId") long campaignId, @Param("ancestorPaths") List<String> ancestorPaths, @Param("escapedEventPath") String escapedEventPath)
      Items touched by a JCR event path, in two index-friendly directions (the column always stays on the LEFT of the LIKE, the parameter on the right):
      • items that are the event node or one of its ancestors: exact match against the ancestor chain computed in Java by the Storage layer
      • items below the event node (a deleted/moved FOLDER fires a single JCR event for the top-most node only): prefix LIKE on the event path, with its '_'/'%' wildcards escaped by the Storage layer ('|' escape character), so padded segments like /Users/j___/ never wildcard-match unrelated rows
    • findAggregatesByCampaignIds

      @Query("SELECT i.campaignId, i.state, COUNT(i), COALESCE(SUM(i.reclaimableBytes), 0), COALESCE(SUM(i.reclaimedBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId IN :campaignIds GROUP BY i.campaignId, i.state") List<Object[]> findAggregatesByCampaignIds(@Param("campaignIds") List<Long> campaignIds)
      Per-campaign, per-state item aggregates of a whole campaigns list in ONE grouped query (rows: campaignId, state, item count, reclaimable bytes sum, reclaimed bytes sum), folded by the Storage layer into one aggregate per campaign — replaces up to 4 aggregate queries PER campaign on the list endpoint.
    • aggregateItemsSharedWithCampaign

      @Query("SELECT COUNT(i), COALESCE(SUM(i.reclaimableBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND EXISTS (SELECT o.id FROM CleanupCampaignItem o WHERE o.campaignId = :otherCampaignId AND o.nodeUuid = i.nodeUuid)") List<Object[]> aggregateItemsSharedWithCampaign(@Param("campaignId") long campaignId, @Param("otherCampaignId") long otherCampaignId)
      Count and reclaimable-bytes sum of the items of campaignId whose node uuid ALSO belongs to otherCampaignId — the 'persisting' bucket of a campaign comparison, computed set-based by the database (never by loading both candidate sets in memory). The correlated sub-query hits the (CAMPAIGN_ID, NODE_UUID) unique index.
      Returns:
      a single row: item count, reclaimable bytes sum
    • aggregateItemsAbsentFromCampaign

      @Query("SELECT COUNT(i), COALESCE(SUM(i.reclaimableBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND NOT EXISTS (SELECT o.id FROM CleanupCampaignItem o WHERE o.campaignId = :otherCampaignId AND o.nodeUuid = i.nodeUuid)") List<Object[]> aggregateItemsAbsentFromCampaign(@Param("campaignId") long campaignId, @Param("otherCampaignId") long otherCampaignId)
      Count and reclaimable-bytes sum of the items of campaignId whose node uuid is ABSENT from otherCampaignId. Serves BOTH asymmetric buckets of a campaign comparison by swapping the two arguments: 'new' is (base, other), 'gone' is (other, base) — the bytes are always those of the campaign passed first, exactly as the previous in-memory diff computed them.
      Returns:
      a single row: item count, reclaimable bytes sum
    • countByCampaignIdAndState

      long countByCampaignIdAndState(long campaignId, String state)
    • countByCampaignIdAndOwnerIdentityIdInAndState

      long countByCampaignIdAndOwnerIdentityIdInAndState(long campaignId, List<Long> ownerIdentityIds, String state)
    • sumReclaimableBytesByState

      @Query("SELECT COALESCE(SUM(i.reclaimableBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND i.state = :state") long sumReclaimableBytesByState(@Param("campaignId") long campaignId, @Param("state") String state)
    • sumReclaimableBytesByOwnersAndState

      @Query("SELECT COALESCE(SUM(i.reclaimableBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId AND i.ownerIdentityId IN :ownerIdentityIds AND i.state = :state") long sumReclaimableBytesByOwnersAndState(@Param("campaignId") long campaignId, @Param("ownerIdentityIds") List<Long> ownerIdentityIds, @Param("state") String state)
    • sumReclaimedBytes

      @Query("SELECT COALESCE(SUM(i.reclaimedBytes), 0) FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId") long sumReclaimedBytes(@Param("campaignId") long campaignId)
    • sumReclaimedBytesByOwners

      @Query("SELECT COALESCE(SUM(i.reclaimedBytes), 0)\nFROM CleanupCampaignItem i\nWHERE i.campaignId = :campaignId AND i.ownerIdentityId IN :ownerIdentityIds\n") long sumReclaimedBytesByOwners(@Param("campaignId") long campaignId, @Param("ownerIdentityIds") List<Long> ownerIdentityIds)
    • existsByCampaignId

      boolean existsByCampaignId(long campaignId)
    • deleteByCampaignId

      @Modifying @Transactional @Query("DELETE FROM CleanupCampaignItem i WHERE i.campaignId = :campaignId") void deleteByCampaignId(@Param("campaignId") long campaignId)
      Drops every item row of a campaign in ONE statement. DECLARED rather than derived, and that is the point: a derived deleteBy... SELECTs the matching entities and removes them one at a time, so dropping the report of a simulated campaign loaded every one of its rows into a persistence context and issued one DELETE per row — hundreds of thousands of both, on the corpus this feature exists for. One bulk statement is one round trip and no persistence context at all.

      Bulk JPQL bypasses the persistence context by design, so entities already loaded in the calling transaction are NOT evicted. Harmless at both call sites: the campaign is being deleted outright, or its report has just been archived and nothing reads those rows again.

    • findOrphanCampaignIds

      @Query("SELECT DISTINCT i.campaignId FROM CleanupCampaignItem i WHERE i.campaignId NOT IN (SELECT c.id FROM CleanupCampaign c)") List<Long> findOrphanCampaignIds()
      Campaign ids that have item rows but NO campaign row — rows nothing can ever read again, every item query being scoped by campaign id.

      They are what a JVM death in the middle of a delete leaves behind (the campaign row goes first, so that a half-deleted campaign can never be acted on), and they would otherwise sit there forever: invisible, and holding the very space this feature exists to reclaim. Swept at startup.

      Cheap despite the shape, for two reasons and NOT for the one first written here: the subquery reads the campaign table, which holds one row per campaign ever created — nothing trims it, MAX_CAMPAIGNS being the list endpoint's page size and not a constraint — but a campaign is a deliberate administrative act on a grace cycle measured in weeks, so it stays in the hundreds for years; and the outer DISTINCT is served by IDX_DOC_CLEANUP_ITEM_CAMPAIGN, which is a real index. If that table ever does grow, this query is worth re-examining — a comment claiming an enforced bound is what would stop someone doing so.