Skip to content

fix: atomic file-quota reserve + durable indexation job state - #677

Open
Ahmath-Gadji wants to merge 33 commits into
developfrom
fix/660-durable-job-state
Open

fix: atomic file-quota reserve + durable indexation job state#677
Ahmath-Gadji wants to merge 33 commits into
developfrom
fix/660-durable-job-state

Conversation

@Ahmath-Gadji

@Ahmath-Gadji Ahmath-Gadji commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Two related fixes that share a root cause — indexing state living only in the in-memory TaskStateManager Ray actor — plus the follow-ups from review. The first two commits are the substance, quota first, then durability; the rest are fixes found reviewing them.

Closes #664
Closes #660

Rebased onto develop @ d450f14, which includes #685 (bulk cancellation) and #671 (milvus cleanup before catalog delete). Both rewrote paths this PR also rewrites, so each merge needed its own review: the #685 interaction is described in §13–15, and the #671 interaction — 14 overlapping files, including one collision that would have broken every replace re-index — in §16. A finding from the #685 review is split out into #700, stacked on this branch.


1. fix(quota): reserve file slots atomically at admission (#664)

The per-user file quota was a check/admit TOCTOU race: check_user_file_quota compared a stale users.file_count snapshot plus an in-memory pending count against the quota, and the durable count was only incremented at the end of indexing. Concurrent uploads all observed the same pre-increment state and all passed; a restart zeroed the pending count and reopened the gate.

Admission is now a single conditional UPDATE (try_reserve_file_slot), so the read and write happen under one row lock:

UPDATE users SET file_count = file_count + 1
 WHERE id = $1
   AND (is_admin
        OR COALESCE(file_quota, $2::int) < 0
        OR file_count < COALESCE(file_quota, $2::int))
RETURNING file_count

The predicate reproduces the documented resolved-quota semantics exactly. Note the COALESCE resolves the per-user quota against the global default before the < 0 test — the naive file_quota < 0 from the issue body would let a negative global default silently override an explicit per-user limit. No row returned means no slot.

file_count therefore becomes a reserved + completed counter, so the completion-time increment in add_file_to_partition is removed (it would double-count), and every reservation now needs an owner:

  • Before dispatch the request owns it. check_user_file_quota is a yield dependency whose teardown releases anything uncommitted — covering the 409 duplicate, upload rejection, workspace validation, dispatch errors, and client disconnects.
  • After dispatch the worker owns it. IndexerWorker.process_file releases in a finally — deliberately not an except, which cancellation would skip, since ray.cancel raises CancelledError (a BaseException). This covers indexing failure, cancellation, and the duplicate-at-catalog race.
  • The MCP tools (index_url, copy_file) have no dependency chain, so they reserve inline. put_file (replace re-index) never reserves; it reuses an existing row.

The in-memory pending count is no longer a correctness input anywhere, and get_current_user_info stops adding it to file_count — reserved uploads are already counted, so that double-counted in-flight files.

Status code is 403, not the 429 the issue floated: validate_file_quota already raised FILE_QUOTA_EXCEEDED as 403 and the API tests assert it. Easy to flip if reviewers prefer 429.

Admins and unlimited users are incremented too. Removing the completion-time increment while reserving only for capped users would have frozen every admin's file_count at zero, and /users/info displays it. This keeps release symmetric for all callers.

2. feat(jobs): persist indexation job state in Postgres (#660)

Job state lived only in the in-memory actor: unbounded (insert-only, no TTL/cap), volatile (wiped on restart), and unpersisted (PgJobRepository was a stub whose every method raised, with no jobs table and zero writers).

  • Migration d4e5f6a7b8c9 adds a jobs table (one row per dispatched task, id = task_id, status CHECK over the 7 states, three indexes). Guarded with table_exists/index_exists in both directions, because metadata.create_all() runs at startup and an unguarded CREATE would raise DuplicateTable. Constraints stay unnamed so the migration and create_all converge on the same Postgres-default names. No FK to partitions — a job row is a historical record and must outlive the partition it targeted.
  • Real PgJobRepository replaces the stub. IndexationJob becomes a per-task record reusing DocumentStatus (matches the actor's taxonomy verbatim; JobStatus lacked SERIALIZING/CHUNKING/INSERTING/CANCELLED).
  • Lifecycle writes: QUEUED from the dispatcher before submit (so a crash between submit and the worker's first write still leaves the job visible rather than silently in-flight), SERIALIZING/COMPLETED/FAILED from the worker, CANCELLED from cancel_task. All durable writes are best-effort by design — this is bookkeeping about the work, not the work; a Postgres blip must not fail a file that indexed correctly, nor mask the real exception on the failure path.
  • Reads hit Postgres and fall back to the actor — on a failure or on an empty result (see §6). This is required, not optional: evicting terminal entries from the actor without it would make completed work vanish from the queue views.
  • Bounding, both sides: terminal jobs swept by age (7d) + row cap (10k), throttled to once/5min and run inline from the dispatch path (no new background task to own across replicas); the actor evicts terminal tasks by cap (2000) + TTL (1h), never in-flight ones — their object_ref is not serializable. Stored tracebacks are truncated (4000 chars, tail kept).

Incidental fix: the worker was discarding set_failed_if_not_cancelled's return value. It now honours the verdict, so a durable FAILED cannot overwrite a cancellation the user already asked for and already saw.

On the in-memory pending count

get_user_pending_task_count was intentionally not routed through Postgres. A job row only leaves the active states when a worker writes a terminal transition, so a job orphaned by a crash would occupy the quota of that user permanently (retention sweeps terminal rows only) — the in-memory count is wrong in the opposite, self-healing direction. §1 makes this moot by removing the pending count from the quota decision entirely, so the combined design has no reader of it as a correctness input.

3–12. Review follow-ups

Found reviewing the two commits above; each is standalone and independently reviewable.

  • fix(quota): release the reserved slot when the SERIALIZING write fails. The pre-flight set_state ran outside the try whose finally releases, so an unreachable state actor leaked the slot permanently — the request had already handed ownership over at dispatch.
  • fix(quota): reserve for the MCP index_url / copy_file tools. They create files rows without passing through the dependency, so removing the completion-time increment made MCP files invisible on the way in but still counted on the way out — driving file_count below reality and handing back free slots on a repeatable loop. Measured at quota 5: upload 5 via HTTP, index 3 via MCP, delete those 3 → the user holds 5 files with 3 free slots.
  • fix(jobs): fail closed when listing tasks for an anonymous non-admin. list_jobs(user_id=None) means "every job". Not reachable today, but the MCP _USER_ID ContextVar defaults to None.
  • fix(jobs): treat an empty durable read as a miss, not an empty queue. The fallback only triggered when the repo raised[]/{} are not None, so zero rows read as authoritative. The actor is detached and survives the API restart that first deploys this, so every pre-cutover task is live in the cache and absent from jobs: /queue/tasks answered [] and /queue/info reported 0 active while workers were indexing. Both aggregate readers now fall through on empty; get_task_details keeps is not None, since for a single row None already is the miss.
  • fix(workers): drop a late write instead of resurrecting an evicted task. set_error/set_details/set_object_ref went through _ensure_task, which recreates on miss; the recreated entry has state=None, never re-enters terminal_at, and is never evictable again — the unbounded growth Persist indexing job state to Postgres; retire the in-memory-only TaskStateManager (unbounded, wiped on restart) #660 exists to fix. Not reachable today (the dispatcher writes set_state first), but silent and permanent if it ever is.
  • fix(jobs): upper-case a job status before it reaches the CHECK. _expand_status normalized case, _status_value did not; a lower-case status would violate ck_jobs_status, and best-effort writes would swallow it, freezing the job at its previous status.
  • refactor(workers): use the project logger in indexer_actor.
  • test(workers): pin the retention bounds and prove the throttle recovers. The shipped 7d/10k were asserted nowhere, and the throttle test also passed for an implementation that purges once per process and never again.
  • test(repos): revive the delete-cascade file_count assertion. Dead on develop (create_legacy_user signature drift), and it guards the very counter this branch redefines. Fixing the signature alone would have made it pass vacuously (0 → 0) now that the insert no longer increments, so it reserves the slots at admission the way a real upload does and asserts 2 → 0.
  • test(quota): pin that dispatch tells the worker the slot is reserved. The release paths were covered, but nothing asserted the signal that arms them: commit_quota_reservation only stops the request's teardown from releasing, while quota_reserved=True is what makes the worker take ownership. Dropping that keyword from the router leaves the rest of the unit suite green — teardown declines to release (committed) and the worker declines too (never told it owns one), so every failed upload would leak a slot in silence. It now fails exactly one test.
  • docs: stop two comments overstating their guarantees — the setup-failure release in indexer_pool releases through the store whose init just failed; truncate_error_text bounds the retained text, not the returned string.
  • fix(jobs): stop list_tasks truncating the queue silently. _LIST_LIMIT caps the page at 1000 against a retention cap of 10k, so the cap is reachable and the route returned a short list with nothing to say it was short. It now reads one row past the limit to tell an exactly-full page from a truncated one, drops the probe, and logs a warning. Real pagination is the proper fix and is left as follow-up.

13–15. Found reviewing the rebase onto #685

#685 landed a bulk-cancel UI and rewrote cancel_task to claim the cancellation before signalling ray.cancel. That is the same code path this branch makes durable, so the merge needed its own review.

  • fix(workers): mark a claimed cancellation terminal so it can be evicted. set_cancelled_if_active (from Add bulk cancellation for active jobs #685) is the only state write that does not call _mark_terminal (this branch's eviction hook). Eviction is driven entirely off terminal_at, and nothing writes that task's state again — ray.cancel raises CancelledError, a BaseException that process_file's except Exception sails past. So every user-initiated cancel retained a TaskInfo, its user_index entry and its pinned object_ref for the lifetime of the detached actor: verbatim the unbounded growth Persist indexing job state to Postgres; retire the in-memory-only TaskStateManager (unbounded, wiped on restart) #660 exists to fix, and reachable in bulk from Add bulk cancellation for active jobs #685's cancel-all button. Neither change is wrong alone; the gap is the merge. test_cancelled_tasks_are_evictable missed it because it drives set_state(..., "CANCELLED"), a path no cancellation actually takes.
  • fix(jobs): keep a cancellation sticky in the durable row too. mark_failed_if_not_cancelled arbitrates FAILED-vs-CANCELLED in SQL, but the worker's SERIALIZING/COMPLETED writes were blind UPDATEs racing the cancel's blind UPDATE — so only the direction where the cancel loses was covered. When the cancel wins: COMPLETED-over-CANCELLED leaves the actor and the table disagreeing permanently (and the two read paths answer differently for the same task); SERIALIZING-over-CANCELLED is worse, stranding the row non-terminal after ray.cancel has killed the only writer that could finish it — and retention sweeps terminal rows only, so it never ages out. update_job now carries the same guard, mirroring the stickiness TaskStateManager.set_state already enforces in memory.
  • docs(quota): name the residual ambiguity at the submit boundary. A submit timeout leaves ownership of the reserved slot genuinely undecidable — the worker may or may not have started. Teardown releases, which is right for the common case and off by one the other way; both directions undercount rather than leak.

A fourth finding from the same review — the cancel-before-start slot leak, which #685's cancel-all makes reachable at scale — is fixed in #700, stacked on this branch. It is a change to the ownership model rather than a bug fix, so it is kept separate and reviewable on its own.

Verification

Re-run on the rebased branch:

  • ruff check / ruff format --check clean; scripts/check_layer_imports.pylayer import guard: OK.
  • Unit: 1914 passed, 0 failed.
  • Integration against real Postgres: 120 passed, 1 failed. The 1 (test_create_is_idempotent_per_namecreate_partition raises PARTITION_EXISTS where the test expects the conflict swallowed) is pre-existing, confirmed identical on clean origin/develop with the same DSN, and fixed separately in test(repos): assert create_partition rejects duplicates, as it does #691.
  • On the "6 failed" reported in the previous revision: those were an artefact of the checkout, not the code. load_dotenv() (core/config/loader.py:271) is called with no argument, so it walks up from the CWD and takes the closest .env; a checkout without one of its own inherits a parent directory's, and an empty RERANKER_PROVIDER= there breaks a pydantic discriminator. A checkout with its own .env shadows it and is fully green. Unrelated to this PR either way, but it means test runs are not hermetic depending on checkout location — worth a separate look.
  • The quota concurrency claim is measured, not asserted: against a real Postgres, 20 parallel admits at quota 1 and quota 5 grant exactly 1 and exactly 5, with contiguous returned counts. The regression test was checked against a read-then-check reimplementation of the reserve — it overshoots the quota by a nondeterministic margin, observed between 14 and all 20 of the 20 concurrent admits across runs and machines — i.e. the test reliably fails on the bug it guards, though not at a fixed count.
  • Migration verified end-to-end against real Postgres: create_allupgrade head does not raise, re-upgrade is a no-op, downgrade drops and re-upgrade re-creates, and the two schemas are byte-identical across columns, PK, FK, indexes and check constraints. Names converge on jobs_pkey / jobs_user_id_fkey. Single head (d4e5f6a7b8c9).
  • Two guards were mutation-checked rather than assumed: dropping quota_reserved=True from the router, and dropping the status upper-casing in _status_value, each fail exactly one test.

Known gaps — tracked in #676

  • No startup reconciliation. A hard process death between reserve and dispatch leaks a quota slot, and an orphaned job row stays active forever. There is now a durable record to reconcile against; the sweep itself is follow-up work.
    • Retention only sweeps terminal rows, so an orphaned row is never purged; and cancel_task returns early when the actor no longer holds an object_ref, so an operator cannot force one terminal by hand either. The active roll-up in /queue/info therefore drifts upward across such events until reconciliation lands.
  • A cancel of a not-yet-started task leaks its reserved quota slot — fixed in fix(quota): arbitrate the reserved file-slot release instead of leaking it #700, stacked on this branch, and left out of this one so the ownership-model change stays separately reviewable. After dispatch the worker owns the slot and only releases it in process_file's finally, so if ray.cancel retires the task before that body runs the release never happens — and cancel_task cannot release itself without double-releasing a task cancelled mid-flight (whose finally does run). Because the cancel writes a terminal CANCELLED row, indistinguishable from a clean cancel, recovering the slot on this branch alone needs the Reconcile orphaned in-flight jobs on startup + live-Ray e2e coverage for the durable job/quota paths #676 reconciliation to recount file_count (completed files + active job rows), not merely sweep orphaned active rows.
  • A dispatch that partially succeeds (pool accepts the job, set_object_ref then raises) double-releases the slot: the worker consumes it and teardown releases it. Chosen deliberately here — under-counting is recoverable, whereas a leak permanently locks the user out. Also closed by fix(quota): arbitrate the reserved file-slot release instead of leaking it #700.
  • No live-Ray e2e. The dispatch → worker → Postgres path is proven at unit and repo-integration level only.

Review notes

  • JobService.list_tasks caps at 1000 rows and now says so when it truncates (§12). Filtering happens in SQL before the cap, so the cap cannot leak another user's jobs.
  • JobStatus is now unused; left in place rather than change a public model export. IndexationJob.total_documents was dropped — it had no readers and the model is internal (it appears in no API response).
  • The actor's TTL is swept lazily, only when some task settles — an idle deployment keeps its last terminal entries cached past the hour. Harmless (a terminal state is immutable, so a stale read is not a wrong read) and the cap is the real memory bound; noted because the comment used to claim otherwise.

16. Found reviewing the rebase onto #671

Rebased onto develop @ d450f149, which includes #671 (fix/658-milvus-cleanup-before-catalog-delete). #671 rewrote the indexing dispatch, cancellation and delete paths that this branch also rewrites — 14 files overlap — so, as with #685 in §13–15, the merge needed its own review rather than a textual resolution. Seven of the 33 commits conflicted; the decisions that were not mechanical are below.

The single most important one is §16.1: a naive merge is not merely untidy, it breaks every replace re-index.

16.1 _write_catalog_record — two incompatible meanings for one bool

The two branches gave the same return value different meanings:

Merged textually, replace keeps returning False and #671's new guard turns every re-index into a hard failure. Nothing in either test suite catches it: this branch's tests assert the return value directly, and #671's exercise the insert path.

The two questions are now separate. _write_catalog_record reports whether the write landed (#671's meaning, so the fail-closed guard is correct), and the quota verdict is derived at the call site from replace:

if not replace:
    release_slot = False

The duplicate-at-catalog race — the other case that used to return False — now reaches the same outcome by a different route: add_file_to_partition returns False for the loser, the fail-closed guard raises, and process_file's finally releases the slot on the way out. test_duplicate_at_catalog_releases_the_slot was updated to assert both halves (it raises and the slot comes back) so neither can regress silently, and test_a_replace_reindex_does_not_consume_the_reservation was rewritten against the new contract as test_a_replace_reindex_updates_the_row_without_creating_one.

Verified live, not just in tests: a PUT re-index over an existing file returns 202 and settles COMPLETED, with users.file_count unchanged.

16.2 4ec0a634 is obsoleted by #671, and its test contradicted the new behaviour

fix(quota): don't report a dispatch failure once the worker has started argued that once submit returns, the worker owns the reserved slot and will run to completion — so a failing set_object_ref must not fail the dispatch, or the router skips commit_quota_reservation and teardown releases a slot the worker also owns.

#671 invalidated the premise. dispatch_indexing now rolls the dispatch back: _cancel_submitted_task cancels the worker and _cleanup_submitted_vectors sweeps what it wrote. The worker no longer runs to completion, so the error is truthful and must propagate — and #671's test_dispatch_indexing_cancels_worker_when_ref_registration_fails asserts exactly that.

Kept #671's behaviour and removed this branch's test_dispatch_does_not_fail_once_the_worker_has_started, which asserted the opposite of a test already on develop. The residual cost is a double release (the cancelled worker's finally and the request teardown both give the slot back), which under-counts rather than leaks — the direction this branch already chose deliberately (see Known gaps), and which #700 closes.

Worth noting the two signals from that one call are genuinely different and only one may fail the dispatch: a raised exception means the state actor is unreachable, while False is #671's deliberate delete-fence refusal. They happen to want the same handling here only because #671 rolls back in both cases.

16.3 The durable QUEUED write moves behind #671's admission gate

set_state(QUEUED) + set_details(...) were collapsed into a single atomic _set_queued_details(...) -> bool, which can now refuse: it returns False when a delete fence covers the file, and forces the in-memory state to CANCELLED.

The durable create_job(QUEUED) therefore moved after that gate. Writing it before would leave a row for a job that was never admitted — and since no worker ever runs, nothing settles it: non-terminal forever (retention sweeps terminal rows only) and counted active in /queue/info for good. Exactly the orphan class §16.5 exists to close, introduced by the rebase rather than fixed by it.

16.4 quota_reserved threads through #671's submit_kwargs, unconditionally

#671 hoisted the nine inline submit.remote(...) keywords into a submit_kwargs dict, and adds require_existing_partition to it only when true so a rolling-deploy actor without the kwarg still accepts the common case.

quota_reserved is passed unconditionally, like the nine base kwargs. The conditional pattern exists for rolling-deploy tolerance, and _is_legacy_require_existing_partition_rejection is hard-coded to that one kwarg name; generalising it for a flag that ships in the same release would be speculative. Flagging the asymmetry since it is deliberate: an actor old enough to reject quota_reserved is old enough to predate the whole quota model.

16.5 The dispatch-orphan fix lands in #671's failure path, not around submit

The fix from §16's sibling commit (settle a task that never reaches a worker) originally wrapped the submit call. On develop that call lives in _submit_indexing_task_once, which _submit_indexing_task retries for legacy actors — settling there would write a spurious FAILED for an attempt that is about to be retried successfully.

It moved into _mark_submit_failed, which #671 already calls on the failure path and which is gated on _cancel_submitted_task confirming the task really was cancelled. So the durable FAILED inherits that gate and a task that actually completed is never recorded failed in either store.

One improvement kept from this branch: #671's _mark_submit_failed drove its actor write through an unguarded self._call, so an unreachable state actor there replaced the exception the caller needs ("the pool is down") with one about the bookkeeping ("the actor is gone"). It is now guarded, matching how every other durable write on this branch behaves.

16.6 _live_task composes with #671's _record_details

b7e0b966 stops set_error / set_details / set_object_ref recreating an evicted task, while #671 refactored set_details onto a shared _record_details helper. Merged so set_details drops the write on a miss and still uses the shared helper. _ensure_task now has exactly two callers — set_state and #671's set_queued_details — and both are legitimate creation points for a task admission.

16.7 copy_file keeps both changes

#671 added strip_internal_metadata so _openrag* keys cannot leak into copied chunks; this branch made the return value the quota-commit verdict. Both kept. The empty-source path is an explicit return False rather than the bare return #671 left — under a -> bool signature that returned None, which is accidentally falsy but not something to rely on.

Not addressed — a gap this rebase opens

Cancellations from the delete path write no durable row. #671 added task_cancellation.cancel_active_indexing_tasks, which delete_file and delete_partition call to cancel every active task for a file or partition; it writes set_state(task_id, "CANCELLED") directly to the actor. That path did not exist at the merge base, so this branch does not mirror it to Postgres — a delete now silently cancels tasks with no durable record, and the two read paths disagree for those task ids until the actor evicts them.

Left out deliberately: it is a new writer to cover rather than a merge conflict to resolve, and _cancel_refs writes its state after ray.cancel and a settle-wait, which is the opposite ordering from cancel_task (§14). Making that path durable should be a deliberate decision about which ordering is right, not a rebase artefact. Tracked in #676 alongside the other reconciliation work.

Verification after the rebase

  • ruff check / ruff format --check clean; layer import guard: OK.
  • Unit: 1914 passed, 0 failed.
  • Integration against real Postgres: 120 passed, 1 failedtest_create_is_idempotent_per_name, pre-existing and unrelated, fixed separately in test(repos): assert create_partition rejects duplicates, as it does #691.
  • Re-ran the live stack on the rebased code: clean boot, durable job history survived an API restart, a new index settles COMPLETED, a replace re-index settles COMPLETED without consuming a slot (§16.1), and a cancel writes a terminal CANCELLED and returns the slot.

Summary by CodeRabbit

  • New Features

    • Added durable indexation job history with lifecycle statuses, error details, filtering, queue counts, cancellation tracking, and retention.
    • Job status and details remain available after in-memory task data is evicted or services restart.
    • Added atomic file-quota reservations for uploads, URL indexing, and file copies.
  • Bug Fixes

    • Prevented concurrent uploads from exceeding quotas or double-counting in-progress files.
    • Ensured reservations are released after failed, cancelled, duplicate, or incomplete operations.
    • Limited stored error details to prevent excessive diagnostic data growth.
  • Documentation

    • Clarified quota usage, reservation ownership, and release behavior.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request replaces pending-task quota checks with atomic durable reservations, adds reservation cleanup across upload and copy flows, implements durable Postgres indexation jobs with lifecycle and retention handling, and bounds the Ray task-state cache and stored error text.

Changes

File quota admission

Layer / File(s) Summary
Quota contracts and atomic reservation
CLAUDE.md, openrag/api/dependencies/auth.py, openrag/services/persistence/user_repo.py, openrag/services/orchestrators/auth_service.py, openrag/services/orchestrators/user_service.py
File counts now include reserved and completed files; admission uses conditional reservation, release is clamped, and pending tasks are informational.
Reservation propagation and cleanup
openrag/api/routers/admin/indexing.py, openrag/services/orchestrators/mcp_service.py, openrag/services/workers/*, openrag/services/persistence/document_repo.py
Reservation ownership is propagated to indexing workers, committed after successful dispatch or catalog creation, and released on failure or duplicate paths.

Durable indexation state

Layer / File(s) Summary
Durable job model and repository
openrag/core/models/catalog.py, openrag/core/ports/job_repo.py, openrag/services/persistence/schema.py, openrag/services/persistence/job_repo.py, openrag/services/persistence/migrations/...
Single-task job records, lifecycle operations, status filtering, cancellation guards, retention purging, and error truncation are persisted in Postgres.
Durable dispatch and read paths
openrag/services/workers/dispatcher.py, openrag/services/workers/indexer_actor.py, openrag/services/orchestrators/job_service.py, openrag/di/container.py
Dispatch, worker transitions, cancellation, failure settlement, queue reads, and task details use durable jobs with Ray fallback behavior.
Bounded task-state cache
openrag/services/workers/task_state.py
Terminal task entries are evicted by cap or TTL, and late writes do not recreate evicted entries.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant JobService
  participant WorkerDispatcher
  participant PgJobRepository
  participant TaskStateManager
  Client->>WorkerDispatcher: dispatch_indexing
  WorkerDispatcher->>PgJobRepository: create QUEUED job
  WorkerDispatcher->>TaskStateManager: submit task state
  WorkerDispatcher-->>Client: task id
  Client->>JobService: list_tasks or get_task_details
  JobService->>PgJobRepository: read durable jobs
  PgJobRepository-->>JobService: job state and details
  JobService-->>Client: task information
  WorkerDispatcher->>PgJobRepository: update lifecycle or failure
Loading

Possibly related issues

  • linagora/openrag#660 — Covers the durable Postgres job state, lifecycle persistence, retention, and bounded task-state work.
  • linagora/openrag#676 — Covers reconciliation and end-to-end validation of quota reservation and job-state behavior.

Possibly related PRs

  • linagora/openrag#671 — Both changes modify the copy_file flow in openrag/services/workers/dispatcher.py.
  • linagora/openrag#700 — Both changes address reserved file-slot release arbitration and cleanup.

Suggested labels: fix

Suggested reviewers: hedhoud, enjoybacon7

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.36% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the two main changes: atomic file-quota reservation and durable indexation job state.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/660-durable-job-state

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.

@EnjoyBacon7
EnjoyBacon7 changed the base branch from main to develop July 16, 2026 12:32

@EnjoyBacon7 EnjoyBacon7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: two bugs fixed on the branch, two design gaps left for you

Reviewed both commits, ran the suites, and verified the claims in the description rather than taking them on trust. The core of this is solid — see the end for what I checked and confirmed. Four commits pushed to the branch (704d00f6..dcffc1fd); two findings below are not fixed because they're your design calls, not mine.


Fixed: MCP paths bypass the quota entirely (00da466e)

This is the one I'd have blocked a merge on.

Removing the completion-time increment assumes every new files row is preceded by a reservation. MCPService.index_url (mcp_service.py:623) and MCPService.copy_file (mcp_service.py:555) create rows without one — they aren't HTTP routes, so they never pass through the check_user_file_quota dependency.

It's worse than an unenforced quota, because the delete path still decrements unconditionally on created_by (document_repo.py:201, :225, :374). MCP files are invisible going in but counted going out, driving the counter below reality. Measured against a real Postgres, at file_quota = 5:

upload 5 via HTTP   -> file_count=5   (correctly at quota, 6th rejected)
index 3 via MCP     -> file_count=5   (3 real files invisible to the quota)
delete those 3      -> file_count=2   (count driven below reality)
=> user holds 5 files, has 3 free slots again. Repeatable => quota unbounded.

GREATEST(..., 0) doesn't save this — the count is driven down, not negative.

Both tools now reserve at admission and release anything unconsumed. index_url reserves before the download so an over-quota call costs no bandwidth; copy_file is synchronous, so its slot settles on the created verdict. I made auth_service/default_file_quota required constructor args rather than optional — a missed wiring would silently reopen exactly this hole.

Note this is the failure mode your own CLAUDE.md warns about ("any new code path that creates a file row without reserving … leaks the counter"), so I extended that section to name the MCP tools.

Fixed: quota slot leaks when the SERIALIZING write fails (7d0dd791)

process_file takes ownership of the slot at dispatch, but set_state.remote(task_id, "SERIALIZING") ran before the try whose finally releases it. TaskStateManager is a detached actor that can be unreachable (restart, node loss), so that await can raise — and nothing else covers the window:

  • the request already handed ownership over (commit_quota_reservation runs once dispatch returns a task id), so its teardown won't release;
  • IndexerWorkerActor.process_file's except BaseException only wraps the catalog/registry setup, not the worker call.

Result: the slot leaks permanently and narrows that user's quota until an admin fixes file_count by hand. Moved the pre-flight writes inside the try; regression test added. Your pool comment already names this exact window for the file-purge concern — the release just hadn't been extended to it.


Not fixed — your call

1. The durable FAILED write is gated on the volatile actor's verdict (indexer_actor.py, the if failed: gate)

set_failed_if_not_cancelled returns False for two different reasons (task_state.py:126-133): the task was cancelled (correct to skip), or info is None — the actor has no record of it. So if the actor is restarted or has evicted the entry between SERIALIZING and the exception, no durable FAILED is written and the row is stranded in SERIALIZING forever.

This inverts the stated design: the source of truth defers to the cache for a decision the cache is allowed to forget.

The clean fix is to move the arbitration into SQL rather than distinguish the two in the actor's return value — make the durable transition conditional:

UPDATE jobs SET status='FAILED', error=$2, completed_at=$3, updated_at=now()
 WHERE id=$1 AND status <> 'CANCELLED'

Then Postgres arbitrates the durable row and the actor arbitrates only its own cache, which is what "Postgres is the source of truth" should mean. I left this alone because test_failing_after_eviction_does_not_resurrect_the_task shows the no-resurrect behaviour is deliberate, and reworking your failure arbitration felt like your decision rather than a review fix.

2. Jobs stuck in active states are never purged — partly #676, but with a consequence worth calling out

purge_terminal_jobs only matches terminal states, and every durable write is best-effort (_record_job, _update_job both swallow). So a worker OOM, a lost node, or a Postgres blip at the terminal write leaves a row no retention pass can ever reclaim — contradicting the module docstring's "bounded rows … otherwise the durable store would just reproduce the in-memory leak on disk".

The extra consequence beyond #676: it permanently inflates get_queue_info's active count, which reads count_by_status from Postgres. This is precisely the hazard you documented as the reason to keep get_user_pending_task_count on the actor (job_service.py:171-179) — the same reasoning applies to get_queue_info but wasn't applied there. Worth either an age-based sweep for active rows or a note in #676.

Minor: _LIST_LIMIT = 1000 silently truncates an unpaginated route whose retention cap is 10k — no indicator to the caller that rows were dropped.


Also pushed

  • dcffc1fdlist_tasks fails closed for an anonymous non-admin. list_jobs(user_id=None) means every job, and there's no guard for is_admin=False, user_id=None. Unreachable today (the HTTP path always resolves an id, MCP 403s first), but the MCP _USER_ID ContextVar's unset default is exactly None — the escalating value. One middleware-ordering change from a data leak.
  • 704d00f6 — logging/naming consistency in indexer_actor: the two new helpers each invented an idiom (function-local get_logger import; stdlib logging.getLogger(__name__) with %s-formatting) where every other module under services/workers/ binds logger = get_logger() at import. Also dropped the underscore from release_quota_slot, since indexer_pool imports it — it's module API, and its neighbour delete_uploaded_file is already public.

Claims I verified

  • The 6 unit / 2 integration failures are genuinely pre-existing and environmental. Confirmed both directions: the same 6 fail on a clean origin/develop checked out inside a directory whose parent holds a .env, and all pass on the same commit checked out outside it. Your load_dotenv() diagnosis is exactly right.
  • The atomicity claim holds under harsher conditions than the test asserts. Re-derived independently with 50 concurrent racers on separate pool connections: quota 7 → exactly 7 granted, quota 1 → 1, quota 0 → 0, admin → all 50, unlimited → all 50, returned counts contiguous every time. The COALESCE-before-< 0 ordering is right, and the point about a negative global default not overriding an explicit per-user limit is a good catch.
  • Migration chain: b7c1d2e3f4a5 really is the current head, single head after this revision, up/down idempotent, create_allupgrade head converges.
  • update_job's enumerate(..., start=2) parameter numbering is correct — dict ordering keeps assignments and updates.values() aligned.
  • TaskStateManager eviction is sound under stress (50k tasks/100 users → caps hold at 2000, no dangling user_index, in-flight never evicted).

Final state: 1735 unit passed (+10 from the new tests), integration 111 passed, ruff/format/layer-guard clean, merges cleanly into current develop.

The acknowledged double-release on a partially-succeeded dispatch I left alone — agreed that under-counting beats a permanent lockout.

The per-user file quota was a check/admit TOCTOU race. `check_user_file_quota`
compared the request's stale `users.file_count` snapshot plus an in-memory
`TaskStateManager` pending count against the quota, and the durable count was
only incremented at the *end* of indexing. Concurrent uploads all observed the
same pre-increment state and all passed, overshooting the quota by roughly the
burst width; a restart additionally zeroed the pending count and reopened the
gate.

Admission is now a single conditional UPDATE (`try_reserve_file_slot`) that
reads and writes under one row lock, so N racers admit exactly the free slots:

    UPDATE users SET file_count = file_count + 1
     WHERE id = $1
       AND (is_admin
            OR COALESCE(file_quota, $2::int) < 0
            OR file_count < COALESCE(file_quota, $2::int))
    RETURNING file_count

The predicate reproduces the documented resolved-quota semantics: admins
bypass, a NULL per-user quota falls back to the global default, a resolved
quota < 0 is unlimited - so a negative global default does not override an
explicit per-user limit. No row returned means no slot: 403 FILE_QUOTA_EXCEEDED,
the status/code the previous check already raised.

`file_count` therefore becomes a reserved+completed counter, so the
completion-time increment in `add_file_to_partition` is removed (it would
double-count) and the reservation gains an owner that must release it:

- before dispatch the request owns it - `check_user_file_quota` is now a yield
  dependency whose teardown releases any uncommitted reservation, covering the
  409 duplicate, upload rejection, workspace validation, dispatch errors and
  client disconnects;
- after dispatch the worker owns it - `IndexerWorker.process_file` releases in a
  `finally` (not an `except`, which cancellation would skip) unless the catalog
  write reports a new row, covering indexing failure, `ray.cancel`, and the
  duplicate-at-catalog race;
- `copy_file` is gated too and releases when no row was created.

The in-memory pending count is no longer a correctness input anywhere, and
`get_current_user_info` stops adding it to `file_count` for `total_files` -
reserved uploads are already counted, so that double-counted in-flight files.

Verified against a real Postgres: 20 parallel admits at quota N grant exactly N.

Closes #664
Job state lived only in the detached TaskStateManager Ray actor: unbounded
(insert-only, no TTL/cap), volatile (wiped on any restart) and unpersisted
(the job repository was a stub with no table and no writers). A restart
mid-batch made the in-flight work unobservable and un-cancellable.

Postgres is now the source of truth and the actor is a hot cache in front
of it:

- Add a `jobs` table (one row per dispatched task, `id` = `task_id`) via an
  idempotent migration, plus the matching `schema.py` definition. The row
  carries no FK to `partitions` — job history must outlive the partition it
  targeted — and `user_id` is `ON DELETE SET NULL`.
- Implement `PgJobRepository` against it, replacing the stub. `update_job`
  allowlists columns since the worker path passes `**fields`.
- Write lifecycle transitions durably: QUEUED (dispatcher, before submit),
  SERIALIZING/COMPLETED/FAILED (worker), CANCELLED (dispatcher). The worker
  honours `set_failed_if_not_cancelled`'s verdict so a durable FAILED cannot
  overwrite a cancellation. All durable writes are best-effort — a Postgres
  blip must not fail a file that indexed correctly.
- Read through the durable store in `JobService` and in the dispatcher's
  state/error lookups, falling back to the actor. `get_user_pending_task_count`
  deliberately stays in-memory: it gates uploads, and a job orphaned by a crash
  would hold the user's quota until reconciliation exists (tracked with #664).

Bound both stores so the durable one does not repeat the leak it replaced:
terminal jobs are swept by retention (age + row cap, throttled off the
dispatch path); the actor evicts terminal tasks by cap + TTL, keeping
in-flight ones (their `object_ref` is not serializable); and stored
tracebacks are truncated to their diagnostic tail.

`IndexationJob` becomes a per-task record and reuses `DocumentStatus`, which
matches the actor's taxonomy verbatim. Startup reconciliation of orphaned
in-flight jobs, orphaned upload cleanup and per-(partition,file_id)
single-flight are out of scope.

Closes #660
The two new helpers each invented their own logging idiom: `_update_job`
did a function-local `from core.utils.logging import get_logger`, and
`_release_quota_slot` reached for stdlib `logging.getLogger(__name__)`
with %s-formatting. Every other module under services/workers/ binds
`logger = get_logger()` at import; loguru also takes structured kwargs,
which the stdlib call was throwing away.

Also drop the underscore from `release_quota_slot`: it is imported by
indexer_pool, so it is module API, and its neighbour `delete_uploaded_file`
is already public and imported the same way.

No behaviour change.
`process_file` takes ownership of the uploader's reserved file slot at
dispatch, but `set_state.remote(task_id, "SERIALIZING")` ran *before* the
try block whose `finally` releases it. `TaskStateManager` is a detached
actor that can be unreachable (restart, node loss), so that await can
raise — and when it did, nothing gave the slot back:

  - the request had already handed ownership over (`commit_quota_reservation`
    runs once dispatch returns a task id), so its teardown will not release;
  - `IndexerWorkerActor.process_file`'s `except BaseException` guard only
    wraps the catalog/registry setup, not the worker call itself.

The slot leaked permanently, narrowing that user's quota until an admin
reconciled `file_count` by hand.

Move the state/job pre-flight writes inside the try, so the `finally` that
already owns the release covers them. The pool's own comment names this
window for the file-purge concern; the quota release now covers it too.
…ools

Removing the completion-time `file_count` increment assumed every new
`files` row is preceded by a reservation. `MCPService.index_url` and
`MCPService.copy_file` create rows without one: they are not HTTP routes,
so they never pass through the `check_user_file_quota` dependency.

The result is worse than an unenforced quota. The delete path still
decrements `file_count` unconditionally on `created_by`, so MCP-created
files were invisible on the way in but counted on the way out, driving the
counter *below* reality. Measured against Postgres, at quota 5: upload 5
via HTTP (count=5, correctly at quota), index 3 via MCP (count stays 5),
delete those 3 (count=2) — the user holds 5 files and has 3 free slots
again. The loop is repeatable, so the quota was effectively unbounded.

Both tools now reserve at admission and release anything unconsumed,
matching the routes. `index_url` reserves before the download so an
over-quota call costs no bandwidth; `copy_file` is synchronous, so its
slot is consumed or handed straight back on the `created` verdict.

`auth_service` and `default_file_quota` are required constructor args
rather than optional: a missed wiring would silently reopen the bypass,
which is the exact failure mode this fixes.
`list_tasks` scopes the durable read with `user_id=None if is_admin else
user_id`, and `list_jobs(user_id=None)` means "every job" — so a non-admin
arriving without an id selects the whole table. The in-memory fallback it
replaced was effectively fail-closed (`user_index.get(None)` → empty).

Not reachable today: the HTTP path always resolves an id and the MCP path
403s first. But the MCP `_USER_ID` ContextVar defaults to `None`, so the
escalating value is the unset default — one wiring change from a leak.
Guard explicitly rather than depend on every caller resolving an id.
The durable reads fall back to the in-memory actor, but only when the
repository *raised*: `_from_jobs` returns None on failure, and both
aggregate readers tested `is not None`. An empty result is not None, so
zero rows was taken as authoritative.

That is wrong at exactly the moment it matters most. `TaskStateManager` is
a detached actor, so it survives the API restart that first deploys the
durable store — every task dispatched before the cutover is live in the
actor and absent from `jobs`. `GET /queue/tasks` answered [] and
/queue/info reported 0 active while workers were indexing those files,
with the cache holding the answer and never being asked. The same happens
to any task whose best-effort create_job write was swallowed by a blip.

Both aggregate readers now treat empty as a miss and fall through. The
cost is one actor call on a genuinely-empty query, which is what these
routes did before the durable store existed. `get_task_details` keeps its
`is not None` test — for a single-row read, None already is the miss.

Regression tests fail on the pre-fix code (list_tasks returns [],
get_queue_info reports 0 active) and a third pins the over-correction:
a populated Postgres must still win over a stale cache.

Incidental: `get_user_pending_task_count`'s docstring still claimed the
count 'gates uploads (check_user_file_quota)' and that #664 tracks the
reconciliation sweep. Neither survived the quota commit — the count is
informational only, and the sweep is #676.
`set_error`, `set_details` and `set_object_ref` went through
`_ensure_task`, which recreates the entry on a miss. A recreated TaskInfo
has state=None, so it never enters `terminal_at` and is never evictable
again — a permanent entry on a detached actor, which is the unbounded
growth #660 exists to fix. `set_failed_if_not_cancelled` already guarded
this correctly; the other three did not, and the existing regression test
covers only that one method, which is why the gap stayed invisible.

Not reachable today: the dispatcher writes set_state(QUEUED) first and
only then set_details/set_object_ref, so the entry always exists and is
not yet terminal. The guard is for the shape of the code, not a live bug —
a resurrected entry is silent and permanent, and nothing about the current
call order is enforced. `set_state` remains the one writer that may create
a task, since it alone legitimately hears about a task first.

Also corrects the eviction comment, which claimed the TTL 'keeps a quiet
deployment from serving hours-old state'. It does not: `_evict_terminal`
runs only from `_mark_terminal`, so nothing is swept until some other task
settles, and the getters never check age. The cap is the real memory
guarantee. The trade-off is now written down and pinned by a test, because
it is harmless (a terminal state is immutable, so a stale read is not a
wrong read) but only if a reader knows not to assume otherwise.

The shipped bounds (2000 / 1h) are now asserted too — every other test
here monkeypatches them, so widening them back to unbounded broke nothing.
`_expand_status` normalizes case for the read filter; `_status_value` did
not for the write. A caller passing a lower-case string would violate
`ck_jobs_status`, and since every durable write is best-effort the
violation would be swallowed — leaving the job frozen at its previous
status, permanently if the dropped write was the terminal one, because
retention only sweeps terminal rows.

Latent today (all current callers pass DocumentStatus members), but the
failure is silent and the asymmetry with the read path is an invitation.
Two gaps in the durable-retention tests:

- The shipped values were asserted nowhere. Every test passes its own
  bounds or monkeypatches them, so narrowing JOB_RETENTION_MAX_ROWS to 10
  — or widening it until the table is effectively unbounded again — broke
  no test. These bounds are the only thing standing between the durable
  store and the growth #660 exists to fix.
- The throttle was tested one-directionally: 'at most once per 3
  dispatches' also passes for an implementation that purges exactly once
  per process and never again. Driving a fake monotonic clock across the
  interval pins that the sweep is rate-limited, not disabled.
`test_delete_cascades_files_and_decrements_uploader_count` has been dead
on develop — `create_legacy_user` grew four required arguments and the
call was never updated, so it errored before asserting anything. It guards
the per-uploader decrement, which is exactly the counter this branch
redefines, so it should not stay dead through this change.

Fixing the signature alone would have made it pass vacuously: since #664
`add_file_to_partition` no longer increments, so the count would start at
0 and the test would assert 0 -> 0 while the decrement went untested. It
now reserves the two slots at admission the way a real upload does, and
asserts 2 before the delete and 0 after — which also pins that the catalog
insert consumes the reservation rather than re-incrementing it.

Also corrects the conftest docstring, which named POSTGRES_TEST_DSN while
the code reads POSTGRES_TEST_ADMIN_DSN.
- The setup-failure release in `indexer_pool` reads as if it covers the
  window; it releases through the very store whose initialization just
  failed. A cancellation or a registry-refresh failure releases fine, but
  an unreachable Postgres — `_ensure_catalog`'s most likely failure — fails
  the release too, and `release_quota_slot` swallows it. Nothing local can
  fix that (the DB is the counter); recovery needs the sweep in #676.
- `truncate_error_text` bounds the retained original text, not the returned
  string: the audit marker is overhead on top, so a 4000-char cap returns
  ~4035. Says so now, and says why a hard ceiling is not worth it — it
  would either eat into the tail (where the exception message lives) or
  need a fixed-point loop against the marker's own digit count.
The release paths are covered end-to-end, but nothing asserted the signal
that arms them. `commit_quota_reservation` only stops the request's
teardown from releasing; it is `quota_reserved=True` reaching
`IndexingService.add_file` that makes the worker take ownership and
release on failure or cancellation.

Drop that one keyword from the router and the two halves disagree —
teardown declines to release (the reservation was committed), the worker
declines too (it was never told it owns one) — so every failed or
cancelled upload leaks a slot permanently, in silence. Verified: removing
it left all 1746 unit tests green. It now fails exactly one.

The release tests cannot catch this; they never reach a worker.
`submit` starts the worker task, so from the moment it returns the worker
owns the uploader's reserved slot and releases it in its own `finally`.
`set_object_ref` was awaited *after* that and allowed to propagate, so an
unreachable state actor (a 60s timeout window) made `dispatch_indexing`
raise for a job that was already running.

The router then skipped `commit_quota_reservation`, so the request teardown
released a slot the worker also owned:

- worker succeeds -> the catalog row is written, `created=True` suppresses
  the worker's release, and the teardown's release leaves `file_count` one
  below reality. Permanent, repeatable, silent: a free slot per occurrence,
  which is the quota bypass #664 exists to close.
- worker fails -> both release, `file_count` one below reality.

Either way the client saw a 500 for an upload that indexed correctly.

Storing the ref only enables cancellation, so it is bookkeeping about the
work, not the work — the same reasoning the durable job writes above already
follow. Make it best-effort: log and return the task_id, so dispatch reports
the success that actually happened and the handover point in the code matches
the one in the design.

The task is uncancellable either way in this window (the ref was never
stored), so nothing is lost that the previous behaviour preserved.
`_status_value`'s `.upper()` was entirely unguarded: removing it left all 26
job-repo tests (12 unit, 14 integration) green, because every test feeds it an
already-upper-case `DocumentStatus` member, making the call a no-op on every
tested input. `test_list_jobs_status_match_is_case_insensitive` looks like it
covers this but routes through `_expand_status`, a different function.

`create_job` cannot reach the gap — pydantic validates `IndexationJob.status`
to a `DocumentStatus`. `update_job(**fields)` takes `Any` from the worker path
and is the one reachable route, so the test goes there.

Guards the failure mode the docstring describes: a lower-case status violates
`ck_jobs_status`, the best-effort write swallows the violation, and the job
freezes at its previous status — permanently, if the dropped write was the
terminal one.
cancel_task has no quota-release path by design: a task cancelled while
running releases its reserved slot in process_file's finally, and releasing
here too would double-release. A task ray.cancel retires before that body
runs leaks the slot, and because the row is a terminal CANCELLED it is
indistinguishable from a clean cancel -- so the #676 reconciliation must
recount file_count rather than only sweep orphaned active rows.
The worker gated its durable FAILED write on the state actor's verdict, but
`set_failed_if_not_cancelled` returns False for two different things: a real
cancellation, and an entry the actor no longer holds (restart, TTL eviction,
lost node). In the second case the write was skipped and the row stayed in
SERIALIZING forever, since retention sweeps terminal rows only — a permanent
phantom in the queue views, which is the failure #660 exists to remove.

Move the arbitration into the statement itself: `mark_failed_if_not_cancelled`
does a conditional `UPDATE ... WHERE status <> 'CANCELLED'`. Postgres is the
one participant guaranteed to still know what the user asked for, so a
forgetful actor can no longer veto a terminal write, and a concurrent cancel is
still respected. The actor is still told, to keep the hot cache in step; its
answer just no longer decides anything durable.
`cancel_task` used to write CANCELLED to the jobs row unconditionally. Since
`get_object_ref` still answers for a finished task — the ref is only dropped
when the entry is evicted — a late `DELETE /task/{id}` reached that path for
work that had already succeeded, and durably recorded a COMPLETED file as
cancelled.

Rebasing onto #685 supplies the fix: `set_cancelled_if_active` refuses the
claim on a terminal task, and returning on that answer now short-circuits
before the durable write rather than after it. This pins the durable half,
which #685 alone does not cover.
`_LIST_LIMIT` caps the page at 1000 against a retention cap of 10k, so the cap
is reachable — and the route returned the short list with nothing to say it was
short. A caller could not tell "these are all the jobs" from "these are the
first 1000 of many".

Ask the store for one row more than we will return: that extra row is what
distinguishes an exactly-full page from a truncated one. On truncation, drop
the probe row and log a warning with the filter that produced it.

The response body stays a bare list, so this does not break the route contract
or the MCP tool that consumes it. Real pagination is the proper fix and is left
as follow-up; this makes the current behaviour honest rather than silent.
`set_cancelled_if_active` is the only state write that does not call
`_mark_terminal`. Every sibling does — `set_state` and
`set_failed_if_not_cancelled` both register the transition — and eviction is
driven entirely off `terminal_at`, so an entry that never lands there is
retained for the lifetime of the detached actor: neither the 2000 cap nor the
1h TTL can reclaim it.

Nothing recovers it afterwards. `ray.cancel` raises `CancelledError`, a
`BaseException` that `process_file`'s `except Exception` sails straight past,
so no later write ever re-registers the task. Every user-initiated cancel
therefore leaks one `TaskInfo` — its details, its `user_index` entry, and its
pinned `object_ref` — which is verbatim the unbounded growth this branch
exists to fix.

The method predates the eviction machinery this branch adds (it arrived with
the bulk-cancel work in #685), and the two only met on the rebase; the gap is
the merge, not either change on its own. `#685`'s cancel-all UI fires one
cancel per selected task, so the leak scales with a single click.

`test_cancelled_tasks_are_evictable` did not catch it because it drives
`set_state(..., "CANCELLED")`, a path no cancellation actually takes. The new
test uses the entry point `cancel_task` really calls.
`mark_failed_if_not_cancelled` arbitrates the FAILED-vs-CANCELLED write in SQL,
but the worker's other two lifecycle writes — SERIALIZING and COMPLETED — went
through `update_job` as blind UPDATEs. They race `cancel_task`'s equally blind
UPDATE, and Postgres is free to order the two either way, so the guard only
covered the direction where the cancel *loses*.

When the cancel wins, the worker's in-flight write lands after it:

- COMPLETED overwriting CANCELLED leaves the actor (which keeps CANCELLED
  sticky) and the table disagreeing permanently, and the two read paths answer
  differently for the same task — `JobService` reads Postgres first,
  `WorkerDispatcher.get_task_state` reads the actor first.
- SERIALIZING overwriting CANCELLED is worse. `ray.cancel` has already killed
  the only writer that could have driven the row terminal, so no later write
  arrives, and `purge_terminal_jobs` filters on terminal statuses — the row is
  never swept. The result is a permanently active phantom in `list_tasks` and
  in `get_queue_info`'s `active` roll-up, with `completed_at` set while the
  status says otherwise. That is verbatim the failure the arbitration exists to
  prevent.

Extend the same rule to `update_job`: a status write that is not itself
CANCELLED carries `AND status <> 'CANCELLED'`. This mirrors the stickiness
`TaskStateManager.set_state` already enforces in the hot cache, so the two
records now agree by construction rather than by luck of scheduling.

A declined write re-reads and returns the row, because reporting a cancelled
job as a missing one would be its own bug. Non-status patches are unaffected —
the guard is about transitions, not about freezing the row — and a
CANCELLED-over-CANCELLED rewrite still lands, so a retried cancel is unchanged.
The failure handler already argues that the hot cache must not decide the
durable outcome: `set_failed_if_not_cancelled` returns False both for a real
cancellation and for an entry the actor no longer has, so Postgres arbitrates
instead as "the one participant guaranteed to still know what the user asked
for". But the actor call sat first and unguarded, so it could prevent the very
write it was reasoned about.

An unreachable actor -- a restart, a lost node, exactly the cases the detached
lifetime exists to survive -- raises `RayActorError` straight out of the
`except` block and takes `_mark_job_failed` with it. The row is left
non-terminal, and `purge_terminal_jobs` sweeps terminal rows only, so it never
ages out. Every file dispatched during an actor outage strands one row forever:
verbatim the unbounded growth this branch exists to fix, reproduced on the
durable side.

Guard the hot-cache write and let the durable one run regardless. The original
exception still propagates; only the bookkeeping is best-effort, which is the
same contract `_update_job` and `_mark_job_failed` already carry.
`list_jobs` floored the limit at 1, so `limit=0` returned a row instead of
none. The adjacent line clamps the offset with `max(0, offset)`; this reads
like a copy of it with the wrong floor.

Not reachable from `JobService` today, which always passes `_LIST_LIMIT + 1`,
but "give me no rows" is a reasonable thing for a caller to ask and the current
answer is wrong.
`_status_value` upper-cases a status so it matches `ck_jobs_status`, and its
docstring spells out why that matters: every durable write is best-effort, so a
constraint violation is swallowed and the job silently freezes at its previous
status -- permanently, if the dropped write was the terminal one.

Casing is not the only way to build a value the CHECK rejects. `update_job` is
untyped and documented as taking whatever the indexing hot path knows, so
`status=None` reaches SQL as `"NONE"` (`str(None).upper()`), and `JobStatus` --
the enum this field used before this branch, still exported from `catalog` --
supplies `RUNNING`/`SUCCESS`/`PARTIAL`. All of them produce the same swallowed
violation and the same permanent freeze.

Validate membership against the allowed set and raise before the statement is
built. The allowlist is derived from `ACTIVE_JOB_STATES` and
`TERMINAL_JOB_STATES` rather than restated, so the guard cannot drift from the
constraint or the migration. A caller that passes something unwritable now gets
a loud local error instead of a lost write.

Also records why the cancel guard keys off `None`: that branch keeps the guard
off for a status-less patch, which is deliberate -- the rule is about status
transitions, not about freezing the row.
`ix_jobs_completed_at` was added for the retention sweep -- "scans terminal rows
by completion time" -- but the sweep filters and orders on
`COALESCE(completed_at, created_at)`, not on the bare column. A b-tree on
`completed_at` can serve neither, and nothing else in the tree queries that
column alone, so the index was maintained on every insert and every terminal
transition and read by nothing.

`EXPLAIN (ANALYZE, BUFFERS)` over 200k rows, 150k of them terminal, with the
shipped 7d/10k bounds: before, a `Seq Scan` feeding a sort that spills to disk
(`external merge  Disk: 3600kB`); after, an `Index Scan Backward using
ix_jobs_settled_at` with the sort node gone entirely.

The `COALESCE` is load-bearing rather than incidental -- a row whose terminal
write raced a failure has no `completed_at` and is aged out on `created_at`
instead -- so the index has to match the expression. schema.py and the migration
change together; `create_all` and `upgrade head` were re-checked and still
produce byte-identical columns, constraints and indexes.
…aint

The docstring claims "only `set_state` may do this: it is the dispatcher's
first write for a task id". `set_state` is not dispatcher-only -- the worker
also writes SERIALIZING and COMPLETED through it -- so the guard the surrounding
commit added for `set_error`/`set_details`/`set_object_ref` does not cover the
one path a live worker actually takes.

What makes it safe today is ordering, not the caller set: the worker's first
write happens long before the task can be terminal, and eviction only removes
terminal entries, so there is nothing evicted to resurrect. Worth stating
explicitly, because the safety is incidental -- any `set_state` after a terminal
transition reopens the leak, and a non-terminal resurrection never re-enters
`terminal_at` and is never evictable again.

Making creation opt-in is the durable fix, but it is not a plain code change:
`TaskStateManager` is created with `get_or_create_actor(..., lifetime="detached")`,
so an API deploy keeps the previous instance alive. A new dispatcher passing a
new keyword to an old actor would fail every dispatch. It has to be sequenced
with a deliberate actor restart, so it is recorded here and left to #676 rather
than shipped blind.
`IndexerWorkerActor.process_file` releases the reserved slot when
`_ensure_catalog`/`_ensure_registry_fresh` blow up, because the worker's own
`finally` never runs in that case and nothing else would give the slot back.
The branch carries a twelve-line comment justifying it and no test: deleting the
release outright leaves the whole unit suite green.

Covers both arms of the `except BaseException` -- an ordinary exception and a
cancellation, the latter being why it is not `except Exception` -- and the
negative case, since `put_file` dispatches without reserving and must not
release a slot it never took.
`_maybe_purge_jobs` stamps `_last_job_purge_at` before running the sweep, with
a comment explaining that a slow or failing purge must not be retried on every
dispatch. Moving the stamp after the call leaves all nineteen dispatcher tests
passing.

The existing test covers that a failing purge does not fail the upload, but it
dispatches once, so it cannot see the retry. This one dispatches three times
against a purge that always raises and asserts a single attempt -- otherwise an
unhealthy database collects a failing round-trip per request, at exactly the
moment it can least afford one.
``cancel_task`` wrote the durable CANCELLED row *after* ``ray.cancel``.
``ray.cancel`` kills the only other writer of that row, and the write that
followed it had no successor that could heal it: ``_record_job`` catches
``Exception``, but a client disconnect or a graceful shutdown raises
``asyncio.CancelledError`` -- a ``BaseException`` -- straight through it.

The window strands the job permanently. The actor holds CANCELLED (the
claim above already landed) while the row keeps its last active status, so:
``purge_terminal_jobs`` never sweeps it (terminal rows only), it is counted
active in ``/queue/info`` for good, and the actor-first and durable-first
read paths answer differently for the same task id -- until the actor
evicts its entry, after which both agree on the wrong answer.

The durable write is part of the claim, so it now happens first, under
``asyncio.shield`` so a disconnect cannot abort the UPDATE in flight.
``ray.cancel`` moves into a ``finally``: the actor has already claimed the
cancellation, so the worker must die even if the durable write raises --
otherwise the fix trades a stranded row for a live worker both records say
was cancelled.

Ordering it first cannot mis-record a job that escapes the cancel:
``update_job`` keeps CANCELLED sticky, so a worker that somehow finishes is
declined by the same guard in both stores.
Two paths write QUEUED to both stores and can then fail before
``IndexerWorker`` -- the only writer of SERIALIZING/COMPLETED/FAILED -- is
ever entered, leaving nothing that will ever write a terminal state:

- ``WorkerDispatcher.dispatch_indexing``: ``submit`` raises or times out.
- ``IndexerWorkerActor.process_file``: ``_ensure_catalog`` /
  ``_ensure_registry_fresh`` raise. The more reachable of the two -- a
  Postgres blip at catalog init does it -- and here the client has already
  been handed a 201 and a ``task_status_url`` that answers QUEUED forever.

Neither store can reclaim a non-terminal task on its own. Actor eviction is
driven entirely off ``terminal_at``, which only a terminal state enters, so
the ``TaskInfo`` -- with its details, its ``user_index`` entry and its
pinned ``object_ref`` -- is retained for the lifetime of the *detached*
actor and survives the API restart. ``purge_terminal_jobs`` sweeps terminal
rows only, so the row is a phantom in ``count_by_status`` for good. One
leak in each store per failure, and a degraded pool fails every dispatch.

This is not the #676 case: a hard process death leaves nobody to write
anything, whereas here we are still running and holding the exception, so
both records can be settled before re-raising. ``BaseException`` at both
sites so a cancellation settles them too.

Both settlings are best-effort and carry the CANCELLED guard, so a user who
cancelled during setup keeps that outcome, and a bookkeeping failure never
masks the exception the caller must actually see. The durable half on the
pool path writes through the very store whose init may have just failed --
the same honest caveat the quota release beside it already carries -- but
its hot-cache half is independent, so an unreachable Postgres still leaves
an evictable ``TaskInfo`` rather than a pinned one.

The quota slot is untouched on the dispatcher path: the handover to the
worker never happened, so the request still owns it and its teardown
releases it (#664).
Found by mutation-testing the branch: each of these was deleted or inverted
in the source without a single test going red. All five now fail exactly
one test.

- ``set_failed_if_not_cancelled`` skipping ``_mark_terminal``. The headline
  one, because this is the path every failed job actually takes --
  ``process_file``'s ``except`` reaches for it, never ``set_state``.
  Unpinned, a failure leaks a ``TaskInfo`` (its traceback, its
  ``user_index`` entry, its pinned ``object_ref``) that no cap and no TTL
  can reclaim, on a detached actor that outlives the API: verbatim the
  unbounded growth #660 exists to fix. The sibling method already had
  ``test_a_claimed_cancellation_is_evictable`` reasoning about precisely
  this leak; the commoner path had nothing.

- ``IndexerWorkerActor`` not forwarding ``quota_reserved`` to the worker.
  Nothing covered the wire: the pool tests stub the worker out entirely
  (``_worker = None  # must never be reached``) and the worker tests call
  ``process_file`` directly. Dropped, ``release_slot`` is always False and
  every failed upload leaks a slot in silence -- the same bug class already
  pinned one hop upstream at the router.

- ``_write_catalog_record`` returning True on the ``replace`` branch.
  ``add_file`` always dispatches with ``quota_reserved=True``, so a re-index
  would consume the reservation it should have handed back, eating one slot
  per run.

- ``WorkerDispatcher.copy_file`` returning True for an empty source. The
  router commits the reservation on that verdict, so it would charge a slot
  for a copy that wrote no catalog row. The MCP copy path had this covered;
  the dispatcher probe did not.

- ``truncate_error_text`` keeping the head instead of the tail. Nothing in
  the suite referenced the function at all. The assertions that do exist
  cover the length and the marker, both of which a head-truncating
  implementation satisfies -- while discarding the exception message that
  lives at the very end and is the entire stated reason the tail is kept.
#671 and this branch each carried their own copy of
test_cancelled_state_is_not_overwritten_by_worker_transitions, with
identical assertions and different manager helpers, so reconciling the two
versions of the module kept both and ruff flagged the redefinition (F811).
Keeps #671's copy, which the rest of that file's helpers match, and moves
this branch's docstring onto it.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openrag/services/workers/task_state.py (1)

300-311: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return False for missing task registrations

set_object_ref can return None for an unknown or evicted task, but dispatch_indexing only treats False as a failed registration, so this falls through as success. Return False here to match the delete-fence branch and the -> bool contract.

🩹 Proposed fix
             info = self._live_task(task_id)
             if info is None:
-                return
+                return False
🤖 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 `@openrag/services/workers/task_state.py` around lines 300 - 311, Update
set_object_ref so the missing-task branch when _live_task(task_id) returns None
explicitly returns False, preserving the bool contract and ensuring
dispatch_indexing treats unknown or evicted registrations as failures.
🤖 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 `@openrag/services/storage/postgres_store.py`:
- Around line 87-88: Move the job_repo property from the stub repositories
section into the Real repos section, alongside the initialized _job_repo in the
constructor. Keep the property implementation unchanged and update its
surrounding placement so the stub-repository header only covers actual stubs.

In `@tests/unit/services/workers/test_indexer_pool.py`:
- Around line 1256-1301: Add the `@pytest.mark.asyncio` decorator to both
test_setup_failure_releases_the_reserved_slot and
test_setup_failure_releases_nothing_when_no_slot_was_reserved so pytest-asyncio
awaits them in strict mode and executes their assertions.

---

Outside diff comments:
In `@openrag/services/workers/task_state.py`:
- Around line 300-311: Update set_object_ref so the missing-task branch when
_live_task(task_id) returns None explicitly returns False, preserving the bool
contract and ensuring dispatch_indexing treats unknown or evicted registrations
as failures.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4233d284-ea88-47fd-b52c-af436a7b5629

📥 Commits

Reviewing files that changed from the base of the PR and between d450f14 and 4402d7e.

📒 Files selected for processing (42)
  • CLAUDE.md
  • openrag/api/dependencies/auth.py
  • openrag/api/routers/admin/indexing.py
  • openrag/core/indexing/dispatcher.py
  • openrag/core/models/catalog.py
  • openrag/core/ports/job_repo.py
  • openrag/core/ports/user_repo.py
  • openrag/core/utils/text.py
  • openrag/di/container.py
  • openrag/services/orchestrators/auth_service.py
  • openrag/services/orchestrators/indexing_service.py
  • openrag/services/orchestrators/job_service.py
  • openrag/services/orchestrators/mcp_service.py
  • openrag/services/orchestrators/user_service.py
  • openrag/services/persistence/__init__.py
  • openrag/services/persistence/document_repo.py
  • openrag/services/persistence/job_repo.py
  • openrag/services/persistence/migrations/alembic/versions/d4e5f6a7b8c9_add_jobs_table.py
  • openrag/services/persistence/schema.py
  • openrag/services/persistence/user_repo.py
  • openrag/services/storage/postgres_store.py
  • openrag/services/workers/dispatcher.py
  • openrag/services/workers/indexer_actor.py
  • openrag/services/workers/indexer_pool.py
  • openrag/services/workers/task_state.py
  • tests/integration/repos/conftest.py
  • tests/integration/repos/test_job_repo.py
  • tests/integration/repos/test_partition_repo.py
  • tests/integration/repos/test_user_repo_quota_reserve.py
  • tests/unit/api/dependencies/test_auth.py
  • tests/unit/api/routers/admin/test_indexing_quota_release.py
  • tests/unit/core/utils/test_text.py
  • tests/unit/services/orchestrators/test_indexing_service.py
  • tests/unit/services/orchestrators/test_job_service.py
  • tests/unit/services/orchestrators/test_mcp_service.py
  • tests/unit/services/orchestrators/test_user_service.py
  • tests/unit/services/persistence/test_job_repo.py
  • tests/unit/services/workers/test_dispatcher.py
  • tests/unit/services/workers/test_indexer_pool.py
  • tests/unit/services/workers/test_indexer_worker.py
  • tests/unit/services/workers/test_indexer_worker_quota_release.py
  • tests/unit/services/workers/test_task_state.py

Comment on lines +87 to +88
self._job_repo = PgJobRepository(pool_getter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mirror this move in the property section. The __init__ now correctly places _job_repo above the stubs block, but the job_repo property (Lines 178-180) still sits under the "Stub repos — methods raise StubRepositoryError…" header (Lines 173-176). That comment is now inaccurate for a durable repo and can mislead readers into thinking job_repo is unimplemented. Move the property up into the "Real repos" section.

🤖 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 `@openrag/services/storage/postgres_store.py` around lines 87 - 88, Move the
job_repo property from the stub repositories section into the Real repos
section, alongside the initialized _job_repo in the constructor. Keep the
property implementation unchanged and update its surrounding placement so the
stub-repository header only covers actual stubs.

Comment on lines +1256 to +1301
@pytest.mark.parametrize(
"error",
[RuntimeError("catalog init failed"), asyncio.CancelledError()],
ids=["exception", "cancellation"],
)
async def test_setup_failure_releases_the_reserved_slot(error):
"""Setup blew up before the worker owned the slot, so the pool must release it.

The worker's own ``finally`` never runs when ``_ensure_catalog`` /
``_ensure_registry_fresh`` raise, so without this release the upload
permanently narrows the user's quota. ``BaseException`` is deliberate:
a cancellation during setup must release too, and ``CancelledError`` is not
an ``Exception``.
"""
user_repo = _FakeUserRepo()
pool = _pool_with_broken_setup(user_repo, error=error)

with pytest.raises(type(error)):
await pool.process_file(
task_id="t1",
path="/tmp/doc.txt",
metadata={"file_id": "f1"},
partition="p1",
user={"id": 42},
quota_reserved=True,
)

assert user_repo.released == [42], "a setup failure leaked the reserved quota slot"


async def test_setup_failure_releases_nothing_when_no_slot_was_reserved():
"""The other half: no reservation, no release (``put_file`` never reserves)."""
user_repo = _FakeUserRepo()
pool = _pool_with_broken_setup(user_repo, error=RuntimeError("catalog init failed"))

with pytest.raises(RuntimeError):
await pool.process_file(
task_id="t1",
path="/tmp/doc.txt",
metadata={"file_id": "f1"},
partition="p1",
user={"id": 42},
quota_reserved=False,
)

assert user_repo.released == []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check pytest-asyncio mode; strict/legacy means unmarked async tests won't run.
rg -n 'asyncio_mode' -g 'pyproject.toml' -g 'pytest.ini' -g 'setup.cfg' -g 'tox.ini' || \
  echo "asyncio_mode not set → pytest-asyncio defaults to strict (markers required)"

Repository: linagora/openrag

Length of output: 230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="tests/unit/services/workers/test_indexer_pool.py"

# Show the relevant range with line numbers.
sed -n '1225,1315p' "$file" | cat -n

# Look for module-level pytest markers in this file.
rg -n 'pytestmark|mark\.asyncio|pytest\.asyncio' "$file"

Repository: linagora/openrag

Length of output: 4561


Add @pytest.mark.asyncio to both setup-failure tests. With no asyncio_mode configured, pytest-asyncio stays in strict mode here, so these coroutines are collected but never awaited and the quota-release assertions never run.

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 1275-1275: Do not hardcode temporary file or directory names
Context: "/tmp/doc.txt"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)


[info] 1293-1293: Do not hardcode temporary file or directory names
Context: "/tmp/doc.txt"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)

🤖 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 `@tests/unit/services/workers/test_indexer_pool.py` around lines 1256 - 1301,
Add the `@pytest.mark.asyncio` decorator to both
test_setup_failure_releases_the_reserved_slot and
test_setup_failure_releases_nothing_when_no_slot_was_reserved so pytest-asyncio
awaits them in strict mode and executes their assertions.

@hedhoud

hedhoud commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4402d7e290

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +685 to +691
ray.cancel(obj_ref["ref"], recursive=True)
# The reserved quota slot (#664) is deliberately not released here.
# A task cancelled mid-flight gives its slot back in
# ``IndexerWorker.process_file``'s ``finally``; releasing here too
# would double-release. But a task that ``ray.cancel`` retires
# *before* that body runs never executes the ``finally``, so its slot
# leaks -- and the CANCELLED row written just above is terminal, hence

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Release the reservation when cancellation wins before work starts

When a user cancels a task while it is still queued (a common case when the pool is busy), ray.cancel can retire the Ray call before IndexerWorker.process_file enters its finally. The request already committed its reservation after dispatch, and this path deliberately does not release it, so each such cancellation permanently consumes one file_count slot and eventually blocks the user from uploading despite having no corresponding file.

Useful? React with 👍 / 👎.

Comment on lines +127 to +130
# #664: the worker owns the uploader's reserved file slot and
# releases it when the file never reaches the catalog.
user_repo=self._catalog_store.user_repo,
job_repo=self._catalog_store.job_repo,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use the API catalog database for worker reservation releases

With an explicit settings.rdb.database, the API store preserves that configured database (di/repositories.py only derives a name when it is None), while IndexerWorkerActor still constructs _catalog_store using the collection-derived database (indexer_pool.py:89). Passing this worker-local user_repo here means failed/cancelled uploads release against a different database than the API admission increment, typically doing nothing and permanently leaking quota; the same split also leaves worker job-state updates unable to settle the API-created job rows.

Useful? React with 👍 / 👎.

Comment on lines +173 to +183
if jobs:
if len(jobs) > _LIST_LIMIT:
jobs = jobs[:_LIST_LIMIT]
logger.warning(
"Task list truncated at the page cap; the response is not the whole queue",
limit=_LIST_LIMIT,
task_status=task_status,
user_id=None if is_admin else user_id,
)
# Filtering happened in SQL; the fallback below has to do it itself.
return [{"task_id": j.id, "state": j.status.value, "details": self._job_details(j)} for j in jobs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge hot-cache tasks when durable history is only partial

If a durable job write is transiently lost (the dispatcher explicitly swallows such failures), while the jobs table already contains any historical row, this truthy result returns only Postgres jobs and omits the live actor-only task. Thus a successfully accepted upload can disappear from the task list during precisely the database degradation this fallback is intended to handle; get_queue_info has the same non-empty-table behavior for its active counts.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Fix issue

Projects

None yet

3 participants