Skip to content

Commit e04a425

Browse files
authored
Merge pull request ggozad#385 from ggozad/feat/auto-prune
Auto-prune dead jobs when a sibling DELETE succeeds
2 parents 3828a91 + 5400085 commit e04a425

5 files changed

Lines changed: 105 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22
## [Unreleased]
33

4+
### Changed
5+
6+
- A successful DELETE job auto-prunes dead jobs with the same `(source_id, uri)`. New `JobRepo.prune_dead(source_id, uri)`.
7+
48
## [0.50.0] - 2026-05-27
59

610
### Added

haiku_rag_slim/haiku/rag/ingester/queue/repository.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,21 @@ async def release_if_claimed(self, job_id: str, claimed_by: str) -> bool:
363363
await self._conn.commit()
364364
return row is not None
365365

366+
async def prune_dead(self, source_id: str, uri: str) -> int:
367+
"""Delete dead jobs for the given (source_id, uri). Called after a
368+
successful DELETE to clear stale UPSERT failures for the same URI —
369+
the document is gone, so a "couldn't ingest this" entry is no longer
370+
actionable. Returns the number of rows removed."""
371+
async with self._lock:
372+
cursor = await self._conn.execute(
373+
"DELETE FROM jobs WHERE source_id=? AND uri=? AND status='dead'",
374+
(source_id, uri),
375+
)
376+
rowcount = cursor.rowcount or 0
377+
await cursor.close()
378+
await self._conn.commit()
379+
return rowcount
380+
366381
async def reap_stale(self, claim_timeout_seconds: int) -> int:
367382
"""Reset claimed jobs whose claimed_at is older than the timeout
368383
back to `queued`. Decrements `attempts` to undo the increment from

haiku_rag_slim/haiku/rag/ingester/workers/pool.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ async def _process(self, job: Job) -> None:
235235
logger.info("Worker pool breaker closed after successful probe")
236236
if job.op is JobOp.DELETE:
237237
await self._sync.delete(job.source_id, job.uri)
238+
# A successful DELETE resolves any earlier UPSERT failures for the
239+
# same (source_id, uri): the document is gone, the original error
240+
# is no longer actionable, the DLQ entry is just visual noise.
241+
pruned = await self._jobs.prune_dead(job.source_id, job.uri)
242+
if pruned:
243+
logger.info(
244+
"Pruned %d dead job(s) for %s after successful DELETE",
245+
pruned,
246+
job.uri,
247+
)
238248
else:
239249
await self._sync.upsert(
240250
job.source_id,

tests/ingester/test_queue.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,54 @@ async def test_reap_stale_leaves_fresh_claims_alone(jobs):
519519
assert refreshed.status is JobStatus.CLAIMED
520520

521521

522+
# --- prune_dead ---
523+
524+
525+
@pytest.mark.asyncio
526+
async def test_prune_dead_removes_matching_dead_rows(jobs):
527+
"""A dead UPSERT becomes stale once a sibling DELETE has resolved the URI;
528+
prune_dead() removes it so the DLQ stops showing resolved entries."""
529+
job = await jobs.enqueue("s", "u", JobOp.UPSERT)
530+
assert job is not None
531+
claimed = await jobs.claim_next("w")
532+
assert claimed is not None
533+
await jobs.mark_dead(claimed.id, "boom", "w")
534+
535+
pruned = await jobs.prune_dead("s", "u")
536+
assert pruned == 1
537+
assert await jobs.get_job(job.id) is None
538+
539+
540+
@pytest.mark.asyncio
541+
async def test_prune_dead_leaves_non_dead_rows_alone(jobs):
542+
"""Queued/claimed/succeeded rows for the same (source, uri) are not
543+
touched — only `dead` is purged."""
544+
queued = await jobs.enqueue("s", "u", JobOp.UPSERT)
545+
assert queued is not None
546+
547+
pruned = await jobs.prune_dead("s", "u")
548+
assert pruned == 0
549+
refreshed = await jobs.get_job(queued.id)
550+
assert refreshed is not None and refreshed.status is JobStatus.QUEUED
551+
552+
553+
@pytest.mark.asyncio
554+
async def test_prune_dead_scoped_to_matching_uri(jobs):
555+
"""Dead rows for other URIs (and other sources) survive."""
556+
j1 = await jobs.enqueue("s", "u1", JobOp.UPSERT)
557+
assert j1 is not None
558+
await jobs.mark_dead((await jobs.claim_next("w")).id, "err", "w")
559+
560+
j2 = await jobs.enqueue("s", "u2", JobOp.UPSERT)
561+
assert j2 is not None
562+
await jobs.mark_dead((await jobs.claim_next("w")).id, "err", "w")
563+
564+
pruned = await jobs.prune_dead("s", "u1")
565+
assert pruned == 1
566+
assert await jobs.get_job(j1.id) is None
567+
assert await jobs.get_job(j2.id) is not None
568+
569+
522570
# --- release_if_claimed ---
523571

524572

tests/ingester/test_workers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,34 @@ async def test_drain_delete_op_removes_sync_state(client, jobs, sync):
105105
assert snapshot == {}
106106

107107

108+
@pytest.mark.asyncio
109+
async def test_successful_delete_prunes_dead_jobs_for_same_uri(client, jobs, sync):
110+
"""Once a DELETE resolves a URI, any earlier UPSERT failure for the same
111+
(source_id, uri) is stale — auto-prune keeps the DLQ free of resolved
112+
entries."""
113+
# Stage a prior dead UPSERT (file-not-found style).
114+
upsert = await jobs.enqueue("src", "file:///gone.md", JobOp.UPSERT)
115+
assert upsert is not None
116+
claimed = await jobs.claim_next("prev-worker")
117+
assert claimed is not None
118+
await jobs.mark_dead(claimed.id, "File does not exist", "prev-worker")
119+
assert (await jobs.get_job(upsert.id)).status is JobStatus.DEAD
120+
121+
# Now run a DELETE for the same URI.
122+
client.get_document_by_uri.return_value = Document(
123+
id="doc-9", content="", uri="file:///gone.md"
124+
)
125+
delete = await jobs.enqueue("src", "file:///gone.md", JobOp.DELETE)
126+
assert delete is not None
127+
128+
pool = _pool(client, jobs, sync)
129+
await pool.drain_once()
130+
131+
assert (await jobs.get_job(delete.id)).status is JobStatus.SUCCEEDED
132+
# The stale dead UPSERT for the same URI is gone.
133+
assert await jobs.get_job(upsert.id) is None
134+
135+
108136
@pytest.mark.asyncio
109137
async def test_permanent_error_marks_dead_no_reschedule(client, jobs, sync):
110138
client.create_document_from_source.side_effect = PermanentError("unsupported")

0 commit comments

Comments
 (0)