Skip to content

Add hourly cron to garbage-collect ingest jobs with no documents#143

Open
tifa2UP wants to merge 2 commits into
mainfrom
feat/cleanup-empty-ingest-jobs
Open

Add hourly cron to garbage-collect ingest jobs with no documents#143
tifa2UP wants to merge 2 commits into
mainfrom
feat/cleanup-empty-ingest-jobs

Conversation

@tifa2UP

@tifa2UP tifa2UP commented Jul 16, 2026

Copy link
Copy Markdown
Member

Why

Deleting documents (individually or "delete all") never deletes the parent ingest job — only deleteIngestJob removes job rows. So jobs whose documents were all deleted linger forever. Besides the stale rows, they permanently squat on their @@unique([namespaceId, externalId]) slot, blocking that externalId from ever being reused.

What

A new hourly trigger.dev cron (cleanup-empty-ingest-jobs, 0 * * * *) sweeps ingest jobs that have no documents and deletes them, keeping namespace.totalIngestJobs / organization.totalIngestJobs in sync and emitting ingest_job.deleted webhooks.

Eligibility — a job is only collected when all of these hold:

  • terminal status (COMPLETED / FAILED / CANCELLED). In-flight jobs legitimately have no documents yet; DELETING/CANCELLING/QUEUED_FOR_DELETE are owned by another workflow that deletes the row and updates counters itself.
  • zero document rows (documents: { none: {} }NOT EXISTS, probed via the existing document(ingestJobId, …) index).
  • updatedAt older than 24h. The grace keeps zero-result crawls and failed jobs (the user's error surface) visible for a day, and gives API consumers polling a terminal job a day to read its final status before the id 404s.
  • namespace is ACTIVE — a namespace mid-teardown owns its jobs (and deliberately suppresses webhooks for them).

Efficiency / correctness on critical infra:

  • Keyset pagination (id > cursor, PK order) in batches of 100, hard cap of 1000 batches/run with a warning log — the cap and any skips are logged, never silent.
  • Jobs are grouped per namespace: one guarded deleteMany + one grouped counter decrement (namespace + nested org) per group, instead of per-job writes.
  • The full filter is re-checked inside the delete transaction, so a job that was concurrently claimed by deleteIngestJob (status → DELETING), touched since the scan (fresh updatedAt resets its grace), or whose namespace started tearing down is skipped — no double-decrements, no deleting under an active workflow.
  • Counters and webhooks are driven by the exact set of ids the transaction deleted (survivor re-select only on the rare count mismatch), so raced jobs get their webhook from whichever path actually deleted them — nothing is dropped and nothing is emitted twice.
  • Webhook emission uses a new emitBulkIngestJobWebhooks helper that fetches the org's webhook list once per namespace group (mirrors the existing emitBulkDocumentWebhooks), early-exits when the org has no webhooks, and is best-effort after commit (failure logs instead of re-sweeping already-deleted rows).
  • queue: { concurrencyLimit: 1 } so hourly runs never overlap; the loop is idempotent under retries regardless.

Notes / follow-ups

  • No index added on purpose: terminal statuses are most of the table, so a status index has poor selectivity; the dominant cost is the NOT EXISTS probes, which no ingest_job index removes. In steady state each run costs one pass of index probes over terminal jobs — fine at current table sizes. If it ever shows up in db metrics, the better shape is event-driven: check-and-delete the parent job in the document-deletion path and keep this cron as a backstop.
  • The 24h grace is one constant (GRACE_MS) — easy to tune if we want empties gone faster.
  • Pre-existing sliver worth a separate fix: the re-ingest endpoints trigger the task before flipping job status, so a job deleted between those two steps can 500. The delete-time grace re-check makes this near-impossible to hit via this cron, but reordering the endpoint would close it fully.
  • Typecheck/lint: the touched packages pass; the only failures are the pre-existing packages/emails --jsx errors, identical on main.

Greptile Summary

This PR adds an hourly Trigger.dev cron job that garbage-collects ingest jobs whose documents were all deleted — a gap where the parent job row lingered indefinitely and permanently occupied its @@unique([namespaceId, externalId]) slot. It also introduces emitBulkIngestJobWebhooks as a clean mirror of the existing emitBulkDocumentWebhooks helper.

  • The cron uses keyset pagination with a hard cap, a DELETE ... RETURNING that re-checks every eligibility condition atomically inside a transaction, and per-namespace batched counter decrements — making it correct under concurrent deleteIngestJob runs.
  • Jobs are only collected after a 24-hour grace period and only when the namespace is ACTIVE, preventing races with in-flight workflows and namespace teardown.
  • queue: { concurrencyLimit: 1 } ensures runs never overlap; the task directory is already included in trigger.config.ts so no registration change is needed.

Confidence Score: 5/5

Safe to merge; the cron is idempotent, isolated behind a concurrency limit, and carefully guards against double-decrements via RETURNING-based accounting.

The concurrency design is thorough: the DELETE … RETURNING re-check eliminates the double-decrement/double-webhook class of bugs, keyset pagination guarantees forward progress even when jobs are skipped, and the catch block correctly absorbs mid-sweep namespace teardown. The one new finding is a minor maintainability nit (raw 'ACTIVE' string in SQL instead of the imported constant). All previously identified concerns have been addressed in the current code.

No files require special attention; cleanup-empty-ingest-jobs.ts has the most logic but its concurrency guards are well-reasoned.

Important Files Changed

Filename Overview
packages/jobs/src/cron/cleanup-empty-ingest-jobs.ts New hourly cron that garbage-collects empty ingest jobs with keyset pagination, a RETURNING-based atomic delete, and per-namespace counter decrements; well-designed with strong concurrency guards, though the webhook payload hardcodes status: "DELETING" regardless of the actual terminal status
packages/jobs/src/webhook.ts Adds emitBulkIngestJobWebhooks wrapper mirroring the existing emitBulkDocumentWebhooks pattern; thin delegation to the base implementation, no issues
packages/webhooks/src/emit.ts Adds emitBulkIngestJobWebhooks as a direct mirror of emitBulkDocumentWebhooks; fetches webhooks once per namespace group, filters and fans out via Promise.all, consistent with existing patterns

Reviews (2): Last reviewed commit: "Fix deleted-set attribution race and rev..." | Re-trigger Greptile

Deleting documents never deletes the parent ingest job, so jobs whose
documents were all deleted linger forever (and keep squatting on their
namespace-unique externalId). A new hourly trigger.dev cron sweeps
terminal (COMPLETED/FAILED/CANCELLED) jobs that have had no documents
for 24 hours and deletes them.

- keyset-paginated scan, one deleteMany + one grouped counter decrement
  per namespace instead of per-job operations
- full filter re-checked inside the delete transaction so concurrent
  deleteIngestJob runs, re-ingests (fresh updatedAt), and namespace
  teardowns (status != ACTIVE) are never raced or double-decremented
- counters and ingest_job.deleted webhooks are driven by the exact set
  of ids the transaction deleted
- new emitBulkIngestJobWebhooks helper fetches the org webhook list once
  per namespace group (mirrors emitBulkDocumentWebhooks)
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app-agentset-ai Ready Ready Preview, Comment Jul 16, 2026 1:12pm

Request Review

Comment on lines +109 to +150
try {
deletedIds = await db.$transaction(async (tx) => {
// re-check the FULL filter at delete time: a job that gained a
// concurrent deleteIngestJob run (status -> DELETING), was
// touched since the scan (fresh updatedAt resets its grace), or
// sits in a namespace that started tearing down is skipped
const candidates = await tx.ingestJob.findMany({
where: { id: { in: ids }, ...emptyJobFilter },
select: { id: true },
});
if (candidates.length === 0) return [];

const candidateIds = candidates.map((candidate) => candidate.id);
const { count } = await tx.ingestJob.deleteMany({
where: { id: { in: candidateIds }, ...emptyJobFilter },
});

// rare: a row changed between the two statements — re-derive
// exactly which ids this transaction deleted, so webhooks are
// emitted for precisely those and counters match reality
let deleted = candidateIds;
if (count !== candidateIds.length) {
const survivors = await tx.ingestJob.findMany({
where: { id: { in: candidateIds } },
select: { id: true },
});
const surviving = new Set(
survivors.map((survivor) => survivor.id),
);
deleted = candidateIds.filter((id) => !surviving.has(id));
}

if (deleted.length > 0) {
await tx.namespace.update({
where: { id: namespaceId },
data: {
totalIngestJobs: { decrement: deleted.length },
organization: {
update: { totalIngestJobs: { decrement: deleted.length } },
},
},
select: { id: true },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Survivors re-select over-attributes concurrent deletions

After deleteMany returns a count mismatch, the survivors query runs under READ COMMITTED isolation, meaning it sees rows deleted by any committed transaction — including a concurrent deleteIngestJob that committed between the re-check findMany and the deleteMany. A job that deleteIngestJob deleted in that narrow window will appear absent from survivors and be added to deleted, causing the cron to decrement totalIngestJobs a second time and emit a duplicate ingest_job.deleted webhook for it.

The protection against deleteIngestJob relies on deleteIngestJob updating status to DELETING (which is not in DELETABLE_STATUSES) before the cron's re-check findMany. If that status update commits after the re-check findMany but the full deletion (status update + delete + counter) commits before the cron's deleteMany, the row disappears from survivors even though our deleteMany got 0 for it — and the counter and webhook double-fire.

A reliable fix is to use the count from deleteMany as the authoritative total (using Prisma $queryRaw with RETURNING id is the cleanest approach), so only rows our transaction actually removed drive counter and webhook logic. The 24-hour grace period makes this race very rare in practice, but the code comment on line 148 explicitly claims correctness in this scenario.

};

let totalDeleted = 0;
let totalSkipped = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Batch-cap warning can be a false positive

The while (batches < MAX_BATCHES) guard exits once batches === MAX_BATCHES, after which the warning "Cleanup stopped at the batch cap with jobs remaining" fires unconditionally. However, if the total number of eligible jobs is exactly MAX_BATCHES × BATCH_SIZE, the last batch fills and batches reaches the cap — but the next iteration would return zero rows and stop naturally. The warning fires claiming there are remaining jobs when there are none.

Consider checking whether the final batch was full (jobs.length < BATCH_SIZE) before emitting the warning, to avoid a spurious alert that could mask real cap-hits.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread packages/jobs/src/cron/cleanup-empty-ingest-jobs.ts
Comment on lines +141 to +152
if (deleted.length > 0) {
await tx.namespace.update({
where: { id: namespaceId },
data: {
totalIngestJobs: { decrement: deleted.length },
organization: {
update: { totalIngestJobs: { decrement: deleted.length } },
},
},
select: { id: true },
});
}

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.

needs to update page counters / other counters too too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dug into this before changing it, because I initially expected the same thing — turns out for jobs that can reach this cron, the remaining page/doc delta is provably zero, and decrementing here would double-count:

  • Pages/documents are document-lifecycle counters: they're incremented per-document during ingest (ingest.ts increments totalDocuments at doc creation, totalPages at job end for completed docs), and every path that deletes a document settles them at that moment — delete-document.ts (updateCounters: true) decrements namespace.totalPages/totalDocuments + org.totalDocuments and credits org.deletedPages; delete-ingest-job.ts applies the same shape once, aggregated. Job creation only ever increments totalIngestJobs — so that's the only counter an empty job still owes, which this cron settles.
  • The two ways a terminal job ends up empty: (1) all its docs were deleted through the paths above → their pages/docs were already settled per-document (a second decrement here would double-count them); (2) it never produced documents (empty crawl, failed before doc creation) → nothing was ever counted, so the only correct delta is zero.
  • Mechanically there's also nothing to decrement by: the ingest_job row stores no page/doc totals, and by definition there are no document rows left to sum.

That said — the underlying instinct is right that page counters do drift, just via other paths, and the magnitude is unknowable by the time the doc rows are gone (so no per-job decrement here can repair it):

  1. delete-ingest-job.ts — a child doc-delete that fails all retries gets cascade-deleted by the parent's ingestJob.delete with zero counter adjustment; a parent crash after children deleted rows strands their counters permanently (job stuck in DELETING).
  2. delete-namespace.ts:63-74 — child results are never inspected; failed children's jobs/docs get cascade-deleted while only totalNamespaces is decremented → org.totalPages/totalDocuments/totalIngestJobs inflated forever.
  3. re-ingest.ts:108-119 — sets completedAt: null on already-counted docs without touching counters; if the resync fails and the doc is later deleted, delete-document.ts:141 credits 0 and the pages leak (this is the likely mechanism behind the stuck 33k totalPages in the customer report).

Happy to do a follow-up PR with the source fixes for those three + a reconciliation pass (recompute namespace.totalPages/totalDocuments from live docs for quiescent namespaces and push any org-side excess into deletedPages so the existing billing-cycle release clears it — keeps the org.totalPages == Σ namespace.totalPages + deletedPages invariant intact). If you're seeing a specific path where docs vanish without counter updates that isn't one of these three, point me at it and I'll trace it.

- Replace the candidates-select + deleteMany + survivors-select with a
  single DELETE ... RETURNING inside the transaction. The survivors
  re-select ran on a fresh READ COMMITTED snapshot, so a job deleted by
  a concurrent deleteIngestJob (its status flip and final delete are
  milliseconds apart for empty jobs) could be misattributed to the
  cron's own deleted set, double-decrementing totalIngestJobs and
  double-emitting the webhook. RETURNING reports exactly the rows this
  statement removed. Raw SQL because Prisma has no deleteManyAndReturn;
  identifiers validated with EXPLAIN against the dev schema.
- Only warn about the batch cap when the final page was full (a sweep
  landing exactly on the cap is a natural finish).
- Drop unused status from the scan select.
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.

2 participants