slice_dispatch.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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 for API-key / auth-disabled callers (no per-row identity);
  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 set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
  111. """Update the live-progress snapshot for a running job.
  112. Called by the slice route's progress poller every ~1s while the
  113. sidecar slice request is in flight. Silently ignores unknown ids
  114. (the job may have just finished and been retention-swept) so a
  115. late poll doesn't crash the polling task.
  116. """
  117. job = self._jobs.get(job_id)
  118. if job is not None:
  119. job.progress = progress
  120. def _sweep_locked(self) -> None:
  121. """Drop finished jobs older than the retention window. Caller holds
  122. the lock."""
  123. now = datetime.now(timezone.utc)
  124. stale_ids = [
  125. jid
  126. for jid, job in self._jobs.items()
  127. if job.status in ("completed", "failed")
  128. and job.completed_at is not None
  129. and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
  130. ]
  131. for jid in stale_ids:
  132. self._jobs.pop(jid, None)
  133. class _SliceJobError(Exception):
  134. """Raised inside a slice job's `run` callable to surface a specific
  135. HTTP status + detail. The dispatcher catches these and stores them on
  136. the job. Callers convert ``HTTPException`` to this on the boundary.
  137. """
  138. def __init__(self, status_code: int, detail: str) -> None:
  139. super().__init__(detail)
  140. self.status_code = status_code
  141. self.detail = detail
  142. def http_exception_to_job_error(exc) -> _SliceJobError:
  143. """Convert a starlette ``HTTPException`` into the dispatcher's error
  144. type. Handles the common case where slice helpers raise FastAPI's
  145. ``HTTPException`` for validation / sidecar failures.
  146. """
  147. return _SliceJobError(exc.status_code, str(exc.detail))
  148. # Module-level singleton, started/stopped by main.py's lifespan.
  149. slice_dispatch = SliceDispatchService()