-
Notifications
You must be signed in to change notification settings - Fork 56
fix(quota): arbitrate the reserved file-slot release instead of leaking it #700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fix/660-durable-job-state
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,6 +63,7 @@ def __init__( | |
| collection: str, | ||
| timeout: float = DEFAULT_TIMEOUT, | ||
| job_repo: Any = None, | ||
| user_repo: Any = None, | ||
| ) -> None: | ||
| self._pool = pool | ||
| self._tsm = task_state_manager | ||
|
|
@@ -76,6 +77,10 @@ def __init__( | |
| # is absent, job state degrades to the in-memory actor — the pre-#660 | ||
| # behaviour. | ||
| self._job_repo = job_repo | ||
| # Optional for the same reason as ``job_repo``. Without it a cancel that | ||
| # beats the worker to the task cannot return the reserved slot, which is | ||
| # the pre-#664 behaviour rather than a new failure. | ||
| self._user_repo = user_repo | ||
| self._last_job_purge_at: float | None = None | ||
|
|
||
| async def _call(self, future: Any, task_description: str) -> Any: | ||
|
|
@@ -95,6 +100,7 @@ async def _set_queued_details( | |
| partition: str, | ||
| metadata: dict[str, Any], | ||
| user_id: int | None, | ||
| quota_reserved: bool = False, | ||
| ) -> bool: | ||
| remote = _remote_actor_method(self._tsm, "set_queued_details") | ||
| if remote is not None: | ||
|
|
@@ -105,6 +111,7 @@ async def _set_queued_details( | |
| partition=partition, | ||
| metadata=metadata, | ||
| user_id=user_id, | ||
| quota_reserved=quota_reserved, | ||
| ), | ||
| task_description=f"set_queued_details({task_id})", | ||
| ) | ||
|
|
@@ -121,6 +128,7 @@ async def _set_queued_details( | |
| partition=partition, | ||
| metadata=metadata, | ||
| user_id=user_id, | ||
| quota_reserved=quota_reserved, | ||
| ), | ||
| task_description=f"set_details({task_id})", | ||
| ) | ||
|
|
@@ -168,6 +176,9 @@ async def dispatch_indexing( | |
| partition=partition, | ||
| metadata=user_metadata, | ||
| user_id=user.get("id") if user else None, | ||
| # Tells the actor a slot is outstanding for this task, so | ||
| # ``claim_quota_release`` can arbitrate who gives it back. | ||
| quota_reserved=quota_reserved, | ||
| ) | ||
| if not accepted: | ||
| raise RuntimeError( | ||
|
|
@@ -683,17 +694,53 @@ async def cancel_task(self, task_id: str) -> bool: | |
| # raises: the actor has already claimed the cancellation, and | ||
| # leaving the worker running would contradict both records. | ||
| 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 | ||
| # indistinguishable from a clean cancel. Recovering it needs the #676 | ||
| # reconciliation to *recount* ``file_count`` (completed files + active | ||
| # job rows), not merely sweep orphaned active rows. | ||
| # Return the reserved quota slot (#664) if -- and only if -- this | ||
| # cancel is the reason nobody else will. A task cancelled mid-flight | ||
| # releases in ``IndexerWorker.process_file``'s ``finally``; a task that | ||
| # ``ray.cancel`` retires before that body runs never executes it, and | ||
| # used to leak the slot outright. ``claim_quota_release`` is a one-shot | ||
| # token in the state actor, so exactly one of the two wins and the | ||
| # outcome no longer depends on how far the worker got. | ||
| # | ||
| # After the release, not inside the ``finally`` above: the claim is | ||
| # only meaningful once ``ray.cancel`` has actually retired the task, | ||
| # and a failure here must not stop the worker from being killed. | ||
| await self._release_cancelled_slot(task_id) | ||
| return True | ||
|
|
||
| async def _release_cancelled_slot(self, task_id: str) -> None: | ||
| """Give a cancelled task's reserved file slot back, if we own it. | ||
|
|
||
| Best-effort throughout: a cancellation the user already asked for and | ||
| already saw must not fail because quota bookkeeping did. A lost release | ||
| leaves ``file_count`` one too high, which the #676 reconciliation | ||
| recount is there to repair. | ||
| """ | ||
| if self._user_repo is None: | ||
| return | ||
| try: | ||
| details = await self._call( | ||
| self._tsm.get_details.remote(task_id), | ||
| task_description=f"get_details({task_id})", | ||
| ) | ||
| user_id = (details or {}).get("user_id") | ||
| if user_id is None: | ||
| return | ||
| claimed = await self._call( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This claim does not distinguish a task that never started from one that has already created its catalog row. If cancellation lands after add_file_to_partition succeeds but before COMPLETED is reported, the dispatcher can win this claim and decrement file_count even though the file remains stored. Could the worker take ownership when processing starts, or otherwise mark the reservation consumed atomically, so only a true cancel-before-start releases here? This also needs a regression test for that timing window. |
||
| self._tsm.claim_quota_release.remote(task_id), | ||
| task_description=f"claim_quota_release({task_id})", | ||
| ) | ||
| if not claimed: | ||
| return | ||
| await self._user_repo.release_file_slot(user_id) | ||
| except Exception as exc: # noqa: BLE001 - the cancellation itself already succeeded | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If get_details succeeds but this arbitration RPC raises or times out, the outer handler only logs and leaves the slot reserved. That reintroduces the permanent leak this PR is meant to close and differs from the worker-side fallback. Could we release with the already-resolved user_id when the claim cannot be made, and cover that path with a regression test? |
||
| logger.warning( | ||
| "Could not release the reserved file slot for a cancelled task; " | ||
| "the user file_count may be one too high until reconciliation.", | ||
| task_id=task_id, | ||
| error=str(exc), | ||
| ) | ||
|
|
||
|
|
||
| def from_ray_namespace( | ||
| namespace: str = "openrag", | ||
|
|
@@ -704,6 +751,7 @@ def from_ray_namespace( | |
| workspace_repo: Any, | ||
| collection: str, | ||
| job_repo: Any = None, | ||
| user_repo: Any = None, | ||
| ) -> WorkerDispatcher: | ||
| import ray | ||
| from services.workers.indexer_pool import build_indexer_pool | ||
|
|
@@ -717,6 +765,7 @@ def from_ray_namespace( | |
| collection=collection, | ||
| timeout=timeout, | ||
| job_repo=job_repo, | ||
| user_repo=user_repo, | ||
| ) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TaskStateManager is detached and reused under the same actor name. During a rolling upgrade, the existing actor will not accept the new quota_reserved argument and also will not expose claim_quota_release, so new indexing requests can fail until Ray is manually restarted. Could we version the actor name or add an explicit compatibility/recreation guard before changing this contract?