slice_dispatch.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. status: SliceJobStatus = "pending"
  25. created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  26. started_at: datetime | None = None
  27. completed_at: datetime | None = None
  28. # On success: the body returned to the caller — usually a SliceResponse
  29. # or SliceArchiveResponse dict.
  30. result: dict[str, Any] | None = None
  31. # On failure: HTTP status + error message.
  32. error_status: int | None = None
  33. error_detail: str | None = None
  34. # Live progress fed by the sidecar's --pipe channel while the slicer
  35. # is running. Populated by a polling task spawned alongside the
  36. # blocking POST /slice request; None when the sidecar doesn't
  37. # support progress (older sidecars, no request_id, etc.). Surfaced
  38. # in the SliceJobState response so the persistent toast can render
  39. # "Generating G-code (75%)" instead of just elapsed time.
  40. progress: dict[str, Any] | None = None
  41. # Retention: keep finished jobs around for 30 minutes so the polling client
  42. # always sees a terminal state on its next tick. After that, the next access
  43. # sweep prunes them.
  44. _RETENTION_SECONDS = 30 * 60
  45. class SliceDispatchService:
  46. def __init__(self) -> None:
  47. self._jobs: dict[int, SliceJob] = {}
  48. self._next_id: int = 1
  49. self._lock = asyncio.Lock()
  50. self._tasks: dict[int, asyncio.Task] = {}
  51. async def enqueue(
  52. self,
  53. *,
  54. kind: Literal["library_file", "archive"],
  55. source_id: int,
  56. source_name: str,
  57. run: Callable[[int], Awaitable[dict[str, Any]]],
  58. ) -> SliceJob:
  59. """Register a new slice job and start it on the event loop.
  60. ``run`` is an async callable that takes the freshly-created
  61. ``job_id`` (so it can wire up live-progress reporting via
  62. :meth:`set_progress`) and returns the response body the caller
  63. will receive once status flips to ``completed``.
  64. """
  65. async with self._lock:
  66. job = SliceJob(
  67. id=self._next_id,
  68. kind=kind,
  69. source_id=source_id,
  70. source_name=source_name,
  71. )
  72. self._next_id += 1
  73. self._jobs[job.id] = job
  74. self._sweep_locked()
  75. task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
  76. self._tasks[job.id] = task
  77. return job
  78. async def _run_job(
  79. self,
  80. job: SliceJob,
  81. run: Callable[[int], Awaitable[dict[str, Any]]],
  82. ) -> None:
  83. job.started_at = datetime.now(timezone.utc)
  84. job.status = "running"
  85. try:
  86. result = await run(job.id)
  87. job.result = result
  88. job.status = "completed"
  89. except _SliceJobError as exc:
  90. # Caller-controlled HTTP error — propagate status + detail.
  91. job.status = "failed"
  92. job.error_status = exc.status_code
  93. job.error_detail = exc.detail
  94. except Exception as exc:
  95. logger.exception("Slice job %s failed unexpectedly", job.id)
  96. job.status = "failed"
  97. job.error_status = 500
  98. job.error_detail = f"Unexpected error: {exc}"
  99. finally:
  100. job.completed_at = datetime.now(timezone.utc)
  101. self._tasks.pop(job.id, None)
  102. def get(self, job_id: int) -> SliceJob | None:
  103. return self._jobs.get(job_id)
  104. def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
  105. """Update the live-progress snapshot for a running job.
  106. Called by the slice route's progress poller every ~1s while the
  107. sidecar slice request is in flight. Silently ignores unknown ids
  108. (the job may have just finished and been retention-swept) so a
  109. late poll doesn't crash the polling task.
  110. """
  111. job = self._jobs.get(job_id)
  112. if job is not None:
  113. job.progress = progress
  114. def _sweep_locked(self) -> None:
  115. """Drop finished jobs older than the retention window. Caller holds
  116. the lock."""
  117. now = datetime.now(timezone.utc)
  118. stale_ids = [
  119. jid
  120. for jid, job in self._jobs.items()
  121. if job.status in ("completed", "failed")
  122. and job.completed_at is not None
  123. and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
  124. ]
  125. for jid in stale_ids:
  126. self._jobs.pop(jid, None)
  127. class _SliceJobError(Exception):
  128. """Raised inside a slice job's `run` callable to surface a specific
  129. HTTP status + detail. The dispatcher catches these and stores them on
  130. the job. Callers convert ``HTTPException`` to this on the boundary.
  131. """
  132. def __init__(self, status_code: int, detail: str) -> None:
  133. super().__init__(detail)
  134. self.status_code = status_code
  135. self.detail = detail
  136. def http_exception_to_job_error(exc) -> _SliceJobError:
  137. """Convert a starlette ``HTTPException`` into the dispatcher's error
  138. type. Handles the common case where slice helpers raise FastAPI's
  139. ``HTTPException`` for validation / sidecar failures.
  140. """
  141. return _SliceJobError(exc.status_code, str(exc.detail))
  142. # Module-level singleton, started/stopped by main.py's lifespan.
  143. slice_dispatch = SliceDispatchService()