Skip to content

feat(worker): add cleanup task for unreferenced S3 attachments - #195

Open
bbornino wants to merge 6 commits into
playfulprogramming:mainfrom
bbornino:feature/191-cleanup-unreferenced-attachments
Open

feat(worker): add cleanup task for unreferenced S3 attachments#195
bbornino wants to merge 6 commits into
playfulprogramming:mainfrom
bbornino:feature/191-cleanup-unreferenced-attachments

Conversation

@bbornino

@bbornino bbornino commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Description:
Closes #191.

Adds a cleanup-attachments BullMQ task that removes S3 attachment objects with no matching post_attachments row, since sync-post can't safely delete an attachment itself when multiple branches of the same post might still be using it.

What changed

  • packages/s3/src/utils.ts: new list(bucket, prefix) helper (paginated ListObjectsV2), returning each object's key and lastModified. Reuses the existing S3 client rather than adding new client logic.
  • apps/worker/src/tasks/cleanup-attachments/processor.ts: lists S3 objects under posts/*/attachments/*, filters out anything younger than a one-hour grace period, diffs the remainder against the full set of attachmentKey values in post_attachments, and removes any orphaned object. Plain sequential loop, no concurrency/batching, errors throw and fail the job — matching the existing worker-task conventions.
  • Registered as a new task type in packages/bullmq, wired up in apps/worker/src/index.ts alongside a repeatable job registration (see open question test: add initial unit tests #1 below).
  • processor.test.ts covers: an unreferenced attachment getting removed, a referenced attachment being left alone, a no-op when there are no attachment objects, that the query has no per-post filter, and that an otherwise-unreferenced attachment inside the grace period is left alone (and never even triggers the post_attachments query).

Redesign: DB-native cleanup instead of S3-list-based orphan detection

Per James's review feedback on the original implementation, this replaces the S3-list()-and-diff
approach with a query against the attachments table directly.

Before: list every object under posts/ in S3, filter to /attachments/ keys older than the grace
period, then check each against post_attachments for references, removing whatever's left.

After: loop a single query per iteration:

DELETE FROM attachments
WHERE attachment_key = (
  SELECT attachment_key FROM attachments a
  WHERE NOT EXISTS (
    SELECT 1 FROM post_attachments pa WHERE pa.attachment_key = a.attachment_key
  )
  AND a.last_modified < now() - interval '1 hour'
  LIMIT 1
)
RETURNING attachment_key, sha, width, height;

removing the returned key from S3 each time, until a pass returns nothing. This drops the S3 list()
call entirely and the attachments table now RETURNINGs exactly what changed.

One clarification on the literal query: Postgres doesn't support LIMIT directly on DELETE
(unlike MySQL) — so DELETE ... WHERE NOT EXISTS (...) LIMIT 1 RETURNING ... as originally described
isn't valid SQL as written. This implements the same intent via the subquery form above, where the
LIMIT 1 lives inside the correlated SELECT, not the DELETE itself. Wanted to flag this explicitly
in case the literal shape was assumed to be directly runnable elsewhere.

New lastModified column on attachments

attachments had no timestamp column at all before this — just attachmentKey, sha, width,
height. Added lastModified (timestamp with time zone, NOT NULL DEFAULT now()) so the grace-period
check can live in the WHERE clause above instead of coming from S3's own object timestamp via list().

The insert timing already lines up correctly for this: sync-post uploads to S3, then inserts the
attachments row, then later commits the corresponding post_attachments row — so a freshly-inserted
attachments row with no post_attachments reference yet is exactly the "mid-flight, not actually
orphaned" case the grace period exists to protect. Using the DB row's own insert time is
precise as the S3 object timestamp we were reading before.

onConflictDoNothing → onConflictDoUpdate

The attachments insert in sync-post used to be onConflictDoNothing(), since attachmentKey is
content-addressed (posts/{post}/attachments/{sha}{extension}) and a conflicting insert just means the
same content already has a row. That's still true, but with lastModified in play, a silen
conflict stops refreshing the timestamp — so a row hit by a conflicting insert (e.g. content reused
across posts/branches, or a re-added attachment whose post_attachments row was previously removed)
could carry a stale lastModified right as it's about to be newly referenced again, openin
where the cleanup sweep could delete it before the new post_attachments row commits. Switched to
onConflictDoUpdate refreshing lastModified on every insert attempt, including conflicts, to close
that.

Race safety against a concurrently-committing post_attachments insert (as opposed to the
already-committed-content-reuse case above) comes from the existing post_attachments -> a
foreign key (onDelete: "cascade"): inserting a post_attachments row takes a lock on the referenced
attachments row, which serializes against the cleanup task's DELETE. That's noted inline in the code
so it doesn't get attributed to the query being a single SQL statement, which isn't actua
safe.

Re-insert on S3-removal failure

Once a row is claimed via DELETE ... RETURNING, it's gone from the DB. If the following s3.remove()
call then fails (network blip, throttling), the old S3-list-based approach would just leave the object
for the next scheduled run to find again — it was stateless. This approach isn't: once the attachments
row is deleted, nothing else points at that key, so a failed removal would otherwise perm
that object in S3 with zero visibility, not just delay its cleanup.

To avoid that, a failed s3.remove() re-inserts the row (onConflictDoUpdate, fresh lastModified)
before re-throwing, so the job still fails loud and gets retried as a whole (per the existing worker-task
convention), but the next scheduled run can find and retry that specific key instead of l
it.

Verification

Unit tests cover the delete-loop control flow and the re-insert-on-failure path, but the grace-period and
"still referenced" checks now live entirely in the SQL WHERE clause, which the mocked tests can't
exercise. Ran a live smoke test against a disposable local Postgres 16 container (migrati
fresh, matching the pinned postgres:16.10-alpine3.22 image) covering all three cases in combination:
an unreferenced attachment past the grace period (swept), an unreferenced attachment insi
period (left alone), and a referenced-but-stale attachment (left alone despite being old). All three
came back as expected.

## Open questions for James

1. **Trigger mechanism.** The issue doesn't specify what should kick this off. I defaulted to a BullMQ repeatable job on a fixed interval (currently once/day, registered in `apps/worker/src/index.ts`), since re-registering a repeatable job with the same name/options on every worker restart is a no-op in BullMQ rather than a duplicate schedule. Open to a different trigger (e.g. a `dev/`-style manual route, or something driven off the webhook work in #7) if you'd rather not have an always-on scheduled job.

2. **Grace period threshold.** `sync-post` uploads an attachment to S3 in Phase 3 before writing the `post_attachments` row in the Phase 4 transaction, so an object could briefly look orphaned mid-flight (or permanently, if the job crashes in that window). This PR ships a one-hour grace period by default — any S3 object younger than that is never considered a deletion candidate, regardless of whether a `post_attachments` row references it yet. Wanted your read on whether one hour is the right value, or whether you'd prefer a different threshold (or a different mechanism entirely, e.g. checking job status rather than object age).

## Test plan
- [x] `pnpm test:unit` passes (lint, knip, publint, sherif, vitest across all projects)
- [x] `pnpm prettier` check clean (aside from the pre-existing `.claude/settings.local.json` exclusion)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added automated cleanup for unused post attachments in storage.
  - Cleanup runs on a recurring schedule and removes only unreferenced attachments older than the grace period.
  - Added storage object listing support, including pagination for large result sets.
  - Attachment activity timestamps are now tracked to improve cleanup accuracy.

- **Bug Fixes**
  - Protects recently uploaded and currently referenced attachments from accidental removal.
  - Failed storage deletions preserve attachment records for later retry.

- **Tests**
  - Added coverage for cleanup, retention, empty results, retries, and reference-handling scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

bbornino added 2 commits July 13, 2026 07:33
Sweeps S3 objects under posts/*/attachments/* on a repeatable interval
and removes any with no matching post_attachments row, catching
attachments orphaned by interrupted sync-post jobs.
sync-post uploads an attachment to S3 before its post_attachments row
commits, so a very recent orphan-looking object may just be mid-flight.
S3's list now returns lastModified alongside each key, and the cleanup
task skips anything younger than a one-hour grace period.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@bbornino, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d0468b63-ea09-49e0-bd5e-88b80c2f3208

📥 Commits

Reviewing files that changed from the base of the PR and between c17d85e and ac90122.

📒 Files selected for processing (6)
  • apps/worker/src/tasks/cleanup-attachments/processor.test.ts
  • apps/worker/src/tasks/cleanup-attachments/processor.ts
  • apps/worker/src/tasks/sync-post/processor.test.ts
  • packages/db/drizzle/20260807150244_fancy_blink/migration.sql
  • packages/db/drizzle/20260807150244_fancy_blink/snapshot.json
  • packages/db/src/schema/attachments.ts
📝 Walkthrough

Walkthrough

Adds attachment modification tracking and a repeatable BullMQ cleanup worker. The worker removes stale, unreferenced attachment rows and corresponding S3 objects, restores rows after S3 failures, and runs validated cleanup processing.

Changes

Attachment cleanup

Layer / File(s) Summary
Attachment timestamp persistence
packages/db/..., packages/test-fixtures/src/db-mock.ts, apps/worker/src/tasks/sync-post/*
Adds the required lastModified attachment field, updates schema artifacts and fixtures, and refreshes the timestamp on attachment conflicts.
Cleanup task processing
packages/bullmq/src/tasks/types.ts, apps/worker/src/tasks/cleanup-attachments/*
Defines CLEANUP_ATTACHMENTS, deletes stale unreferenced rows, removes their S3 objects, restores rows after S3 failures, and tests repeated, empty, successful, and failing paths.
Worker wiring and S3 support
apps/worker/src/index.ts, packages/s3/src/utils.ts, apps/worker/test-utils/setup.ts
Registers and schedules the cleanup worker, adds paginated S3 listing, and extends the S3 mock.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Worker as Worker startup
  participant Queue as BullMQ queue
  participant Processor as Cleanup processor
  participant DB as Attachments database
  participant S3
  Worker->>Queue: Schedule repeatable CLEANUP_ATTACHMENTS job
  Queue->>Processor: Execute cleanup processor
  Processor->>DB: Select stale unreferenced attachment row
  Processor->>DB: Delete attachment row
  Processor->>S3: Remove attachment object
  Processor->>DB: Restore row when S3 removal fails
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a worker task to clean up unreferenced S3 attachments.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/s3/src/utils.ts (1)

79-107: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider streaming or batching for very large attachment sets.

list() accumulates all matching S3 objects into a single in-memory array before returning. For workspaces with many posts/attachments this could consume significant memory. If scale is a concern, exposing an async iterable or accepting a callback would let the processor stream candidates without holding the full list. Not a blocker for the current scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/s3/src/utils.ts` around lines 79 - 107, Consider changing list() to
expose matching S3 objects incrementally through an async iterable or callback
instead of accumulating every object in the objects array. Preserve pagination
via continuationToken and emit each valid object as soon as it is received,
while updating callers to consume the streaming interface where appropriate.
apps/worker/src/tasks/cleanup-attachments/processor.ts (2)

26-31: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Loading all post_attachments rows into memory may not scale.

The processor selects every attachmentKey from post_attachments and builds an in-memory Set. For workspaces with a large number of attachments this query transfers and holds the full key set in memory on every run. Consider scoping the query to only the candidate keys (e.g., where attachmentKey in (...)) or streaming the comparison if the table grows large.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/cleanup-attachments/processor.ts` around lines 26 - 31,
Update the referenced-key lookup in the cleanup processor to scope
postAttachments rows to the current candidate attachment keys, using the query
builder’s attachmentKey membership filter before constructing referencedKeys.
Preserve the existing Set-based comparison while avoiding retrieval of unrelated
rows.

33-38: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Sequential deletion is safe but slow for large orphan sets.

The for...of loop awaits each s3.remove call one at a time. This is correct for error isolation but could be slow when many orphans exist. If throughput becomes a concern, consider bounded concurrency (e.g., p-limit or a simple chunked Promise.all) while preserving the fail-fast behavior on the first error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/cleanup-attachments/processor.ts` around lines 33 - 38,
Update the orphan-removal loop in the cleanup processor to delete unreferenced
keys with bounded concurrency instead of awaiting each s3.remove call
sequentially. Preserve fail-fast behavior by propagating the first deletion
error, and retain the existing skip logic and success logging for each removed
key.
apps/worker/src/tasks/cleanup-attachments/processor.test.ts (1)

10-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a test for error propagation when s3.remove throws.

The PR objective states the job "fails the job if an error occurs," but no test verifies that a throwing s3.remove causes the processor to reject. A test like vi.mocked(s3.remove).mockRejectedValueOnce(new Error("...")) asserting the processor rejects would lock in that contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/cleanup-attachments/processor.test.ts` around lines 10
- 121, Add a test alongside the existing cleanup processor tests that configures
an eligible orphaned attachment and makes s3.remove reject with an Error, then
assert that processor rejects with the same error. Reuse the existing S3 listing
and database mocks needed to reach removal, and verify the rejection propagates
without being swallowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bullmq/src/tasks/types.ts`:
- Line 50: Update the TaskOutputs mapping for Tasks.CLEANUP_ATTACHMENTS from
object to void so it matches the cleanup processor’s Promise<void> return
contract and resolves the processor type mismatch.

---

Nitpick comments:
In `@apps/worker/src/tasks/cleanup-attachments/processor.test.ts`:
- Around line 10-121: Add a test alongside the existing cleanup processor tests
that configures an eligible orphaned attachment and makes s3.remove reject with
an Error, then assert that processor rejects with the same error. Reuse the
existing S3 listing and database mocks needed to reach removal, and verify the
rejection propagates without being swallowed.

In `@apps/worker/src/tasks/cleanup-attachments/processor.ts`:
- Around line 26-31: Update the referenced-key lookup in the cleanup processor
to scope postAttachments rows to the current candidate attachment keys, using
the query builder’s attachmentKey membership filter before constructing
referencedKeys. Preserve the existing Set-based comparison while avoiding
retrieval of unrelated rows.
- Around line 33-38: Update the orphan-removal loop in the cleanup processor to
delete unreferenced keys with bounded concurrency instead of awaiting each
s3.remove call sequentially. Preserve fail-fast behavior by propagating the
first deletion error, and retain the existing skip logic and success logging for
each removed key.

In `@packages/s3/src/utils.ts`:
- Around line 79-107: Consider changing list() to expose matching S3 objects
incrementally through an async iterable or callback instead of accumulating
every object in the objects array. Preserve pagination via continuationToken and
emit each valid object as soon as it is received, while updating callers to
consume the streaming interface where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bffafa3-3312-4144-af8f-85464415f326

📥 Commits

Reviewing files that changed from the base of the PR and between d1b4e96 and 76e8422.

📒 Files selected for processing (6)
  • apps/worker/src/index.ts
  • apps/worker/src/tasks/cleanup-attachments/processor.test.ts
  • apps/worker/src/tasks/cleanup-attachments/processor.ts
  • apps/worker/test-utils/setup.ts
  • packages/bullmq/src/tasks/types.ts
  • packages/s3/src/utils.ts

Comment thread packages/bullmq/src/tasks/types.ts Outdated
TaskOutputs[Tasks.CLEANUP_ATTACHMENTS] was typed as object but the
processor returns Promise<void>, breaking the build. Also adds a test
locking in that an S3 removal failure rejects the job instead of being
swallowed.
bbornino added a commit to bbornino/hoof that referenced this pull request Jul 15, 2026
…ressed keys

CodeRabbit found that a scheduled deletion's stable job ID lets a
content-addressed attachment key get deleted even after it's been
legitimately re-referenced (e.g. identical content reappearing with
the same sha). ETag-based staleness checking can't catch this, since
identical content always produces an identical ETag whether it's the
original upload or a fresh one. Capturing LastModified at scheduling
time and verifying it's unchanged before deleting does catch it,
since S3 bumps LastModified on every write regardless of content
match - mirroring the same staleness-check pattern playfulprogramming#191/playfulprogramming#195 already
established for the orphan-sweep task.
export default createProcessor(Tasks.CLEANUP_ATTACHMENTS, async () => {
const bucket = await s3.ensureBucket(env.S3_BUCKET);

const objects = await s3.list(bucket, ATTACHMENTS_PREFIX);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After merging main - can this be modified to get a list of attachments directly from the database instead of using S3's ListObjects? (i.e. find attachments rows where no corresponding postAttachments relation exists)

This job could run a loop of DELETE FROM attachments WHERE NOT EXISTS (condition above) LIMIT 1 RETURNING attachment_key, and delete each attachment from S3 when encountered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm realizing this will also conflict with #197, but we should track the lastModified time in the attachments table and use that as a condition of deletion.

bbornino added a commit to bbornino/hoof that referenced this pull request Jul 27, 2026
…heduleS3ObjectDeletion import

Matches the existing slug+branch pattern used elsewhere in this file.
The removal-scheduling call sites this import backed were dropped by
playfulprogramming#190's schema restructuring; per James, that's intentional going
forward since attachments are now shared/reference-counted across
posts and branches, and orphan cleanup belongs to playfulprogramming#191/playfulprogramming#195's sweep
instead of inline detection here.
Conflicted in apps/worker/src/index.ts, apps/worker/test-utils/setup.ts,
and packages/bullmq/src/tasks/types.ts:
this branch's CLEANUP_ATTACHMENTS task and main's now-merged PR playfulprogramming#197
DELETE_S3_OBJECT task (playfulprogramming#188) both added entries to the same task-registry
files while in flight independently.

Resolved additively in all three - both tasks coexist, nothing dropped:
- index.ts registers both createWorker calls (cleanup-attachments and
  delete-s3-object) ahead of createHealthcheck(); this branch's repeatable
  job scheduler for CLEANUP_ATTACHMENTS is untouched
- types.ts keeps both Tasks entries, both TaskInputs entries, and both
  TaskOutputs entries, plus the DeleteS3ObjectInput/Output import
- test-utils/setup.ts keeps both s3 mock additions in the same mock
  object - this branch's list() alongside playfulprogramming#188's getLastModified()/
  unmodifiedSince()

Full build:all and test:unit gates pass after resolution.
…w feedback

Replaces the S3-list-based orphan sweep with a DB-native DELETE ... WHERE
NOT EXISTS ... RETURNING loop against the attachments table, per James's
review feedback on playfulprogramming#195. Adds a lastModified column to attachments, used
as the grace-period condition in place of S3 object timestamps, and
switches the sync-post insert from onConflictDoNothing to
onConflictDoUpdate so a conflicting insert (content reused across posts/
branches) still refreshes the timestamp instead of silently no-opping.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/db/src/schema/attachments.ts (1)

8-10: 🚀 Performance & Scalability | 🔵 Trivial

Consider an index for the cleanup predicate.

apps/worker/src/tasks/cleanup-attachments/processor.ts filters on lastModified with lt(...) combined with a NOT EXISTS anti-join on post_attachments. The cleanup loop runs this query once per deleted row. If the attachments table grows large, add an index on last_modified to keep each iteration cheap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema/attachments.ts` around lines 8 - 10, Add an index for
the lastModified column in the attachments schema so cleanup queries using the
lt(...) predicate can efficiently find stale rows. Define it alongside the
attachments table schema and preserve the existing timestamp definition and
post_attachments anti-join behavior.
apps/worker/src/tasks/cleanup-attachments/processor.test.ts (1)

15-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Grace-period behavior is not covered.

The PR objectives list grace-period tests. These tests mock the query at the .where(expect.anything()) level, so the lt(attachments.lastModified, staleBefore) predicate is never asserted. vi.setSystemTime(NOW) is set in every test, but no assertion reads the cutoff value.

Add an assertion on the arguments passed to the mocked where, or capture staleBefore and confirm it equals NOW minus one hour. Without it, a change to GRACE_PERIOD_MS or an accidental removal of the lt predicate passes the suite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/cleanup-attachments/processor.test.ts` around lines 15
- 46, Add a grace-period assertion to the cleanup processor tests around the
mocked deleteAttachmentReturning query, verifying that its where predicate
includes a cutoff equal to NOW minus one hour. Capture or inspect the argument
passed to where rather than using only expect.anything(), while preserving the
existing S3 removal and empty-result assertions.
apps/worker/src/tasks/sync-post/processor.test.ts (1)

703-710: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the onConflictDoUpdate change.

These assertions confirm the insert payload includes lastModified. They do not confirm the conflict behavior changed from onConflictDoNothing to onConflictDoUpdate. That change is the mechanism that prevents the cleanup sweep from deleting a re-used attachment key. Add an assertion on the mocked onConflictDoUpdate for the attachments table, similar to the assertion in apps/worker/src/tasks/cleanup-attachments/processor.test.ts at lines 105-108.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/tasks/sync-post/processor.test.ts` around lines 703 - 710,
Add test coverage in the attachment processing test around the existing
`db.insert(attachments).values` assertions to verify the mocked
`onConflictDoUpdate` call, matching the pattern used by the cleanup-attachments
test. Assert the attachments conflict-update configuration rather than only the
insert payload, confirming reused attachment keys trigger the update behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/worker/src/tasks/cleanup-attachments/processor.ts`:
- Around line 10-14: Update the cleanup processor callback passed to
createProcessor to accept its abort signal and check signal.aborted before each
iteration of the for loop, returning promptly when cancellation occurs. Replace
the unbounded loop in the cleanup flow with a bounded iteration strategy if an
existing per-run limit is available, while preserving the current bucket and
staleBefore setup and deletion behavior.
- Around line 25-45: Update the delete condition in the cleanup processor to use
inArray() with attachments.attachmentKey and candidateKey instead of eq(). Leave
the correlated notExists() query and candidateKey selection unchanged.

---

Nitpick comments:
In `@apps/worker/src/tasks/cleanup-attachments/processor.test.ts`:
- Around line 15-46: Add a grace-period assertion to the cleanup processor tests
around the mocked deleteAttachmentReturning query, verifying that its where
predicate includes a cutoff equal to NOW minus one hour. Capture or inspect the
argument passed to where rather than using only expect.anything(), while
preserving the existing S3 removal and empty-result assertions.

In `@apps/worker/src/tasks/sync-post/processor.test.ts`:
- Around line 703-710: Add test coverage in the attachment processing test
around the existing `db.insert(attachments).values` assertions to verify the
mocked `onConflictDoUpdate` call, matching the pattern used by the
cleanup-attachments test. Assert the attachments conflict-update configuration
rather than only the insert payload, confirming reused attachment keys trigger
the update behavior.

In `@packages/db/src/schema/attachments.ts`:
- Around line 8-10: Add an index for the lastModified column in the attachments
schema so cleanup queries using the lt(...) predicate can efficiently find stale
rows. Define it alongside the attachments table schema and preserve the existing
timestamp definition and post_attachments anti-join behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 847a349e-6d4a-4174-8090-a27245d61872

📥 Commits

Reviewing files that changed from the base of the PR and between 1440d64 and c17d85e.

📒 Files selected for processing (8)
  • apps/worker/src/tasks/cleanup-attachments/processor.test.ts
  • apps/worker/src/tasks/cleanup-attachments/processor.ts
  • apps/worker/src/tasks/sync-post/processor.test.ts
  • apps/worker/src/tasks/sync-post/processor.ts
  • packages/db/drizzle/20260807133839_sudden_micromacro/migration.sql
  • packages/db/drizzle/20260807133839_sudden_micromacro/snapshot.json
  • packages/db/src/schema/attachments.ts
  • packages/test-fixtures/src/db-mock.ts

Comment thread apps/worker/src/tasks/cleanup-attachments/processor.ts Outdated
Comment thread apps/worker/src/tasks/cleanup-attachments/processor.ts Outdated
Switches the candidate-key comparison from eq() to inArray() per
CodeRabbit's suggestion - confirmed via a live toSQL() check that eq()
was already correctly inlining the subquery, so this is a clarity change
rather than a correctness fix. Fixes abort-signal handling so a timed-out
job stops looping instead of continuing to run after BullMQ has already
marked it failed. Adds a covering test for the lastModified cutoff value
and the onConflictDoUpdate call in sync-post, and adds an index on
attachments.lastModified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cleanup task for unreferenced attachments

2 participants