slice_dispatch.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """In-memory background dispatcher for slice jobs.
  2. Slice jobs are independent (no printer-busy gating), short-lived (typically
  3. 5-60s), and the result is a `LibraryFile` or `PrintArchive` row rather than a
  4. printer-side dispatch.
  5. The frontend kicks off a slice via `POST /library/files/{id}/slice` or
  6. `POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls
  7. `GET /slice-jobs/{id}` until status is `completed` or `failed`.
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import logging
  12. from collections.abc import Awaitable, Callable
  13. from dataclasses import dataclass, field
  14. from datetime import datetime, timezone
  15. from typing import Any, Literal
  16. logger = logging.getLogger(__name__)
  17. SliceJobStatus = Literal["pending", "running", "completed", "failed"]
  18. @dataclass(slots=True)
  19. class SliceJob:
  20. id: int
  21. kind: Literal["library_file", "archive"]
  22. source_id: int
  23. source_name: str
  24. # JWT user id that started the job, for per-row scoping of the polling
  25. # endpoint. None when no user was recorded (auth off, some API-key paths);
  26. # those jobs are visible only to READ_ALL pollers — see slice_jobs.py.
  27. owner_id: int | None = None
  28. status: SliceJobStatus = "pending"
  29. created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  30. started_at: datetime | None = None
  31. completed_at: datetime | None = None
  32. # On success: the body returned to the caller — usually a SliceResponse
  33. # or SliceArchiveResponse dict.
  34. result: dict[str, Any] | None = None
  35. # On failure: HTTP status + error message.
  36. error_status: int | None = None
  37. error_detail: str | None = None
  38. # Live progress fed by the sidecar's --pipe channel while the slicer
  39. # is running. Populated by a polling task spawned alongside the
  40. # blocking POST /slice request; None when the sidecar doesn't
  41. # support progress (older sidecars, no request_id, etc.). Surfaced
  42. # in the SliceJobState response so the persistent toast can render
  43. # "Generating G-code (75%)" instead of just elapsed time.
  44. progress: dict[str, Any] | None = None
  45. # Retention: keep finished jobs around for 30 minutes so the polling client
  46. # always sees a terminal state on its next tick. After that, the next access
  47. # sweep prunes them.
  48. _RETENTION_SECONDS = 30 * 60
  49. class SliceDispatchService:
  50. def __init__(self) -> None:
  51. self._jobs: dict[int, SliceJob] = {}
  52. self._next_id: int = 1
  53. self._lock = asyncio.Lock()
  54. self._tasks: dict[int, asyncio.Task] = {}
  55. async def enqueue(
  56. self,
  57. *,
  58. kind: Literal["library_file", "archive"],
  59. source_id: int,
  60. source_name: str,
  61. owner_id: int | None = None,
  62. run: Callable[[int], Awaitable[dict[str, Any]]],
  63. ) -> SliceJob:
  64. """Register a new slice job and start it on the event loop.
  65. ``run`` is an async callable that takes the freshly-created
  66. ``job_id`` (so it can wire up live-progress reporting via
  67. :meth:`set_progress`) and returns the response body the caller
  68. will receive once status flips to ``completed``.
  69. """
  70. async with self._lock:
  71. job = SliceJob(
  72. id=self._next_id,
  73. kind=kind,
  74. source_id=source_id,
  75. source_name=source_name,
  76. owner_id=owner_id,
  77. )
  78. self._next_id += 1
  79. self._jobs[job.id] = job
  80. self._sweep_locked()
  81. task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
  82. self._tasks[job.id] = task
  83. return job
  84. async def _run_job(
  85. self,
  86. job: SliceJob,
  87. run: Callable[[int], Awaitable[dict[str, Any]]],
  88. ) -> None:
  89. job.started_at = datetime.now(timezone.utc)
  90. job.status = "running"
  91. try:
  92. result = await run(job.id)
  93. job.result = result
  94. job.status = "completed"
  95. except _SliceJobError as exc:
  96. # Caller-controlled HTTP error — propagate status + detail.
  97. job.status = "failed"
  98. job.error_status = exc.status_code
  99. job.error_detail = exc.detail
  100. except Exception as exc:
  101. logger.exception("Slice job %s failed unexpectedly", job.id)
  102. job.status = "failed"
  103. job.error_status = 500
  104. job.error_detail = f"Unexpected error: {exc}"
  105. finally:
  106. job.completed_at = datetime.now(timezone.utc)
  107. self._tasks.pop(job.id, None)
  108. def get(self, job_id: int) -> SliceJob | None:
  109. return self._jobs.get(job_id)
  110. def forget_owner(self, user_id: int) -> None:
  111. """A deleted user's jobs lose their owner (only READ_ALL pollers see them)."""
  112. for job in self._jobs.values():
  113. if job.owner_id == user_id:
  114. job.owner_id = None
  115. def owner_now(self, job_id: int | None, user_id: int | None) -> int | None:
  116. """*user_id*, unless job *job_id* lost that owner while it ran.
  117. A slice takes minutes. When its user is deleted meanwhile (forget_owner),
  118. the result belongs to nobody: the id may already be another user's.
  119. """
  120. if job_id is None or user_id is None:
  121. return user_id
  122. job = self._jobs.get(job_id)
  123. return user_id if job is not None and job.owner_id == user_id else None
  124. def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
  125. """Update the live-progress snapshot for a running job.
  126. Called by the slice route's progress poller every ~1s while the
  127. sidecar slice request is in flight. Silently ignores unknown ids
  128. (the job may have just finished and been retention-swept) so a
  129. late poll doesn't crash the polling task.
  130. """
  131. job = self._jobs.get(job_id)
  132. if job is not None:
  133. job.progress = progress
  134. def _sweep_locked(self) -> None:
  135. """Drop finished jobs older than the retention window. Caller holds
  136. the lock."""
  137. now = datetime.now(timezone.utc)
  138. stale_ids = [
  139. jid
  140. for jid, job in self._jobs.items()
  141. if job.status in ("completed", "failed")
  142. and job.completed_at is not None
  143. and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
  144. ]
  145. for jid in stale_ids:
  146. self._jobs.pop(jid, None)
  147. class _SliceJobError(Exception):
  148. """Raised inside a slice job's `run` callable to surface a specific
  149. HTTP status + detail. The dispatcher catches these and stores them on
  150. the job. Callers convert ``HTTPException`` to this on the boundary.
  151. """
  152. def __init__(self, status_code: int, detail: str) -> None:
  153. super().__init__(detail)
  154. self.status_code = status_code
  155. self.detail = detail
  156. def http_exception_to_job_error(exc) -> _SliceJobError:
  157. """Convert a starlette ``HTTPException`` into the dispatcher's error
  158. type. Handles the common case where slice helpers raise FastAPI's
  159. ``HTTPException`` for validation / sidecar failures.
  160. """
  161. return _SliceJobError(exc.status_code, str(exc.detail))
  162. # Module-level singleton, started/stopped by main.py's lifespan.
  163. slice_dispatch = SliceDispatchService()