fix: atomic file-quota reserve + durable indexation job state - #677
fix: atomic file-quota reserve + durable indexation job state#677Ahmath-Gadji wants to merge 33 commits into
Conversation
📝 WalkthroughWalkthroughThe 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. ChangesFile quota admission
Durable indexation state
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
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
EnjoyBacon7
left a comment
There was a problem hiding this comment.
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_reservationruns once dispatch returns a task id), so its teardown won't release; IndexerWorkerActor.process_file'sexcept BaseExceptiononly 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
dcffc1fd—list_tasksfails closed for an anonymous non-admin.list_jobs(user_id=None)means every job, and there's no guard foris_admin=False, user_id=None. Unreachable today (the HTTP path always resolves an id, MCP 403s first), but the MCP_USER_IDContextVar's unset default is exactlyNone— the escalating value. One middleware-ordering change from a data leak.704d00f6— logging/naming consistency inindexer_actor: the two new helpers each invented an idiom (function-localget_loggerimport; stdliblogging.getLogger(__name__)with %s-formatting) where every other module underservices/workers/bindslogger = get_logger()at import. Also dropped the underscore fromrelease_quota_slot, sinceindexer_poolimports it — it's module API, and its neighbourdelete_uploaded_fileis 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/developchecked out inside a directory whose parent holds a.env, and all pass on the same commit checked out outside it. Yourload_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-< 0ordering is right, and the point about a negative global default not overriding an explicit per-user limit is a good catch. - Migration chain:
b7c1d2e3f4a5really is the current head, single head after this revision, up/down idempotent,create_all→upgrade headconverges. update_job'senumerate(..., start=2)parameter numbering is correct — dict ordering keeps assignments andupdates.values()aligned.TaskStateManagereviction is sound under stress (50k tasks/100 users → caps hold at 2000, no danglinguser_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.
13c496e to
2d26598
Compare
0bf87f5 to
0b26864
Compare
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.
a0286dd to
4402d7e
Compare
There was a problem hiding this comment.
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 winReturn
Falsefor missing task registrations
set_object_refcan returnNonefor an unknown or evicted task, butdispatch_indexingonly treatsFalseas a failed registration, so this falls through as success. ReturnFalsehere to match the delete-fence branch and the-> boolcontract.🩹 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
📒 Files selected for processing (42)
CLAUDE.mdopenrag/api/dependencies/auth.pyopenrag/api/routers/admin/indexing.pyopenrag/core/indexing/dispatcher.pyopenrag/core/models/catalog.pyopenrag/core/ports/job_repo.pyopenrag/core/ports/user_repo.pyopenrag/core/utils/text.pyopenrag/di/container.pyopenrag/services/orchestrators/auth_service.pyopenrag/services/orchestrators/indexing_service.pyopenrag/services/orchestrators/job_service.pyopenrag/services/orchestrators/mcp_service.pyopenrag/services/orchestrators/user_service.pyopenrag/services/persistence/__init__.pyopenrag/services/persistence/document_repo.pyopenrag/services/persistence/job_repo.pyopenrag/services/persistence/migrations/alembic/versions/d4e5f6a7b8c9_add_jobs_table.pyopenrag/services/persistence/schema.pyopenrag/services/persistence/user_repo.pyopenrag/services/storage/postgres_store.pyopenrag/services/workers/dispatcher.pyopenrag/services/workers/indexer_actor.pyopenrag/services/workers/indexer_pool.pyopenrag/services/workers/task_state.pytests/integration/repos/conftest.pytests/integration/repos/test_job_repo.pytests/integration/repos/test_partition_repo.pytests/integration/repos/test_user_repo_quota_reserve.pytests/unit/api/dependencies/test_auth.pytests/unit/api/routers/admin/test_indexing_quota_release.pytests/unit/core/utils/test_text.pytests/unit/services/orchestrators/test_indexing_service.pytests/unit/services/orchestrators/test_job_service.pytests/unit/services/orchestrators/test_mcp_service.pytests/unit/services/orchestrators/test_user_service.pytests/unit/services/persistence/test_job_repo.pytests/unit/services/workers/test_dispatcher.pytests/unit/services/workers/test_indexer_pool.pytests/unit/services/workers/test_indexer_worker.pytests/unit/services/workers/test_indexer_worker_quota_release.pytests/unit/services/workers/test_task_state.py
| self._job_repo = PgJobRepository(pool_getter) | ||
|
|
There was a problem hiding this comment.
📐 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.
| @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 == [] |
There was a problem hiding this comment.
📐 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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| # #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, |
There was a problem hiding this comment.
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 👍 / 👎.
| 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] |
There was a problem hiding this comment.
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 👍 / 👎.
Two related fixes that share a root cause — indexing state living only in the in-memory
TaskStateManagerRay 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
1.
fix(quota): reserve file slots atomically at admission (#664)The per-user file quota was a check/admit TOCTOU race:
check_user_file_quotacompared a staleusers.file_countsnapshot 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:The predicate reproduces the documented resolved-quota semantics exactly. Note the
COALESCEresolves the per-user quota against the global default before the< 0test — the naivefile_quota < 0from the issue body would let a negative global default silently override an explicit per-user limit. No row returned means no slot.file_counttherefore becomes a reserved + completed counter, so the completion-time increment inadd_file_to_partitionis removed (it would double-count), and every reservation now needs an owner:check_user_file_quotais a yield dependency whose teardown releases anything uncommitted — covering the 409 duplicate, upload rejection, workspace validation, dispatch errors, and client disconnects.IndexerWorker.process_filereleases in afinally— deliberately not anexcept, which cancellation would skip, sinceray.cancelraisesCancelledError(aBaseException). This covers indexing failure, cancellation, and the duplicate-at-catalog race.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_infostops adding it tofile_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_quotaalready raisedFILE_QUOTA_EXCEEDEDas 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_countat zero, and/users/infodisplays 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 (
PgJobRepositorywas a stub whose every method raised, with nojobstable and zero writers).d4e5f6a7b8c9adds ajobstable (one row per dispatched task,id=task_id, status CHECK over the 7 states, three indexes). Guarded withtable_exists/index_existsin both directions, becausemetadata.create_all()runs at startup and an unguarded CREATE would raiseDuplicateTable. Constraints stay unnamed so the migration andcreate_allconverge on the same Postgres-default names. No FK topartitions— a job row is a historical record and must outlive the partition it targeted.PgJobRepositoryreplaces the stub.IndexationJobbecomes a per-task record reusingDocumentStatus(matches the actor's taxonomy verbatim;JobStatuslacked SERIALIZING/CHUNKING/INSERTING/CANCELLED).QUEUEDfrom 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/FAILEDfrom the worker,CANCELLEDfromcancel_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.object_refis 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 durableFAILEDcannot overwrite a cancellation the user already asked for and already saw.On the in-memory pending count
get_user_pending_task_countwas 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-flightset_stateran outside the try whosefinallyreleases, so an unreachable state actor leaked the slot permanently — the request had already handed ownership over at dispatch.fix(quota): reserve for the MCPindex_url/copy_filetools. They createfilesrows 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 — drivingfile_countbelow 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_IDContextVar defaults toNone.fix(jobs): treat an empty durable read as a miss, not an empty queue. The fallback only triggered when the repo raised —[]/{}are notNone, 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 fromjobs:/queue/tasksanswered[]and/queue/inforeported 0 active while workers were indexing. Both aggregate readers now fall through on empty;get_task_detailskeepsis not None, since for a single rowNonealready is the miss.fix(workers): drop a late write instead of resurrecting an evicted task.set_error/set_details/set_object_refwent through_ensure_task, which recreates on miss; the recreated entry hasstate=None, never re-entersterminal_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 writesset_statefirst), but silent and permanent if it ever is.fix(jobs): upper-case a job status before it reaches the CHECK._expand_statusnormalized case,_status_valuedid not; a lower-case status would violateck_jobs_status, and best-effort writes would swallow it, freezing the job at its previous status.refactor(workers): use the project logger inindexer_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-cascadefile_countassertion. Dead on develop (create_legacy_usersignature 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_reservationonly stops the request's teardown from releasing, whilequota_reserved=Trueis 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 inindexer_poolreleases through the store whose init just failed;truncate_error_textbounds the retained text, not the returned string.fix(jobs): stoplist_taskstruncating the queue silently._LIST_LIMITcaps 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_taskto claim the cancellation before signallingray.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 offterminal_at, and nothing writes that task's state again —ray.cancelraisesCancelledError, aBaseExceptionthatprocess_file'sexcept Exceptionsails past. So every user-initiated cancel retained aTaskInfo, itsuser_indexentry and its pinnedobject_reffor 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_evictablemissed it because it drivesset_state(..., "CANCELLED"), a path no cancellation actually takes.fix(jobs): keep a cancellation sticky in the durable row too.mark_failed_if_not_cancelledarbitrates 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 afterray.cancelhas killed the only writer that could finish it — and retention sweeps terminal rows only, so it never ages out.update_jobnow carries the same guard, mirroring the stickinessTaskStateManager.set_statealready enforces in memory.docs(quota): name the residual ambiguity at the submit boundary. Asubmittimeout 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 --checkclean;scripts/check_layer_imports.py→layer import guard: OK.test_create_is_idempotent_per_name—create_partitionraisesPARTITION_EXISTSwhere the test expects the conflict swallowed) is pre-existing, confirmed identical on cleanorigin/developwith the same DSN, and fixed separately in test(repos): assert create_partition rejects duplicates, as it does #691.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 emptyRERANKER_PROVIDER=there breaks a pydantic discriminator. A checkout with its own.envshadows 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.create_all→upgrade headdoes 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 onjobs_pkey/jobs_user_id_fkey. Single head (d4e5f6a7b8c9).quota_reserved=Truefrom the router, and dropping the status upper-casing in_status_value, each fail exactly one test.Known gaps — tracked in #676
cancel_taskreturns early when the actor no longer holds anobject_ref, so an operator cannot force one terminal by hand either. Theactiveroll-up in/queue/infotherefore drifts upward across such events until reconciliation lands.process_file'sfinally, so ifray.cancelretires the task before that body runs the release never happens — andcancel_taskcannot release itself without double-releasing a task cancelled mid-flight (whosefinallydoes run). Because the cancel writes a terminalCANCELLEDrow, 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 recountfile_count(completed files + active job rows), not merely sweep orphaned active rows.set_object_refthen 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.Review notes
JobService.list_taskscaps 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.JobStatusis now unused; left in place rather than change a public model export.IndexationJob.total_documentswas dropped — it had no readers and the model is internal (it appears in no API response).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
replacere-index.16.1
_write_catalog_record— two incompatible meanings for one boolThe two branches gave the same return value different meanings:
Falseon areplace, because that is the verdict that decides whether the reserved quota slot was consumed (File-quota check/admit is a TOCTOU race (quota bypass under concurrent uploads) #664).Trueon either branch, because it added a fail-closedif not wrote_catalog: raise RuntimeError("Catalog row was not written after vector indexing")on that value, so arequire_existing_partitionrejection surfaces instead of silently no-opping.Merged textually,
replacekeeps returningFalseand #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_recordreports whether the write landed (#671's meaning, so the fail-closed guard is correct), and the quota verdict is derived at the call site fromreplace: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_partitionreturnsFalsefor the loser, the fail-closed guard raises, andprocess_file'sfinallyreleases the slot on the way out.test_duplicate_at_catalog_releases_the_slotwas updated to assert both halves (it raises and the slot comes back) so neither can regress silently, andtest_a_replace_reindex_does_not_consume_the_reservationwas rewritten against the new contract astest_a_replace_reindex_updates_the_row_without_creating_one.Verified live, not just in tests: a
PUTre-index over an existing file returns202and settlesCOMPLETED, withusers.file_countunchanged.16.2
4ec0a634is obsoleted by #671, and its test contradicted the new behaviourfix(quota): don't report a dispatch failure once the worker has startedargued that oncesubmitreturns, the worker owns the reserved slot and will run to completion — so a failingset_object_refmust not fail the dispatch, or the router skipscommit_quota_reservationand teardown releases a slot the worker also owns.#671 invalidated the premise.
dispatch_indexingnow rolls the dispatch back:_cancel_submitted_taskcancels the worker and_cleanup_submitted_vectorssweeps what it wrote. The worker no longer runs to completion, so the error is truthful and must propagate — and #671'stest_dispatch_indexing_cancels_worker_when_ref_registration_failsasserts 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 ondevelop. The residual cost is a double release (the cancelled worker'sfinallyand 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
Falseis #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 returnsFalsewhen a delete fence covers the file, and forces the in-memory state toCANCELLED.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/infofor good. Exactly the orphan class §16.5 exists to close, introduced by the rebase rather than fixed by it.16.4
quota_reservedthreads through #671'ssubmit_kwargs, unconditionally#671 hoisted the nine inline
submit.remote(...)keywords into asubmit_kwargsdict, and addsrequire_existing_partitionto it only when true so a rolling-deploy actor without the kwarg still accepts the common case.quota_reservedis passed unconditionally, like the nine base kwargs. The conditional pattern exists for rolling-deploy tolerance, and_is_legacy_require_existing_partition_rejectionis 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 rejectquota_reservedis old enough to predate the whole quota model.16.5 The dispatch-orphan fix lands in #671's failure path, not around
submitThe fix from §16's sibling commit (
settle a task that never reaches a worker) originally wrapped thesubmitcall. Ondevelopthat call lives in_submit_indexing_task_once, which_submit_indexing_taskretries for legacy actors — settling there would write a spuriousFAILEDfor 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_taskconfirming the task really was cancelled. So the durableFAILEDinherits 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_faileddrove its actor write through an unguardedself._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_taskcomposes with #671's_record_detailsb7e0b966stopsset_error/set_details/set_object_refrecreating an evicted task, while #671 refactoredset_detailsonto a shared_record_detailshelper. Merged soset_detailsdrops the write on a miss and still uses the shared helper._ensure_tasknow has exactly two callers —set_stateand #671'sset_queued_details— and both are legitimate creation points for a task admission.16.7
copy_filekeeps both changes#671 added
strip_internal_metadataso_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 explicitreturn Falserather than the barereturn#671 left — under a-> boolsignature that returnedNone, 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, whichdelete_fileanddelete_partitioncall to cancel every active task for a file or partition; it writesset_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_refswrites its state afterray.canceland a settle-wait, which is the opposite ordering fromcancel_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 --checkclean;layer import guard: OK.test_create_is_idempotent_per_name, pre-existing and unrelated, fixed separately in test(repos): assert create_partition rejects duplicates, as it does #691.COMPLETED, areplacere-index settlesCOMPLETEDwithout consuming a slot (§16.1), and a cancel writes a terminalCANCELLEDand returns the slot.Summary by CodeRabbit
New Features
Bug Fixes
Documentation