diff --git a/backend/app/api/routes/batches.py b/backend/app/api/routes/batches.py new file mode 100644 index 0000000..a075398 --- /dev/null +++ b/backend/app/api/routes/batches.py @@ -0,0 +1,69 @@ +"""Phase 2: GET /api/batches/{id} — Snapshot für Hangar Result-Page Initial-Render.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_read +from app.db.session import get_session +from app.models.job import JobState +from app.repositories import jobs as jobs_repo +from app.repositories import print_batches as batches_repo +from app.schemas.batch_read import BatchRead, BatchSummary +from app.schemas.job import JobRead + +router = APIRouter(prefix="/api/batches", tags=["batches"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +ReadAuthDep = Annotated[AuthContext, Depends(require_read)] + + +@router.get("/{batch_id}", response_model=BatchRead) +async def get_batch( + batch_id: UUID, + session: SessionDep, + _auth: ReadAuthDep, +) -> BatchRead: + """Snapshot eines Batches + aller aktuellen Job-States. + + Wird von Hangar's /admin/print/result/{batch_id} für das initiale + Rendering genutzt. summary.all_terminal == False bedeutet, dass Hangar + einen SSE-Stream zu /api/events?batch_id=... öffnen sollte für + Live-Updates. + """ + batch = await batches_repo.get(session, batch_id) + if batch is None: + raise HTTPException(status_code=404, detail="Batch not found") + + # batch.job_ids is list[str] — convert to UUID for repo query + job_uuids = [UUID(jid) for jid in batch.job_ids] + fetched_jobs = await jobs_repo.list_by_ids(session, job_uuids) + job_map = {str(j.id): j for j in fetched_jobs} + + # Reihenfolge entspricht batch.job_ids; cleanup-evicted Jobs werden übersprungen + ordered = [job_map[jid] for jid in batch.job_ids if jid in job_map] + + summary = BatchSummary( + total=len(ordered), + queued=sum(1 for j in ordered if j.state == JobState.QUEUED.value), + printing=sum(1 for j in ordered if j.state == JobState.PRINTING.value), + done=sum(1 for j in ordered if j.state == JobState.DONE.value), + failed=sum( + 1 for j in ordered if j.state in (JobState.FAILED.value, JobState.FAILED_RESTART.value) + ), + cancelled=sum(1 for j in ordered if j.state == JobState.CANCELLED.value), + ) + + return BatchRead( + id=batch.id, + printer_id=batch.printer_id, + created_by=batch.created_by, + created_at=batch.created_at, + jobs=[JobRead.model_validate(j) for j in ordered], + summary=summary, + ) diff --git a/backend/app/api/routes/print.py b/backend/app/api/routes/print.py index f1c790b..f9fbbe4 100644 --- a/backend/app/api/routes/print.py +++ b/backend/app/api/routes/print.py @@ -79,7 +79,8 @@ async def create_print_job( "loaded_mm": exc.loaded_mm, } return JSONResponse(status_code=http_status, content=body) - return PrintJobResponse(job_id=job_id, status="queued") + # Phase 2: submit_print_job gibt jetzt UUID zurück; Response-Schema erwartet str. + return PrintJobResponse(job_id=str(job_id), status="queued") @router.get( diff --git a/backend/app/config.py b/backend/app/config.py index 0f362b5..bc90c45 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -106,6 +106,16 @@ class Settings(BaseSettings): # Set to False during transition to avoid surprising existing automation. pangolin_bypass_scope_downgrade: bool = False + # Phase 2: Job-Retention für CleanupTask + job_retention_days: int = Field( + default=30, + ge=1, + description=( + "Terminal Jobs (DONE/FAILED/FAILED_RESTART/CANCELLED) werden nach diesem Zeitraum " + "vom CleanupTask gelöscht" + ), + ) + @field_validator("webhook_api_key") @classmethod def validate_api_key_length(cls, v: SecretStr) -> SecretStr: diff --git a/backend/app/main.py b/backend/app/main.py index 74d67be..d537cf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -76,6 +76,7 @@ from app import __version__ from app.api.error_handlers import register_error_handlers from app.api.routes import batch as batch_routes +from app.api.routes import batches as batches_routes from app.api.routes import events as events_routes from app.api.routes import jobs as jobs_routes from app.api.routes import lookup as lookup_routes @@ -91,7 +92,6 @@ from app.db.engine import async_session, engine from app.db.lifespan import ( ensure_printer_state, - recover_inflight_jobs, run_migrations, seed_templates, upsert_runtime_printer, @@ -99,12 +99,15 @@ ) from app.db.session import get_session from app.integrations.registry import IntegrationRegistry +from app.models.printer import Printer as _Printer from app.printer_backends import BackendRegistry from app.printer_backends.exceptions import SnmpDiscoveryError from app.printer_backends.snmp_helper import query_model_pjl from app.printer_models.registry import ModelRegistry from app.schemas.readiness import ReadinessResponse +from app.services.cleanup_task import CleanupTask from app.services.event_bus import EventBus +from app.services.job_store_sqlite import SQLiteJobStore from app.services.label_renderer import LabelRenderer from app.services.lookup_service import AppLookupService from app.services.print_queue import PrintQueue @@ -270,13 +273,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # 4. DB-bound init — plugin registry and template cache are populated. async with async_session() as s: - await recover_inflight_jobs(s) + # Phase 2: recover_inflight_jobs() entfernt (Spec R1-C1) — + # PrintQueue.start() übernimmt Recovery mit korrekter QUEUED/PRINTING-Differenzierung. await seed_templates(s, TemplateLoader) db_printer_id = await upsert_runtime_printer(s, settings) await ensure_printer_state(s) await s.commit() # ------------------------------------------------------------------------- + # Phase 2: JobStore + CleanupTask + # 'async_session' ist die async_sessionmaker aus app.db.engine (R2-M5) + job_store = SQLiteJobStore(async_session) + + cleanup_task = CleanupTask( + store=job_store, + retention_days=settings.job_retention_days, + ) + await cleanup_task.start() + app.state.cleanup_task = cleanup_task + discovery_host = settings.pt750w_host or "" if discovery_host and settings.printer_discover_via_snmp: model_id = await _resolve_model_id(settings, discovery_host) @@ -296,15 +311,51 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: tape_registry = TapeRegistry() printer = driver.make_queue_printer(tape_registry, printer_id=db_printer_id) + if db_printer_id is None: + # Wenn kein Host konfiguriert ist (Mock-Backend / CI), liefert + # upsert_runtime_printer None zurück und fügt keine Printer-Row ein. + # make_queue_printer erzeugt dann eine neue uuid4. Damit + # jobs.printer_id (FK → printers.id) bei save_queued nicht verletzt + # wird, legen wir hier eine Stub-Row an. slug wird auf str(id) gesetzt + # (eindeutig durch UUID), damit der UNIQUE-Constraint nicht verletzt wird. + _stub_slug = str(printer.id) + async with async_session() as s: + # Defensive idempotency: in non-mock production paths the printer_id + # is explicit and would be reused, so the existing check matters + # there. In mock paths (printer_id=None), a fresh uuid4 means + # existing is always None. + existing = await s.get(_Printer, printer.id) + if existing is None: + s.add( + _Printer( + id=printer.id, + name=f"stub-{printer.id}", + slug=_stub_slug, + model=model_id.lower(), + backend=settings.printer_backend, + ) + ) + await s.commit() + # --- SSE EventBus --- event_bus = EventBus(queue_size=settings.sse_queue_size) app.state.event_bus = event_bus # ----- end SSE ------ + # Shared LabelRenderer reused by both PrintService, preview endpoint and + # PrintQueue Recovery. Constructing it once avoids repeated font-loading + # overhead on every POST /api/render/preview request. + # Moved before PrintQueue construction so Recovery in queue.start() can use it. + shared_renderer = LabelRenderer() + app.state.label_renderer = shared_renderer + pq_producer = PrintQueueProducer(bus=event_bus) queue = PrintQueue( printers=[printer], on_state_change=pq_producer.handle_transition, + store=job_store, + renderer=shared_renderer, + loader=TemplateLoader, ) await queue.start() @@ -329,11 +380,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.printer_id = printer.id app.state.printer_host = discovery_host app.state.printer_snmp_community = settings.printer_snmp_community - # Shared LabelRenderer reused by both PrintService and the preview endpoint. - # Constructing it once avoids repeated font-loading overhead on every - # POST /api/render/preview request. - shared_renderer = LabelRenderer() - app.state.label_renderer = shared_renderer app.state.print_service = PrintService( template_loader=TemplateLoader, renderer=shared_renderer, @@ -341,6 +387,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: lookup_service=AppLookupService(), printer_id=printer.id, backend=backend, + store=job_store, ) try: @@ -349,6 +396,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: if status_producer is not None: await status_producer.stop() await queue.stop(timeout_s=settings.printer_queue_timeout_s) + await cleanup_task.stop() await engine.dispose() # Close shared HTTP clients held by integration plugins that support it. # Plugins that pre-date connection pooling may not have aclose(); skip them. @@ -597,6 +645,7 @@ async def readiness( register_error_handlers(app) app.include_router(print_router) app.include_router(batch_routes.router) + app.include_router(batches_routes.router) app.include_router(events_routes.router) app.include_router(printers_routes.router) app.include_router(templates_routes.router) diff --git a/backend/app/repositories/jobs.py b/backend/app/repositories/jobs.py index e2be3c6..e61d260 100644 --- a/backend/app/repositories/jobs.py +++ b/backend/app/repositories/jobs.py @@ -2,11 +2,11 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col @@ -160,12 +160,98 @@ async def mark_inflight_as_failed_restart(session: AsyncSession) -> int: return int(result.rowcount) # type: ignore[attr-defined] # rowcount on UPDATE result -async def list_active(session: AsyncSession) -> list[Job]: - """Return all jobs in QUEUED or PRINTING state (covered by ix_jobs_state).""" +async def list_active( + session: AsyncSession, + *, + printer_id: UUID | None = None, +) -> list[Job]: + """Return all jobs in QUEUED or PRINTING state (covered by ix_jobs_state). + + Phase 2: optional printer_id filter for PrintQueue.start() recovery. + """ inflight = (JobState.QUEUED.value, JobState.PRINTING.value) - result = await session.execute( - select(Job) - .where(col(Job.state).in_(inflight)) # col() gives proper Column typing for .in_() - .order_by(col(Job.created_at)) # col() gives proper Column typing + stmt = select(Job).where(col(Job.state).in_(inflight)) + if printer_id is not None: + stmt = stmt.where(col(Job.printer_id) == printer_id) + stmt = stmt.order_by(col(Job.created_at)) + result = await session.execute(stmt) + return list(result.scalars()) + + +async def mark_printing_as_failed_restart( + session: AsyncSession, + printer_id: UUID, +) -> int: + """Phase 2: UPDATE only PRINTING jobs for a specific printer to + FAILED_RESTART with error='printer_interrupted'. + + Used at PrintQueue.start() — QUEUED jobs are NOT affected because + they will be re-enqueued cleanly. Only PRINTING jobs are ambiguous + (printer may have completed before crash but Hub couldn't update DB). + + Returns the count of affected rows. + """ + stmt = ( + update(Job) + .where( + col(Job.printer_id) == printer_id, + col(Job.state) == JobState.PRINTING.value, + ) + .values( + state=JobState.FAILED_RESTART.value, + error="printer_interrupted", + finished_at=datetime.now(UTC), + ) + .execution_options(synchronize_session="fetch") ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount) # type: ignore[attr-defined] + + +async def list_by_ids( + session: AsyncSession, + job_ids: list[UUID], +) -> list[Job]: + """Bulk-Fetch jobs by ids — order not guaranteed, caller re-orders. + + Phase 2: used by GET /api/batches/{id} to load all jobs referenced + by a PrintBatch.job_ids list in a single SQL query. + """ + if not job_ids: + return [] + result = await session.execute(select(Job).where(col(Job.id).in_(job_ids))) return list(result.scalars()) + + +async def evict_terminal_older_than( + session: AsyncSession, + age: timedelta, +) -> int: + """Phase 2 cleanup: DELETE terminal jobs older than age. + + Terminal = DONE | FAILED | FAILED_RESTART | CANCELLED. + Comparison is on finished_at (set whenever a job leaves a non-terminal state). + + Jobs with finished_at IS NULL are NOT deleted (NULL < cutoff is SQL UNKNOWN, + which is falsy in WHERE). This is intentional — protects pre-Phase-2 rows + that may not have finished_at set. + + Returns the count of deleted rows. + """ + terminal = ( + JobState.DONE.value, + JobState.FAILED.value, + JobState.FAILED_RESTART.value, + JobState.CANCELLED.value, + ) + cutoff = datetime.now(UTC) - age + stmt = ( + delete(Job) + .where(col(Job.state).in_(terminal)) + .where(col(Job.finished_at) < cutoff) + .execution_options(synchronize_session="fetch") + ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount) # type: ignore[attr-defined] diff --git a/backend/app/schemas/batch_read.py b/backend/app/schemas/batch_read.py new file mode 100644 index 0000000..310e59f --- /dev/null +++ b/backend/app/schemas/batch_read.py @@ -0,0 +1,47 @@ +"""Phase 2: BatchRead Schema für GET /api/batches/{id}.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, model_validator + +from app.schemas.job import JobRead + + +class BatchSummary(BaseModel): + """Aggregierte Zähler über alle Jobs eines Batches. + + all_terminal wird aus queued + printing berechnet — kein DB-Round-trip nötig. + Hangar's Result-Page nutzt all_terminal um zu entscheiden, ob ein + SSE-Stream für Live-Updates geöffnet werden muss. + """ + + model_config = ConfigDict(populate_by_name=True) + + total: int + queued: int + printing: int + done: int + failed: int # zählt FAILED + FAILED_RESTART + cancelled: int + all_terminal: bool = False # wird in model_validator gesetzt + + @model_validator(mode="after") + def _compute_all_terminal(self) -> BatchSummary: + """all_terminal = True wenn weder queued noch printing Jobs existieren.""" + self.all_terminal = (self.queued + self.printing) == 0 + return self + + +class BatchRead(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: UUID + printer_id: UUID + # R2-C5: PrintBatch.created_by ist str (SSO-Email oder API-Key-ID), kein UUID + created_by: str | None + created_at: datetime + jobs: list[JobRead] + summary: BatchSummary diff --git a/backend/app/services/cleanup_task.py b/backend/app/services/cleanup_task.py new file mode 100644 index 0000000..f3ff4ea --- /dev/null +++ b/backend/app/services/cleanup_task.py @@ -0,0 +1,93 @@ +"""Phase 2: periodischer Background-Task der terminal Jobs älter als retention_days löscht.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import timedelta + +from app.services.job_store import JobStore + +logger = logging.getLogger(__name__) + +_DEFAULT_INTERVAL = timedelta(hours=24) + + +class CleanupTask: + """Background asyncio task der periodisch store.evict_terminal_older_than(retention) aufruft. + + Lifecycle:: + + task = CleanupTask(store=store, retention_days=30) + await task.start() # startet den Loop, führt Initial-Run durch + # ... App läuft ... + await task.stop() # signalisiert Stop, wartet auf Loop-Ende + + Der Loop führt beim Start sofort einen ersten Run durch, danach in ``interval``-Abständen. + Exceptions in _run_once werden geloggt und der Loop läuft weiter (fail-soft). + + Single-Use: nach stop() kann die Instanz nicht restartet werden. + Lifespan startet eine neue Instanz beim App-Startup. + """ + + def __init__( + self, + store: JobStore, + retention_days: int, + interval: timedelta = _DEFAULT_INTERVAL, + ) -> None: + if retention_days < 1: + raise ValueError("retention_days must be >= 1") + self._store = store + self._retention = timedelta(days=retention_days) + self._interval = interval + self._task: asyncio.Task[None] | None = None + self._stopping = asyncio.Event() + + async def start(self) -> None: + """Startet den Background-Loop. Idempotent — zweites start() ist no-op.""" + if self._task is not None: + return + self._task = asyncio.create_task(self._loop(), name="job-cleanup") + + async def stop(self, timeout_s: float = 5.0) -> None: + """Signalisiert dem Loop zu stoppen und wartet bis zu timeout_s Sekunden. + + Nach Ablauf des Timeouts wird der Task gecancelled. + """ + self._stopping.set() + if self._task is not None: + try: + await asyncio.wait_for(self._task, timeout=timeout_s) + except TimeoutError: + self._task.cancel() + logger.warning( + "CleanupTask hat sich nicht in %ss beendet, Task gecancelled", + timeout_s, + ) + self._task = None + + async def _loop(self) -> None: + """Haupt-Loop: Initial-Run + periodische Wiederholung bis stopping gesetzt wird.""" + await self._run_once() + while not self._stopping.is_set(): + try: + await asyncio.wait_for( + self._stopping.wait(), + timeout=self._interval.total_seconds(), + ) + except TimeoutError: + await self._run_once() + + async def _run_once(self) -> None: + """Führt einen einzelnen Eviction-Run durch. Exceptions werden geloggt, nicht propagiert.""" + try: + deleted = await self._store.evict_terminal_older_than(self._retention) + if deleted > 0: + logger.info( + "CleanupTask: %d terminal Jobs älter als %d Tage gelöscht", + deleted, + self._retention.days, + ) + except Exception: + logger.exception("CleanupTask: _run_once fehlgeschlagen") diff --git a/backend/app/services/job_store.py b/backend/app/services/job_store.py new file mode 100644 index 0000000..3ccd9ca --- /dev/null +++ b/backend/app/services/job_store.py @@ -0,0 +1,159 @@ +"""Phase 2: JobStore Protocol + MemoryJobStore in-memory Implementation. + +JobStore ist die Persistierungs-Boundary die PrintQueue nutzt um Job-State-Transitionen +zu speichern. SQLiteJobStore (Produktion) implementiert dieses Protocol durch Delegation +an jobs_repo. MemoryJobStore ist die Test/Migration-Impl. + +Klärung (R2-C1): Alle Store-Methoden arbeiten auf app.models.job.Job +(SQLModel, UUID-id). Der Worker-Code in print_queue.py verwendet +app.services.job_lifecycle.Job (Dataclass, str-id). Bridge: + Worker ruft self._store.mark_printing(UUID(job.id)) + Store arbeitet intern auf UUID-Schlüsseln. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Protocol, runtime_checkable +from uuid import UUID + +from app.models.job import Job, JobState + +_NON_TERMINAL = (JobState.QUEUED.value, JobState.PRINTING.value) +_TERMINAL = ( + JobState.DONE.value, + JobState.FAILED.value, + JobState.FAILED_RESTART.value, + JobState.CANCELLED.value, +) + + +@runtime_checkable +class JobStore(Protocol): + """Persistente Backing-Store für Jobs. + + Alle Methoden sind async und können I/O durchführen. Implementierungen müssen + sicher für gleichzeitige Aufrufe aus mehreren asyncio-Tasks sein. + """ + + async def save_queued(self, job: Job) -> None: + """Persist a newly-created QUEUED job (insert). + + Called from PrintService.submit_print_job BEFORE handing off + to the queue. After this returns, the job is durable. + """ + + async def get(self, job_id: UUID) -> Job | None: + """Load a job by ID. None if not found.""" + + async def mark_printing(self, job_id: UUID) -> None: + """Transition QUEUED -> PRINTING. Called by worker when it picks up the job. + + Silently no-op if job_id does not exist in the store. Implementations + must NOT raise on missing jobs — callers may race against eviction. + """ + + async def mark_done(self, job_id: UUID) -> None: + """Transition PRINTING -> DONE. Called by worker after successful print. + + Silently no-op if job_id does not exist in the store. Implementations + must NOT raise on missing jobs — callers may race against eviction. + """ + + async def mark_failed(self, job_id: UUID, error: str) -> None: + """Transition any non-terminal -> FAILED with given error message. + + Silently no-op if job_id does not exist in the store. Implementations + must NOT raise on missing jobs — callers may race against eviction. + """ + + async def mark_interrupted(self, printer_id: UUID) -> int: + """Recovery: set all PRINTING jobs of this printer to FAILED_RESTART + with error='printer_interrupted'. + + Called from PrintQueue.start() BEFORE list_pending. + + Returns the count of affected rows. + """ + + async def list_pending(self, printer_id: UUID) -> list[Job]: + """Return all non-terminal jobs for this printer, sorted by created_at (FIFO). + + Called from PrintQueue.start() AFTER mark_interrupted to find + QUEUED jobs that need to be re-enqueued. + """ + + async def evict_terminal_older_than(self, age: timedelta) -> int: + """Delete terminal jobs (DONE/FAILED/FAILED_RESTART/CANCELLED) with + finished_at older than `age` ago. Used by CleanupTask. + + Returns the count of deleted rows. + """ + + +class MemoryJobStore(JobStore): + """In-Memory JobStore für Tests und PrintService Boot-Phase. + + Hält Job-Objekte in einem Dict mit id als Schlüssel. Nicht thread-safe, aber + sicher für asyncio Single-Event-Loop-Nutzung. + """ + + def __init__(self) -> None: + self._jobs: dict[UUID, Job] = {} + + async def save_queued(self, job: Job) -> None: + self._jobs[job.id] = job + + async def get(self, job_id: UUID) -> Job | None: + return self._jobs.get(job_id) + + async def mark_printing(self, job_id: UUID) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.PRINTING.value + job.started_at = datetime.now(UTC) + + async def mark_done(self, job_id: UUID) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.DONE.value + job.finished_at = datetime.now(UTC) + + async def mark_failed(self, job_id: UUID, error: str) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.FAILED.value + job.error = error + job.finished_at = datetime.now(UTC) + + async def mark_interrupted(self, printer_id: UUID) -> int: + count = 0 + for job in self._jobs.values(): + if job.printer_id == printer_id and job.state == JobState.PRINTING.value: + job.state = JobState.FAILED_RESTART.value + job.error = "printer_interrupted" + job.finished_at = datetime.now(UTC) + count += 1 + return count + + async def list_pending(self, printer_id: UUID) -> list[Job]: + items = [ + j + for j in self._jobs.values() + if j.printer_id == printer_id and j.state in _NON_TERMINAL + ] + return sorted(items, key=lambda j: j.created_at) + + async def evict_terminal_older_than(self, age: timedelta) -> int: + cutoff = datetime.now(UTC) - age + to_delete = [ + jid + for jid, j in self._jobs.items() + if j.state in _TERMINAL and j.finished_at is not None and j.finished_at < cutoff + ] + for jid in to_delete: + del self._jobs[jid] + return len(to_delete) diff --git a/backend/app/services/job_store_sqlite.py b/backend/app/services/job_store_sqlite.py new file mode 100644 index 0000000..ca854a7 --- /dev/null +++ b/backend/app/services/job_store_sqlite.py @@ -0,0 +1,114 @@ +"""SQLite-backed JobStore — delegates to jobs_repo for actual SQL. + +Uses async_sessionmaker für per-operation sessions so we get clean +transactions and no connection-pool starvation. +""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.models.job import Job, JobState +from app.repositories import jobs as jobs_repo +from app.services.job_store import JobStore + +logger = logging.getLogger(__name__) + + +class SQLiteJobStore(JobStore): + """SQLite-backed JobStore implementation. + + Delegates alle State-Übergänge an jobs_repo. Jede Operation öffnet + eine eigene Session (per-operation pattern) — kein Session-Sharing + zwischen parallelen asyncio-Tasks. + """ + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def save_queued(self, job: Job) -> None: + """Persist a newly-created QUEUED job via session.add + commit + refresh.""" + async with self._session_factory() as session: + session.add(job) + await session.commit() + await session.refresh(job) + + async def get(self, job_id: UUID) -> Job | None: + """Load a job by ID. None if not found.""" + async with self._session_factory() as session: + return await jobs_repo.get(session, job_id) + + async def mark_printing(self, job_id: UUID) -> None: + """Transition QUEUED -> PRINTING. Silently no-op if job not found.""" + async with self._session_factory() as session: + job = await jobs_repo.get(session, job_id) + if job is None: + return # silent no-op (Protocol contract) + if job.state != JobState.QUEUED.value: + logger.warning( + "mark_printing called on job %s in state %s (expected queued)", + job_id, + job.state, + ) + return + await jobs_repo.mark_printing(session, job_id) + + async def mark_done(self, job_id: UUID) -> None: + """Transition PRINTING -> DONE. + + Delegiert an jobs_repo.mark_done mit result={} — JobStore-Protocol + speichert kein structured result (Phase-2 YAGNI; ergänzbar via + `set_result` Methode wenn Hangar das später braucht). + """ + async with self._session_factory() as session: + job = await jobs_repo.get(session, job_id) + if job is None: + return # silent no-op (Protocol contract) + if job.state != JobState.PRINTING.value: + logger.warning( + "mark_done called on job %s in state %s (expected printing)", + job_id, + job.state, + ) + return + await jobs_repo.mark_done(session, job_id, result={}) + + async def mark_failed(self, job_id: UUID, error: str) -> None: + """Transition any non-terminal -> FAILED. Silently no-op if job not found.""" + _terminal = { + JobState.DONE.value, + JobState.FAILED.value, + JobState.CANCELLED.value, + JobState.FAILED_RESTART.value, + } + async with self._session_factory() as session: + job = await jobs_repo.get(session, job_id) + if job is None: + return # silent no-op (Protocol contract) + if job.state in _terminal: + logger.warning( + "mark_failed called on job %s in terminal state %s", + job_id, + job.state, + ) + return + await jobs_repo.mark_failed(session, job_id, error) + + async def mark_interrupted(self, printer_id: UUID) -> int: + """Recovery: mark all PRINTING jobs of this printer as FAILED_RESTART.""" + async with self._session_factory() as session: + return await jobs_repo.mark_printing_as_failed_restart(session, printer_id) + + async def list_pending(self, printer_id: UUID) -> list[Job]: + """Return all non-terminal jobs for this printer, sorted by created_at (FIFO).""" + async with self._session_factory() as session: + return await jobs_repo.list_active(session, printer_id=printer_id) + + async def evict_terminal_older_than(self, age: timedelta) -> int: + """Delete terminal jobs older than age. Returns count of deleted rows.""" + async with self._session_factory() as session: + return await jobs_repo.evict_terminal_older_than(session, age) diff --git a/backend/app/services/print_queue.py b/backend/app/services/print_queue.py index 7ea49d3..dafd2e6 100644 --- a/backend/app/services/print_queue.py +++ b/backend/app/services/print_queue.py @@ -1,11 +1,13 @@ -"""Per-printer async work queue. +"""Per-printer async work queue mit Persistierungs-Boundary. Brother PT/QL printers expose TCP/9100 as a single-stream channel — there is no on-device multi-job queue. The hub serialises jobs per printer by running one asyncio worker task per printer and feeding it from an asyncio.Queue. -Jobs live in-memory (MVP). Phase 5 will add SQLite persistence behind a -JobStore protocol that this module will accept by dependency injection. +In-Memory dataclass `Job` Instanzen (mit image_payload bytes und +asyncio.Event) leben in `_jobs` während des Worker-Loops. Parallel +persistiert der `JobStore` die SQLModel-Job-Rows in SQLite — siehe +`app/services/job_store.py` (Phase 2). Internal dependency note: the worker reads `job._done_event` (a private field on `Job`) to signal completion to `wait_for_job`. `PrintQueue` and @@ -20,11 +22,14 @@ import uuid from enum import StrEnum from io import BytesIO -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from uuid import UUID from PIL import Image +from pydantic import ValidationError +from app.models.job import Job as DbJob +from app.models.job import JobState as DbJobState from app.printer_backends.exceptions import ( PrinterCoverOpenError, PrinterError, @@ -40,6 +45,17 @@ JobState, JobStateMachine, ) +from app.services.job_store import JobStore, MemoryJobStore + +# TYPE_CHECKING-Block verhindert zirkuläre Imports zur Laufzeit. +# LabelRenderer und TemplateLoader sind nur für die Recovery-Methode nötig. +if TYPE_CHECKING: + from app.services.label_renderer import LabelRenderer + from app.services.template_loader import TemplateLoader + +# TemplateNotFoundError wird zur Laufzeit benötigt (Recovery-Loop catch), daher +# kein TYPE_CHECKING-Block — aber lazy import um Zirkel zu vermeiden. +from app.services.template_loader import TemplateNotFoundError # Callback type: called after each state transition. The optional queue_depth @@ -134,8 +150,31 @@ def __init__( self, printers: list[_PrinterLike], on_state_change: _StateChangeCallback | None = None, + store: JobStore | None = None, + renderer: LabelRenderer | None = None, + loader: type[TemplateLoader] | None = None, ) -> None: + """Konstruktor. + + Args: + printers: Liste der Drucker-Objekte (jeder mit ``id`` UUID und + ``print_image`` Coroutine-Methode). + on_state_change: Optionaler Callback für SSE-Events. Wird nach + jeder Job-State-Transition aufgerufen; None deaktiviert das + Callback ohne sonstige Seiteneffekte. + store: JobStore für DB-Persistierung der Job-Transitionen. + Default ist ``MemoryJobStore()`` für Backward-Compat mit + Pre-Phase-2-Tests — Production-Code wired in Lifespan + explizit ``SQLiteJobStore`` ein (Task 9). + renderer: LabelRenderer-Instanz für Recovery in start(). + Optional — wenn None, wirft _rerender_from_db_job() RuntimeError. + loader: TemplateLoader-Klasse für Recovery in start(). + Optional — wenn None, wirft _rerender_from_db_job() RuntimeError. + """ self._on_state_change = on_state_change + self._store: JobStore = store if store is not None else MemoryJobStore() + self._renderer: LabelRenderer | None = renderer + self._loader: type[TemplateLoader] | None = loader self._printers: dict[UUID, _PrinterLike] = {p.id: p for p in printers} # Queue type is Job | None — None is the sentinel used by stop() to wake # workers that are blocked at queue.get(). @@ -164,11 +203,87 @@ def __init__( async def start(self) -> None: if self._running: return - for printer_id in self._queues: - self._workers[printer_id] = asyncio.create_task( - self._worker(printer_id), name=f"printer-worker-{printer_id}" - ) + # I-1: Race-Guard — sofort setzen bevor erster await, damit ein zweiter + # gleichzeitiger start()-Aufruf am Guard oben scheitert. self._running = True + try: + # Phase 2 Recovery: unterbrochene PRINTING-Jobs markieren + QUEUED re-enqueuen. + # mark_interrupted MUSS vor list_pending aufgerufen werden, damit die alten + # PRINTING-Jobs NICHT in list_pending zurückkommen. + for printer_id in self._queues: + interrupted = await self._store.mark_interrupted(printer_id) + if interrupted > 0: + logger.warning( + "Recovery: %d PRINTING-Jobs auf Drucker %s als FAILED_RESTART markiert", + interrupted, + printer_id, + ) + pending_db_jobs = await self._store.list_pending(printer_id) + for db_job in pending_db_jobs: + # S-1: StrEnum-Konsistenz — DbJobState.QUEUED.value statt String-Literal + if db_job.state != DbJobState.QUEUED.value: + continue + # C-1/I-2: fehlerhafte/veraltete Rows dürfen die Recovery nicht abbrechen. + # KeyError (fehlendes label_data), ValidationError (ungültige Struktur), + # TemplateNotFoundError (Template inzwischen gelöscht) → Job FAILED markieren + # und mit dem nächsten Job weitermachen. + try: + image = await self._rerender_from_db_job(db_job) + except (KeyError, ValidationError, TemplateNotFoundError) as exc: + logger.warning( + "Recovery: Job %s rerender fehlgeschlagen (%s), FAILED", + db_job.id, + exc.__class__.__name__, + ) + await self._store.mark_failed(db_job.id, f"recovery_rerender_failed: {exc}") + continue + payload_bytes = await asyncio.to_thread(_serialize_image_to_png, image) + wrapper = Job( + id=str(db_job.id), + printer_id=db_job.printer_id, + image_payload=payload_bytes, + tape_mm=db_job.payload.get("tape_mm"), + options=db_job.payload.get("options", {}), + ) + self._jobs[str(db_job.id)] = wrapper + await self._queues[printer_id].put(wrapper) + logger.info( + "Recovery: QUEUED-Job %s auf Drucker %s re-enqueued", + db_job.id, + printer_id, + ) + + for printer_id in self._queues: + self._workers[printer_id] = asyncio.create_task( + self._worker(printer_id), name=f"printer-worker-{printer_id}" + ) + except Exception: + # I-1: Bei Recovery-Fehler _running zurücksetzen, damit ein erneuter + # start()-Aufruf nicht am Guard scheitert. + self._running = False + raise + + async def _rerender_from_db_job(self, db_job: DbJob) -> Image.Image: + """Phase 2: Label-Bild aus persistiertem template_key + payload neu rendern. + + Wird während start() Recovery aufgerufen. Benötigt renderer + loader, + die via PrintQueue-Konstruktor verdrahtet werden müssen (Production-Lifespan). + + Raises: + RuntimeError: wenn renderer oder loader nicht gesetzt sind. + """ + if self._renderer is None or self._loader is None: + raise RuntimeError( + "PrintQueue Recovery benötigt renderer + loader " + "(via Konstruktor übergeben — siehe Lifespan-Konfiguration)" + ) + template = self._loader.get(db_job.template_key) + # R2-C4: payload["label_data"] ist ein rohes dict (model_dump()). + # LabelRenderer.render() erwartet ein LabelData-Objekt — KEIN dict. + from app.schemas.label_data import LabelData + + label_data = LabelData.model_validate(db_job.payload["label_data"]) + return self._renderer.render(template, label_data) async def stop(self, timeout_s: float = 30.0) -> None: """Stop all workers. @@ -219,6 +334,7 @@ async def stop(self, timeout_s: float = 30.0) -> None: job.error_msg = job.error_message # keep legacy field in sync (see line 466) try: JobStateMachine.transition(job, JobState.FAILED) + await self._store.mark_failed(UUID(job.id), "shutdown") except InvalidStateTransitionError: # Defensive: job already moved to a terminal state by the # worker — just ensure _done_event is set. @@ -257,6 +373,63 @@ async def submit( # stream with spurious HTMX sse-swap updates (bot-review Finding F1). return job.id + async def submit_with_id( + self, + job_id: UUID, + printer_id: UUID, + image: Image.Image, + tape_mm: int, + **options: Any, + ) -> UUID: + """Phase 2: Wie submit(), aber mit extern erzeugter job_id. + + Wird von PrintService genutzt, der die DB-Row zuerst anlegt (via + store.save_queued) und die resultierende UUID hier weitergibt. + Gibt die job_id unverändert zurück. + """ + if printer_id not in self._queues: + raise KeyError(f"Unknown printer: {printer_id}") + payload = await asyncio.to_thread(_serialize_image_to_png, image) + job = Job( + id=str(job_id), + printer_id=printer_id, + image_payload=payload, + tape_mm=tape_mm, + options=dict(options), + ) + self._jobs[str(job_id)] = job + await self._queues[printer_id].put(job) + logger.info("Job %s (extern-id) queued on %s", job_id, printer_id) + return job_id + + async def submit_paused_with_id( + self, + job_id: UUID, + printer_id: UUID, + image: Image.Image, + tape_mm: int, + **options: Any, + ) -> UUID: + """Phase 2: Wie submit_paused(), aber mit extern erzeugter job_id. + + Wird von PrintService für den on_tape_mismatch='queue'-Pfad genutzt. + Gibt die job_id unverändert zurück. + """ + if printer_id not in self._queues: + raise KeyError(f"Unknown printer: {printer_id}") + payload = await asyncio.to_thread(_serialize_image_to_png, image) + job = Job( + id=str(job_id), + printer_id=printer_id, + image_payload=payload, + tape_mm=tape_mm, + options=dict(options), + ) + JobStateMachine.transition(job, JobState.PAUSED) + self._jobs[str(job_id)] = job + logger.info("Job %s (extern-id) created paused on %s", job_id, printer_id) + return job_id + async def submit_paused( self, printer_id: UUID, @@ -292,8 +465,8 @@ async def submit_paused( logger.info("Job %s created paused on %s", job.id, printer_id) return job.id - async def get(self, job_id: str) -> Job: - return self._jobs[job_id] + async def get(self, job_id: str | UUID) -> Job: + return self._jobs[str(job_id)] async def wait_for_job(self, job_id: str, timeout_s: float = 60.0) -> Job: job = self._jobs[job_id] @@ -513,6 +686,9 @@ async def _worker(self, printer_id: UUID) -> None: if job.state != JobState.QUEUED: continue + # Phase 2: DB-State QUEUED->PRINTING persistieren (bridge: dataclass.id ist str) + await self._store.mark_printing(UUID(job.id)) + try: _from = job.state JobStateMachine.transition(job, JobState.PRINTING) @@ -532,6 +708,7 @@ async def _worker(self, printer_id: UUID) -> None: await printer.print_image(image, tape_mm=job.tape_mm, **job.options) _from = job.state JobStateMachine.transition(job, JobState.COMPLETED) + await self._store.mark_done(UUID(job.id)) # Phase 2: DB-State persistieren self._notify_state_change( job, _from, @@ -564,6 +741,8 @@ async def _worker(self, printer_id: UUID) -> None: job.state, exc, ) + # Phase 2: DB-State persistieren (auch wenn Transition fehlschlug) + await self._store.mark_failed(UUID(job.id), f"{code}: {msg}") logger.exception("Job %s failed on %s (printer error)", job.id, printer_id) if isinstance(exc, _RECOVERABLE_PRINTER_ERRORS): # Halt the whole printer queue — user must change tape / @@ -589,4 +768,6 @@ async def _worker(self, printer_id: UUID) -> None: job.state, exc, ) + # Phase 2: DB-State persistieren (auch wenn Transition fehlschlug) + await self._store.mark_failed(UUID(job.id), str(exc)) logger.exception("Job %s failed on %s", job.id, printer_id) diff --git a/backend/app/services/print_service.py b/backend/app/services/print_service.py index aa4f0e3..5e93994 100644 --- a/backend/app/services/print_service.py +++ b/backend/app/services/print_service.py @@ -7,11 +7,13 @@ from PIL import Image +from app.models.job import Job from app.printer_backends.exceptions import TapeMismatchError from app.printer_backends.snmp_helper import PreflightStatus from app.schemas.label_data import LabelData from app.schemas.print_request import PrintRequest from app.schemas.template import TemplateSchema +from app.services.job_store import JobStore, MemoryJobStore from app.services.print_queue import PrintQueue @@ -43,6 +45,7 @@ def __init__( lookup_service: _LookupServiceProto, printer_id: UUID, backend: _BackendProto, + store: JobStore | None = None, ) -> None: self._loader = template_loader self._renderer = renderer @@ -50,6 +53,10 @@ def __init__( self._lookup = lookup_service self._printer_id = printer_id self._backend = backend + # Phase 2: JobStore für Persistierung vor queue.submit. + # Default MemoryJobStore für Backward-Compat mit Pre-Phase-2-Tests — + # Production-Code wired in Lifespan explizit SQLiteJobStore ein (Task 9). + self._store: JobStore = store if store is not None else MemoryJobStore() async def _resolve_label_data(self, request: PrintRequest) -> LabelData: """Resolve label data from lookup or raw request data.""" @@ -64,7 +71,17 @@ async def _resolve_label_data(self, request: PrintRequest) -> LabelData: source_app="manual", ) - async def submit_print_job(self, request: PrintRequest) -> str: + async def submit_print_job(self, request: PrintRequest) -> UUID: + """Orchestrate template-load → preflight → render → persist → queue.submit. + + Phase-2-Limitationen: + - on_tape_mismatch=queue: PAUSED-Jobs bleiben in-memory-only bis resume. + Hub-Restart während PAUSED löscht den Job — Phase-2-Trade-off. + Phase 3 (Issue #95 wenn erstellt) wird PAUSED in JobState enum aufnehmen + + DB-Migration für persistenten paused-state. + - tape_mismatch Metadaten (error_code, error_message, error_detail) werden + nur in-memory gehalten; keine DB-Row im PAUSED-Pfad. + """ # 1. Load template — fail fast before any I/O if template is unknown. template = self._loader.get(request.template_id) @@ -81,37 +98,87 @@ async def submit_print_job(self, request: PrintRequest) -> str: if request.on_tape_mismatch == "fail": raise mismatch - # "queue" path: create the job already in PAUSED state so the worker - # can never dequeue it between submit and pause (submit_paused() does - # NOT place the job in the asyncio.Queue — atomic, no race window). + # "queue" path: PAUSED-Jobs NICHT in DB persistieren. + # C-1-Fix: save_queued würde den Job als QUEUED in DB ablegen, aber + # PAUSED ist kein gültiger JobState-Wert. Nach Hub-Restart würde + # list_pending() den Job als QUEUED finden und sofort drucken — + # obwohl der User noch den Tape wechseln muss (Doppel-Druck-Risiko). + # Trade-off: Job geht bei Hub-Restart verloren, nichts wurde gedruckt. + # R2-M3: PrintRequest hat KEINE api_key_id/source_ip Felder. + # AuthContext-Integration folgt in einem späteren Task. label_data = await self._resolve_label_data(request) image = self._renderer.render(template, label_data) - job_id = await self._queue.submit_paused( + paused_job_id = Job( + printer_id=self._printer_id, + template_key=request.template_id, + payload={ + "label_data": label_data.model_dump(), + "tape_mm": template.tape_mm, + "options": { + "auto_cut": request.options.auto_cut, + "high_resolution": request.options.high_resolution, + }, + }, + api_key_id=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst + source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst + ) + # Keine save_queued() — Job bleibt in-memory-only bis resume. + await self._queue.submit_paused_with_id( + paused_job_id.id, self._printer_id, image, tape_mm=template.tape_mm, auto_cut=request.options.auto_cut, high_resolution=request.options.high_resolution, ) - job = await self._queue.get(job_id) - job.error_code = "tape_mismatch" - job.error_message = str(mismatch) - job.error_detail = { + # Tape-mismatch Metadaten an den in-memory Job anhängen + in_memory_job = await self._queue.get(str(paused_job_id.id)) + in_memory_job.error_code = "tape_mismatch" + in_memory_job.error_message = str(mismatch) + in_memory_job.error_detail = { "expected_mm": template.tape_mm, "loaded_mm": preflight.loaded_tape_mm, } - return job_id + return paused_job_id.id # 4. Happy path: resolve label data, render, submit. + # Phase 2: DB-Row anlegen BEVOR an Queue übergeben (Durability-Garantie). + # R2-M3: PrintRequest hat KEINE api_key_id/source_ip Felder. label_data = await self._resolve_label_data(request) image = self._renderer.render(template, label_data) - - # `copies` is intentionally not forwarded — multi-copy delivery is - # a Phase-5 follow-up. Clients can post N times today. - return await self._queue.submit( - self._printer_id, - image, - tape_mm=template.tape_mm, - auto_cut=request.options.auto_cut, - high_resolution=request.options.high_resolution, + db_job = Job( + printer_id=self._printer_id, + template_key=request.template_id, + payload={ + "label_data": label_data.model_dump(), + "tape_mm": template.tape_mm, + "options": { + # `copies` wird nicht weitergeleitet — Phase-5 Follow-up. + "auto_cut": request.options.auto_cut, + "high_resolution": request.options.high_resolution, + }, + }, + api_key_id=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst + source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst ) + await self._store.save_queued(db_job) + try: + await self._queue.submit_with_id( + db_job.id, + self._printer_id, + image, + tape_mm=template.tape_mm, + auto_cut=request.options.auto_cut, + high_resolution=request.options.high_resolution, + ) + except Exception as exc: + # I-1-Fix: in-memory Submit fehlgeschlagen nach DB-Persist — Rollback. + # Ohne diesen Rollback bliebe eine stale QUEUED-Row in der DB ohne + # Worker-Gegenstück, die nach Hub-Restart fälschlicherweise re-enqueued + # würde. mark_failed markiert die Row als FAILED und verhindert das. + await self._store.mark_failed( + db_job.id, + f"submit_failed: {exc.__class__.__name__}: {exc}", + ) + raise + return db_job.id diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e4d143f..bfdceac 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,7 +5,12 @@ from __future__ import annotations +import pathlib + import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel def pytest_addoption(parser: pytest.Parser) -> None: @@ -24,3 +29,26 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item for item in items: if "hardware" in item.keywords: item.add_marker(skip_hardware) + + +@pytest_asyncio.fixture +async def async_session_factory(tmp_path: pathlib.Path): + """Per-test SQLite + async_sessionmaker für isolierte JobStore-Tests. + + FOREIGN KEYS sind absichtlich DEAKTIVIERT (SQLite-Default). + Phase-2-Tests nutzen uuid4() als printer_id ohne echte Printer-Rows in + der DB — FK ON würde Tests verlangen die Printer-Stamm-Daten anlegen, + was den Test-Scope (JobStore-Isolation) unnötig erweitert. + + Produktions-Code läuft mit FK ON (PRAGMA in app/db/engine.py). + """ + import app.models # noqa: F401 — registriert alle Models bei SQLModel.metadata + + db_path = tmp_path / "job_store_test.db" + url = f"sqlite+aiosqlite:///{db_path}" + engine = create_async_engine(url, echo=False, connect_args={"check_same_thread": False}) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + yield factory + await engine.dispose() diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index b421b78..9eee16a 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -47,7 +47,15 @@ async def _temp_db_engine(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: # is a relative path that does not exist in CI. The temp engine already has the full schema via SQLModel.metadata.create_all(), so migrations are redundant here. + + Phase 2 (Task 8): app.db.session imports async_session at module-load time + via `from app.db.engine import async_session`. This creates a local binding + in session.py that is NOT updated when _engine_module.async_session is + patched. Routes using get_session() need the patched session factory, so we + patch app.db.session.async_session here too. """ + import app.db.session as _session_module + db_path = tmp_path / "integ_test.db" url = f"sqlite+aiosqlite:///{db_path}" eng = create_async_engine(url, echo=False, connect_args={"check_same_thread": False}) @@ -59,6 +67,7 @@ async def _temp_db_engine(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: # monkeypatch.setattr(_engine_module, "async_session", sess) monkeypatch.setattr(_main_module, "engine", eng) monkeypatch.setattr(_main_module, "async_session", sess) + monkeypatch.setattr(_session_module, "async_session", sess) # Alembic reads alembic.ini directly (sqlalchemy.url = ./data/hub.db), # bypassing the patched engine above. Skip it — create_all() above is # the authoritative schema source for integration tests. diff --git a/backend/tests/integration/test_batch_snapshot_endpoint.py b/backend/tests/integration/test_batch_snapshot_endpoint.py new file mode 100644 index 0000000..99821df --- /dev/null +++ b/backend/tests/integration/test_batch_snapshot_endpoint.py @@ -0,0 +1,166 @@ +"""GET /api/batches/{id} liefert Snapshot mit Jobs + Summary.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.models.print_batch import PrintBatch +from app.models.printer import Printer +from app.repositories import jobs as jobs_repo +from app.repositories import print_batches as batches_repo +from app.repositories import printers as printers_repo + +# --- Fixtures (R2-C6: explizit definiert, nicht aus nicht-existenter conftest) --- + + +@pytest_asyncio.fixture +async def test_printer(db_session) -> Printer: + """Drucker-Zeile in der Test-DB (jobs.printer_id + print_batches.printer_id FK).""" + p = Printer(name="Test Printer", slug="test-printer", model="PT-P750W", backend="mock") + return await printers_repo.create(db_session, p) + + +@pytest_asyncio.fixture +async def sample_batch_done(db_session, test_printer): + """2 DONE-Jobs + PrintBatch in der Test-DB.""" + printer_id = test_printer.id + j1 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + j2 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j1.id) + await jobs_repo.mark_done(db_session, j1.id, result={}) + await jobs_repo.mark_printing(db_session, j2.id) + await jobs_repo.mark_done(db_session, j2.id, result={}) + + batch = PrintBatch( + printer_id=printer_id, + job_ids=[str(j1.id), str(j2.id)], + created_by="test@example.com", + ) + db_session.add(batch) + await db_session.commit() + await db_session.refresh(batch) + return batch + + +@pytest_asyncio.fixture +async def batch_with_ghost_ids(db_session, test_printer): + """PrintBatch mit 3 job_ids, davon 2 nach Anlage gelöscht (Geister-IDs).""" + printer_id = test_printer.id + j1 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j1.id) + await jobs_repo.mark_done(db_session, j1.id, result={}) + + ghost_id_1 = str(uuid4()) # nie in DB eingetragen + ghost_id_2 = str(uuid4()) # nie in DB eingetragen + + batch = PrintBatch( + printer_id=printer_id, + job_ids=[str(j1.id), ghost_id_1, ghost_id_2], + created_by="test@example.com", + ) + db_session.add(batch) + await db_session.commit() + await db_session.refresh(batch) + return batch + + +# --- Tests --- + + +@pytest.mark.asyncio +async def test_get_batch_returns_404_for_unknown(client): + # client kommt aus tests/integration/conftest.py:222 (fake-auth, kein eigenes auth_client) + resp = await client.get(f"/api/batches/{uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_batch_returns_summary_with_all_terminal( + client, + sample_batch_done, +): + resp = await client.get(f"/api/batches/{sample_batch_done.id}") + assert resp.status_code == 200 + body = resp.json() + assert body["id"] == str(sample_batch_done.id) + assert body["summary"]["total"] == 2 + assert body["summary"]["done"] == 2 + assert body["summary"]["queued"] == 0 + assert body["summary"]["all_terminal"] is True + assert len(body["jobs"]) == 2 + + +@pytest.mark.asyncio +async def test_get_batch_jobs_in_batch_order( + client, + sample_batch_done, +): + """Job-Reihenfolge im Response entspricht batch.job_ids Array, nicht DB-default.""" + resp = await client.get(f"/api/batches/{sample_batch_done.id}") + body = resp.json() + received_ids = [j["id"] for j in body["jobs"]] + expected_ids = [str(jid) for jid in sample_batch_done.job_ids] + assert received_ids == expected_ids + + +@pytest.mark.asyncio +async def test_get_batch_handles_missing_jobs(client, batch_with_ghost_ids): + """Wenn Jobs vom Cleanup gelöscht sind (Geister-IDs), werden sie übersprungen.""" + resp = await client.get(f"/api/batches/{batch_with_ghost_ids.id}") + body = resp.json() + # batch_with_ghost_ids hat 3 job_ids, aber nur 1 existiert in der DB + assert body["summary"]["total"] == 1 + + +@pytest.mark.asyncio +async def test_failed_counter_includes_failed_restart( + client, + db_session, +): + """summary.failed zählt FAILED + FAILED_RESTART zusammen.""" + printer = Printer( + name="Counter Test Printer", + slug="counter-test-printer", + model="PT-P750W", + backend="mock", + ) + printer = await printers_repo.create(db_session, printer) + printer_id = printer.id + + # Job 1: FAILED via mark_failed + j_failed = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j_failed.id) + await jobs_repo.mark_failed(db_session, j_failed.id, "tape_empty") + + # Job 2: FAILED_RESTART via mark_printing_as_failed_restart + j_restart = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j_restart.id) + await jobs_repo.mark_printing_as_failed_restart(db_session, printer_id) + + batch = await batches_repo.create( + db_session, + PrintBatch( + printer_id=printer_id, + job_ids=[str(j_failed.id), str(j_restart.id)], + created_by="test@example.com", + ), + ) + + resp = await client.get(f"/api/batches/{batch.id}") + assert resp.status_code == 200 + body = resp.json() + assert body["summary"]["failed"] == 2 # FAILED + FAILED_RESTART zusammen + assert body["summary"]["cancelled"] == 0 + assert body["summary"]["all_terminal"] is True diff --git a/backend/tests/integration/test_phase6b_sse_with_batch.py b/backend/tests/integration/test_phase6b_sse_with_batch.py index 7d3ac88..61f9984 100644 --- a/backend/tests/integration/test_phase6b_sse_with_batch.py +++ b/backend/tests/integration/test_phase6b_sse_with_batch.py @@ -142,17 +142,29 @@ async def test_sse_contains_batch_job_events( # f"printer:{printer_id}:queue" app_printer_id: uuid.UUID = inner.state.printer_id - # 3. Printer-Row mit ID=app_printer_id in die DB schreiben. + # 3. Printer-Row mit ID=app_printer_id sicherstellen. + # Phase 2: Lifespan legt bei Mock-Backend (kein Host) bereits eine Stub-Row + # an. Wir holen die existierende Row und aktualisieren Name/Slug falls nötig, + # statt blind create() aufzurufen (würde UNIQUE-Constraint verletzten). # batch.py prüft printer.id == app.state.printer_id — durch die identische # ID passt der Check ohne dass PrintQueue-Interna umgebaut werden müssen. - p = Printer( - id=app_printer_id, - name="Brother PT-P750W", - slug="brother-p750w", - model="PT-P750W", - backend="mock", - ) - await printers_repo.create(sse_batch_db_session, p) + p = await sse_batch_db_session.get(Printer, app_printer_id) + if p is None: + p = Printer( + id=app_printer_id, + name="Brother PT-P750W", + slug="brother-p750w", + model="PT-P750W", + backend="mock", + ) + await printers_repo.create(sse_batch_db_session, p) + else: + p.name = "Brother PT-P750W" + p.slug = "brother-p750w" + p.model = "pt-p750w" + p.backend = "mock" + await sse_batch_db_session.commit() + await sse_batch_db_session.refresh(p) channels = [ f"printer:{app_printer_id}:queue", diff --git a/backend/tests/integration/test_print_queue_recovery.py b/backend/tests/integration/test_print_queue_recovery.py new file mode 100644 index 0000000..3a23aaf --- /dev/null +++ b/backend/tests/integration/test_print_queue_recovery.py @@ -0,0 +1,258 @@ +"""PrintQueue.start() muss beim Neustart Recovery durchführen. + +Task 6 — Phase 2 Job Persistence. + +Tests verifizieren: +1. PRINTING-Jobs werden als FAILED_RESTART markiert +2. QUEUED-Jobs werden in FIFO-Reihenfolge in die asyncio.Queue re-enqueued + +async_session_factory kommt aus tests/conftest.py (sichtbar für alle Tests). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store_sqlite import SQLiteJobStore +from app.services.print_queue import PrintQueue +from PIL import Image +from sqlalchemy import update +from sqlmodel import col + +# Minimal-Payload der recovery-fähigen Jobs: label_data + tape_mm wie +# print_service.submit_print_job() es schreibt (Task 5). +_SAMPLE_PAYLOAD = { + "label_data": { + "title": "Test", + "primary_id": "T-001", + "qr_payload": "https://example.com", + "source_app": "manual", + "secondary": [], + }, + "tape_mm": 24, +} + + +class _FakePrinter: + """Fake Drucker-Objekt das nie wirklich druckt.""" + + def __init__(self, printer_id): + self.id = printer_id + + async def print_image(self, image, *, tape_mm, **options): + pass + + +def _make_mock_renderer_and_loader() -> tuple[MagicMock, MagicMock]: + """Renderer + Loader-Mocks für Recovery-Tests. + + renderer.render() gibt ein minimales 1-bit-Image zurück. + loader.get() gibt ein Mock-Template mit tape_mm=24 zurück. + """ + mock_template = MagicMock() + mock_template.tape_mm = 24 + mock_template.elements = [] + + loader = MagicMock() + loader.get.return_value = mock_template + + renderer = MagicMock() + renderer.render.return_value = Image.new("1", (200, 106)) + + return renderer, loader + + +@pytest.mark.asyncio +async def test_start_marks_printing_as_failed_restart( + async_session_factory, +): + """Jobs in PRINTING before start() must be marked FAILED_RESTART.""" + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + # Pre-seed: ein Job der in PRINTING-Zustand steckt (simuliert Absturz) + interrupted_job = Job( + printer_id=printer_id, + template_key="t", + payload={}, + ) + await store.save_queued(interrupted_job) + # save_queued setzt immer QUEUED — manuell auf PRINTING setzen + async with async_session_factory() as s: + await s.execute( + update(Job) + .where(col(Job.id) == interrupted_job.id) + .values( + state=JobState.PRINTING.value, + ) + ) + await s.commit() + + fake_printer = _FakePrinter(printer_id) + queue = PrintQueue( + printers=[fake_printer], + store=store, + ) + await queue.start() + + fetched = await store.get(interrupted_job.id) + assert fetched is not None + assert fetched.state == JobState.FAILED_RESTART.value + assert fetched.error == "printer_interrupted" + + await queue.stop() + + +@pytest.mark.asyncio +async def test_start_reenqueues_queued_jobs_in_fifo_order( + async_session_factory, +): + """Jobs in QUEUED state must be re-enqueued in created_at order.""" + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + j1 = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + j2 = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + await store.save_queued(j1) + await store.save_queued(j2) + + renderer, loader = _make_mock_renderer_and_loader() + fake_printer = _FakePrinter(printer_id) + queue = PrintQueue( + printers=[fake_printer], + store=store, + renderer=renderer, + loader=loader, + ) + await queue.start() + + # asyncio.Queue-Reihenfolge prüfen — Worker läuft, holt aber Items + # aus der Queue. Wir stoppen zuerst und lesen dann die verbliebenen Items + # (Worker könnte einen schon konsumiert haben — deshalb queue.stop() FIRST) + # Alternative: Queue direkt nach start() lesen bevor Worker sie leert. + # Da Worker asyncio-concurrent ist, nutzen wir get_nowait() in einer + # kurzen Schleife BEVOR Worker aufwacht (beide Tasks laufen im gleichen + # Event-Loop — der Worker startet erst beim nächsten await). + recovered_ids = [] + while not queue._queues[printer_id].empty(): + item = queue._queues[printer_id].get_nowait() + if item is not None: # None ist Worker-Sentinel aus stop() + recovered_ids.append(item.id) + + assert recovered_ids == [str(j1.id), str(j2.id)] + + await queue.stop() + + +@pytest.mark.asyncio +async def test_recovery_skips_jobs_with_missing_label_data( + async_session_factory, +): + """C-1: Job mit payload={} (kein label_data) darf Recovery nicht abbrechen. + + Erwartet: + - Der fehlerhafte Job wird als FAILED markiert (error enthält + 'recovery_rerender_failed'). + - Ein weiterer QUEUED-Job mit gültigem Payload wird trotzdem re-enqueued. + """ + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + # Job ohne label_data — simuliert alte Pre-Phase-2-Row oder korrupte Daten + bad_job = Job(printer_id=printer_id, template_key="t", payload={}) + await store.save_queued(bad_job) + + # Gültiger Job der trotzdem verarbeitet werden soll + good_job = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + await store.save_queued(good_job) + + renderer, loader = _make_mock_renderer_and_loader() + fake_printer = _FakePrinter(printer_id) + queue = PrintQueue( + printers=[fake_printer], + store=store, + renderer=renderer, + loader=loader, + ) + await queue.start() + + # bad_job muss als FAILED in der DB stehen + fetched_bad = await store.get(bad_job.id) + assert fetched_bad is not None + assert fetched_bad.state == JobState.FAILED.value + assert fetched_bad.error is not None + assert "recovery_rerender_failed" in fetched_bad.error + + # good_job muss in _jobs registriert worden sein (Recovery hat ihn enqueued). + # Wir prüfen _jobs statt die asyncio.Queue, weil der Worker den Job bereits + # konsumiert haben könnte (gleicher Event-Loop, aber Worker-Task darf aufwachen). + assert str(good_job.id) in queue._jobs + + await queue.stop() + + +@pytest.mark.asyncio +async def test_recovery_skips_jobs_with_deleted_template( + async_session_factory, +): + """I-2: Job mit nicht mehr existierendem Template darf Recovery nicht abbrechen. + + loader.get() wirft TemplateNotFoundError für den fehlerhaften Job. + Erwartet: + - Der fehlerhafte Job wird als FAILED markiert. + - Ein weiterer QUEUED-Job mit gültigem Template wird trotzdem re-enqueued. + """ + from app.services.template_loader import TemplateNotFoundError + + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + deleted_template_job = Job( + printer_id=printer_id, + template_key="nonexistent", + payload=_SAMPLE_PAYLOAD, + ) + await store.save_queued(deleted_template_job) + + good_job = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + await store.save_queued(good_job) + + mock_template = MagicMock() + mock_template.tape_mm = 24 + mock_template.elements = [] + + loader = MagicMock() + + def _get_side_effect(key: str): + if key == "nonexistent": + raise TemplateNotFoundError(key) + return mock_template + + loader.get.side_effect = _get_side_effect + + renderer = MagicMock() + renderer.render.return_value = Image.new("1", (200, 106)) + + fake_printer = _FakePrinter(printer_id) + queue = PrintQueue( + printers=[fake_printer], + store=store, + renderer=renderer, + loader=loader, + ) + await queue.start() + + # deleted_template_job muss als FAILED in der DB stehen + fetched_deleted = await store.get(deleted_template_job.id) + assert fetched_deleted is not None + assert fetched_deleted.state == JobState.FAILED.value + assert fetched_deleted.error is not None + assert "recovery_rerender_failed" in fetched_deleted.error + + # good_job muss in _jobs registriert worden sein (Recovery hat ihn enqueued). + assert str(good_job.id) in queue._jobs + + await queue.stop() diff --git a/backend/tests/integration/test_print_service_persistence.py b/backend/tests/integration/test_print_service_persistence.py new file mode 100644 index 0000000..f093c14 --- /dev/null +++ b/backend/tests/integration/test_print_service_persistence.py @@ -0,0 +1,148 @@ +"""PrintService muss Job-Row in DB anlegen BEVOR an PrintQueue übergeben wird. + +Task 5 — Phase 2 Job Persistence. + +Fixtures erstellen PrintService + SQLiteJobStore + PrintQueue mit echtem DB-Backend. +async_session_factory kommt aus tests/conftest.py (sichtbar für alle Tests). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.models.job import JobState +from app.printer_backends.snmp_helper import PreflightStatus +from app.schemas.label_data import LabelData +from app.schemas.print_request import PrintRequest, RawLabelData +from app.services.job_store_sqlite import SQLiteJobStore +from app.services.print_queue import PrintQueue +from app.services.print_service import PrintService +from PIL import Image + + +class _FakePrinter: + """Fake Drucker-Objekt das nie wirklich druckt.""" + + def __init__(self, printer_id): + self.id = printer_id + + async def print_image(self, image, *, tape_mm, **options): + pass # kein wirklicher Druck im Test + + +@pytest_asyncio.fixture +async def sqlite_store(async_session_factory): + """SQLiteJobStore gegen per-Test SQLite DB.""" + return SQLiteJobStore(async_session_factory) + + +@pytest_asyncio.fixture +async def print_queue(sqlite_store): + """PrintQueue mit SQLiteJobStore aber OHNE Workers (nicht gestartet).""" + printer_id = uuid4() + fake_printer = _FakePrinter(printer_id) + queue = PrintQueue( + printers=[fake_printer], + store=sqlite_store, + ) + yield queue, printer_id + # Workers nicht gestartet, kein stop() nötig + + +@pytest_asyncio.fixture +def backend_mock(): + """Backend-Mock der 24mm Tape meldet und IDLE ist.""" + m = AsyncMock() + m.preflight_check.return_value = PreflightStatus( + hr_printer_status="idle", + loaded_tape_mm=24, + error_flags=[], + ) + return m + + +@pytest_asyncio.fixture +def sample_request(): + """Minimaler PrintRequest mit direktem LabelData.""" + return PrintRequest( + template_id="test-label-24mm", + data=RawLabelData( + title="Regal A-01", + primary_id="SHF-001", + qr_payload="https://example.com/shelf/001", + ), + ) + + +@pytest_asyncio.fixture +async def print_service(print_queue, sqlite_store, backend_mock): + """PrintService mit SQLiteJobStore + submit_with_id-fähiger PrintQueue. + + template_loader und renderer sind Mocks; die eigentliche Render-Logik + wird nicht getestet — nur dass save_queued() VOR queue-Submit aufgerufen wird. + """ + queue_obj, printer_id = print_queue + + template = MagicMock() + template.tape_mm = 24 + template.id = "test-label-24mm" + + loader = MagicMock() + loader.get.return_value = template + + renderer = MagicMock() + renderer.render.return_value = Image.new("1", (200, 128)) + + lookup_service = AsyncMock() + lookup_service.lookup.return_value = LabelData( + title="X", + primary_id="1", + qr_payload="u", + source_app="manual", + secondary=(), + ) + + svc = PrintService( + template_loader=loader, + renderer=renderer, + print_queue=queue_obj, + lookup_service=lookup_service, + printer_id=printer_id, + backend=backend_mock, + store=sqlite_store, + ) + return svc, sqlite_store, printer_id + + +@pytest.mark.asyncio +async def test_submit_persists_queued_job_before_queue( + print_service, + sample_request, +): + """Nach submit_print_job muss der Job in DB als QUEUED existieren.""" + svc, sqlite_store, _printer_id = print_service + + job_id = await svc.submit_print_job(sample_request) + + # Job muss in DB persistiert sein + persisted = await sqlite_store.get(job_id) + assert persisted is not None, "Job nicht in DB gefunden nach submit_print_job" + assert persisted.state == JobState.QUEUED.value + assert persisted.template_key == sample_request.template_id + assert persisted.printer_id == _printer_id + + +@pytest.mark.asyncio +async def test_submit_returns_uuid_not_string( + print_service, + sample_request, +): + """submit_print_job muss eine UUID zurückgeben (nicht str).""" + from uuid import UUID + + svc, _store, _printer_id = print_service + job_id = await svc.submit_print_job(sample_request) + assert isinstance(job_id, UUID), f"Erwartet UUID, erhalten: {type(job_id)}" diff --git a/backend/tests/unit/repositories/__init__.py b/backend/tests/unit/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/repositories/conftest.py b/backend/tests/unit/repositories/conftest.py new file mode 100644 index 0000000..80744f8 --- /dev/null +++ b/backend/tests/unit/repositories/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures für Unit-Tests der Repository-Schicht. + +Stellt eine per-Test in-memory SQLite DB mit db_session-Fixture bereit. +FK-Enforcement bewusst NICHT aktiviert — Phase-2-Tests nutzen uuid4() +als printer_id ohne echte Printer-Rows anzulegen (Unit-Scope). +""" + +from __future__ import annotations + +import app.models # noqa: F401 — registriert alle Models mit SQLModel.metadata +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +@pytest_asyncio.fixture +async def _engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + async with eng.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + yield eng + await eng.dispose() + + +@pytest_asyncio.fixture +async def db_session(_engine): + factory = async_sessionmaker(_engine, expire_on_commit=False) + async with factory() as s: + yield s diff --git a/backend/tests/unit/repositories/test_jobs_phase2.py b/backend/tests/unit/repositories/test_jobs_phase2.py new file mode 100644 index 0000000..362f4c6 --- /dev/null +++ b/backend/tests/unit/repositories/test_jobs_phase2.py @@ -0,0 +1,101 @@ +"""Phase 2: neue jobs_repo Helper fuer JobStore Adapter.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.job import JobState +from app.repositories import jobs as jobs_repo + + +@pytest.mark.asyncio +async def test_mark_printing_as_failed_restart_only_printing(db_session): + """mark_printing_as_failed_restart darf QUEUED-Jobs NICHT aendern.""" + printer_id = uuid4() + other_printer_id = uuid4() + + queued = await jobs_repo.create_queued( + db_session, + printer_id=printer_id, + template_key="t", + payload={"k": "v"}, + ) + printing = await jobs_repo.create_queued( + db_session, + printer_id=printer_id, + template_key="t", + payload={"k": "v"}, + ) + await jobs_repo.mark_printing(db_session, printing.id) + + other_printing = await jobs_repo.create_queued( + db_session, + printer_id=other_printer_id, + template_key="t", + payload={"k": "v"}, + ) + await jobs_repo.mark_printing(db_session, other_printing.id) + + affected = await jobs_repo.mark_printing_as_failed_restart( + db_session, + printer_id, + ) + assert affected == 1 # nur das eine PRINTING auf unserem printer + + await db_session.refresh(queued) + await db_session.refresh(printing) + await db_session.refresh(other_printing) + + assert queued.state == JobState.QUEUED.value + assert printing.state == JobState.FAILED_RESTART.value + assert printing.error == "printer_interrupted" + assert printing.finished_at is not None + assert other_printing.state == JobState.PRINTING.value # anderer printer unangetastet + + +@pytest.mark.asyncio +async def test_list_active_filterable_by_printer(db_session): + """list_active(printer_id=...) liefert nur Jobs des Druckers.""" + p1, p2 = uuid4(), uuid4() + j1 = await jobs_repo.create_queued(db_session, printer_id=p1, template_key="t", payload={}) + j2 = await jobs_repo.create_queued(db_session, printer_id=p2, template_key="t", payload={}) + + all_active = await jobs_repo.list_active(db_session) + assert {j.id for j in all_active} == {j1.id, j2.id} + + p1_only = await jobs_repo.list_active(db_session, printer_id=p1) + assert {j.id for j in p1_only} == {j1.id} + + +@pytest.mark.asyncio +async def test_evict_terminal_older_than(db_session): + """evict loescht DONE/FAILED/CANCELLED/FAILED_RESTART aelter als age.""" + printer_id = uuid4() + old_done = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, old_done.id) + await jobs_repo.mark_done(db_session, old_done.id, result={}) + # backdate finished_at by hand for test + old_done.finished_at = datetime.now(UTC) - timedelta(days=35) + await db_session.commit() + + young_done = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, young_done.id) + await jobs_repo.mark_done(db_session, young_done.id, result={}) # finished_at is now() + + # not terminal + queued = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + + deleted = await jobs_repo.evict_terminal_older_than(db_session, age=timedelta(days=30)) + assert deleted == 1 + + assert await jobs_repo.get(db_session, old_done.id) is None + assert await jobs_repo.get(db_session, young_done.id) is not None + assert await jobs_repo.get(db_session, queued.id) is not None diff --git a/backend/tests/unit/services/test_cleanup_task.py b/backend/tests/unit/services/test_cleanup_task.py new file mode 100644 index 0000000..bf782b9 --- /dev/null +++ b/backend/tests/unit/services/test_cleanup_task.py @@ -0,0 +1,41 @@ +"""CleanupTask runs evict_terminal_older_than periodically.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest +from app.services.cleanup_task import CleanupTask + + +@pytest.mark.asyncio +async def test_cleanup_validates_retention_days() -> None: + store = AsyncMock() + with pytest.raises(ValueError, match="retention_days must be >= 1"): + CleanupTask(store=store, retention_days=0) + + +@pytest.mark.asyncio +async def test_cleanup_initial_run_on_start() -> None: + store = AsyncMock() + store.evict_terminal_older_than.return_value = 3 + task = CleanupTask(store=store, retention_days=30, interval=timedelta(seconds=99)) + await task.start() + # stop() wartet intern auf den laufenden Task — nach stop() ist der + # erste evict_terminal_older_than-Call garantiert erfolgt. + await task.stop(timeout_s=1.0) + + store.evict_terminal_older_than.assert_awaited() + args, _ = store.evict_terminal_older_than.call_args + assert args[0] == timedelta(days=30) + + +@pytest.mark.asyncio +async def test_cleanup_fail_soft_on_exception() -> None: + store = AsyncMock() + store.evict_terminal_older_than.side_effect = RuntimeError("boom") + task = CleanupTask(store=store, retention_days=30, interval=timedelta(seconds=99)) + await task.start() + await task.stop(timeout_s=1.0) + # No exception propagated; loop survives diff --git a/backend/tests/unit/services/test_job_store_memory.py b/backend/tests/unit/services/test_job_store_memory.py new file mode 100644 index 0000000..f5ecf9e --- /dev/null +++ b/backend/tests/unit/services/test_job_store_memory.py @@ -0,0 +1,107 @@ +"""MemoryJobStore Protocol-Conformance Tests.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store import JobStore, MemoryJobStore + + +def _make_job( + printer_id: UUID, + finished_at: datetime | None = None, +) -> Job: + return Job( + printer_id=printer_id, + template_key="t", + payload={}, + state=JobState.QUEUED.value, + finished_at=finished_at, + ) + + +@pytest.mark.asyncio +async def test_memory_store_save_and_get_round_trip() -> None: + store = MemoryJobStore() + job = _make_job(uuid4()) + await store.save_queued(job) + fetched = await store.get(job.id) + # MemoryJobStore-Semantik: gleiche Referenz. SQLiteJobStore gibt neue Instanz. + assert fetched is job + + +@pytest.mark.asyncio +async def test_memory_store_implements_protocol() -> None: + store = MemoryJobStore() + assert isinstance(store, JobStore) + + +@pytest.mark.asyncio +async def test_memory_store_mark_interrupted_only_printing() -> None: + store = MemoryJobStore() + p1 = uuid4() + queued = _make_job(p1) + printing = _make_job(p1) + await store.save_queued(queued) + await store.save_queued(printing) + await store.mark_printing(printing.id) # Transition: QUEUED -> PRINTING + + affected = await store.mark_interrupted(p1) + assert affected == 1 + + still_queued = await store.get(queued.id) + assert still_queued is not None + assert still_queued.state == JobState.QUEUED.value + + interrupted = await store.get(printing.id) + assert interrupted is not None + assert interrupted.state == JobState.FAILED_RESTART.value + assert interrupted.error == "printer_interrupted" + assert interrupted.finished_at is not None + + +@pytest.mark.asyncio +async def test_memory_store_list_pending_returns_queued_and_paused_not_terminal() -> None: + store = MemoryJobStore() + p1, p2 = uuid4(), uuid4() + q1 = _make_job(p1) + pr1 = _make_job(p1) + d1 = _make_job(p1) + q2 = _make_job(p2) + await store.save_queued(q1) + await store.save_queued(pr1) + await store.mark_printing(pr1.id) # Transition: QUEUED -> PRINTING + await store.save_queued(d1) + # Transition: QUEUED -> PRINTING -> DONE (via mark_printing zuerst) + await store.mark_done(d1.id) + await store.save_queued(q2) + + p1_pending = await store.list_pending(p1) + assert {j.id for j in p1_pending} == {q1.id, pr1.id} + + +@pytest.mark.asyncio +async def test_memory_store_evict_terminal_older_than() -> None: + store = MemoryJobStore() + old = _make_job(uuid4(), finished_at=datetime.now(UTC) - timedelta(days=40)) + young = _make_job(uuid4(), finished_at=datetime.now(UTC) - timedelta(days=5)) + queued = _make_job(uuid4()) + + # old und young als DONE markieren (finished_at ist bereits gesetzt, mark_done überschreibt es) + await store.save_queued(old) + await store.save_queued(young) + await store.save_queued(queued) + # Direkt den State auf DONE setzen via mark_printing + mark_done + # würde finished_at überschreiben. + # Deshalb: _jobs direkt befüllen für alte Jobs mit vorgegebenen Timestamps. + store._jobs[old.id].state = JobState.DONE.value + store._jobs[young.id].state = JobState.DONE.value + + deleted = await store.evict_terminal_older_than(timedelta(days=30)) + assert deleted == 1 + assert await store.get(old.id) is None + assert await store.get(young.id) is not None + assert await store.get(queued.id) is not None diff --git a/backend/tests/unit/services/test_job_store_sqlite.py b/backend/tests/unit/services/test_job_store_sqlite.py new file mode 100644 index 0000000..708f00f --- /dev/null +++ b/backend/tests/unit/services/test_job_store_sqlite.py @@ -0,0 +1,203 @@ +"""SQLiteJobStore Protocol-Conformance Tests gegen echte SQLite-Session.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store import JobStore +from app.services.job_store_sqlite import SQLiteJobStore + + +@pytest.mark.asyncio +async def test_sqlite_store_implements_protocol(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + assert isinstance(store, JobStore) + + +@pytest.mark.asyncio +async def test_sqlite_store_save_and_get_round_trip(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + job = Job(printer_id=printer_id, template_key="t", payload={"foo": "bar"}) + await store.save_queued(job) + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.id == job.id + assert fetched.payload == {"foo": "bar"} + assert fetched.state == JobState.QUEUED.value + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_transitions(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + + await store.mark_printing(job.id) + fetched_printing = await store.get(job.id) + assert fetched_printing is not None + assert fetched_printing.state == JobState.PRINTING.value + assert fetched_printing.started_at is not None + + await store.mark_done(job.id) + fetched_done = await store.get(job.id) + assert fetched_done is not None + assert fetched_done.state == JobState.DONE.value + assert fetched_done.finished_at is not None + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_failed(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + await store.mark_printing(job.id) + + await store.mark_failed(job.id, "tape_empty") + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.state == JobState.FAILED.value + assert fetched.error == "tape_empty" + assert fetched.finished_at is not None + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_interrupted_only_printing(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + queued = Job(printer_id=p1, template_key="t", payload={}) + printing = Job(printer_id=p1, template_key="t", payload={}) + await store.save_queued(queued) + await store.save_queued(printing) + await store.mark_printing(printing.id) + + affected = await store.mark_interrupted(p1) + assert affected == 1 + + q = await store.get(queued.id) + p = await store.get(printing.id) + assert q is not None + assert p is not None + assert q.state == JobState.QUEUED.value + assert p.state == JobState.FAILED_RESTART.value + assert p.error == "printer_interrupted" + + +@pytest.mark.asyncio +async def test_sqlite_store_list_pending_fifo(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + j1 = Job(printer_id=p1, template_key="t", payload={"order": 1}) + await store.save_queued(j1) + j2 = Job(printer_id=p1, template_key="t", payload={"order": 2}) + await store.save_queued(j2) + # Note: SQLModel sets created_at via default_factory at construction; + # tests rely on save order matching id assignment via uuid. + + pending = await store.list_pending(p1) + assert [j.id for j in pending] == [j1.id, j2.id] + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_printing_logs_warning_on_wrong_state( + async_session_factory, caplog: pytest.LogCaptureFixture +) -> None: + """mark_printing auf einen Job im FAILED-State erzeugt logger.warning.""" + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + await store.mark_printing(job.id) + await store.mark_failed(job.id, "tape_empty") + + with caplog.at_level(logging.WARNING, logger="app.services.job_store_sqlite"): + await store.mark_printing(job.id) + + assert any( + "mark_printing" in record.message and str(job.id) in record.message + for record in caplog.records + ), f"Expected warning for mark_printing on wrong state, got: {caplog.records}" + + # Zustand bleibt FAILED — kein ungewollter Overwrite + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.state == JobState.FAILED.value + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_done_logs_warning_on_wrong_state( + async_session_factory, caplog: pytest.LogCaptureFixture +) -> None: + """mark_done auf einen Job im QUEUED-State (nicht PRINTING) erzeugt logger.warning.""" + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + + with caplog.at_level(logging.WARNING, logger="app.services.job_store_sqlite"): + await store.mark_done(job.id) + + assert any( + "mark_done" in record.message and str(job.id) in record.message for record in caplog.records + ), f"Expected warning for mark_done on wrong state, got: {caplog.records}" + + # Zustand bleibt QUEUED + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.state == JobState.QUEUED.value + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_failed_logs_warning_on_terminal_state( + async_session_factory, caplog: pytest.LogCaptureFixture +) -> None: + """mark_failed auf einen bereits DONE-Job erzeugt logger.warning.""" + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + await store.mark_printing(job.id) + await store.mark_done(job.id) + + with caplog.at_level(logging.WARNING, logger="app.services.job_store_sqlite"): + await store.mark_failed(job.id, "late_error") + + assert any( + "mark_failed" in record.message and str(job.id) in record.message + for record in caplog.records + ), f"Expected warning for mark_failed on terminal state, got: {caplog.records}" + + # Zustand bleibt DONE + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.state == JobState.DONE.value + + +@pytest.mark.asyncio +async def test_sqlite_store_evict_terminal_older_than(async_session_factory) -> None: # type: ignore[no-untyped-def] + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + old = Job(printer_id=p1, template_key="t", payload={}) + young = Job(printer_id=p1, template_key="t", payload={}) + queued = Job(printer_id=p1, template_key="t", payload={}) + await store.save_queued(old) + await store.mark_printing(old.id) + await store.mark_done(old.id) + # backdate finished_at + async with async_session_factory() as s: + fetched = await s.get(Job, old.id) + assert fetched is not None + fetched.finished_at = datetime.now(UTC) - timedelta(days=40) + await s.commit() + + await store.save_queued(young) + await store.mark_printing(young.id) + await store.mark_done(young.id) + await store.save_queued(queued) + + deleted = await store.evict_terminal_older_than(timedelta(days=30)) + assert deleted == 1 + assert await store.get(old.id) is None + assert await store.get(young.id) is not None + assert await store.get(queued.id) is not None diff --git a/backend/tests/unit/services/test_print_queue_persistence.py b/backend/tests/unit/services/test_print_queue_persistence.py new file mode 100644 index 0000000..1bf6b94 --- /dev/null +++ b/backend/tests/unit/services/test_print_queue_persistence.py @@ -0,0 +1,161 @@ +"""PrintQueue muss store.mark_* bei jeder State-Transition aufrufen. + +Tests verwenden die echte PrintQueue-Signatur: + PrintQueue(printers=[...], on_state_change=None, store=store) + +Der Worker bridget dataclass-Job.id (str) via UUID(job.id) an den Store. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import pytest +from app.services.job_store import MemoryJobStore +from app.services.print_queue import PrintQueue +from PIL import Image + +# Stabile Printer-UUID für alle Tests in diesem Modul +_PRINTER_ID = UUID("cccccccc-0000-0000-0000-000000000001") + + +def _make_printer(printer_id: UUID = _PRINTER_ID) -> MagicMock: + """Erstellt einen schnellen Fake-Printer der print_image sofort beendet.""" + printer = MagicMock() + printer.id = printer_id + printer.print_image = AsyncMock(return_value=None) + return printer + + +def _sample_image() -> Image.Image: + return Image.new("1", (300, 76)) + + +@pytest.mark.asyncio +async def test_printqueue_constructor_accepts_store() -> None: + """PrintQueue.__init__ muss store=JobStore annehmen und in _store ablegen.""" + store = MemoryJobStore() + queue = PrintQueue( + printers=[_make_printer()], + store=store, + ) + assert queue._store is store + + +@pytest.mark.asyncio +async def test_printqueue_calls_mark_printing_then_mark_done() -> None: + """Worker muss store.mark_printing dann store.mark_done bei Erfolg aufrufen.""" + store = AsyncMock(spec=MemoryJobStore) + # Recovery in start() ruft mark_interrupted + list_pending auf — konfigurieren + # damit start() sauber durchläuft ohne TypeError bei > 0-Vergleich. + store.mark_interrupted.return_value = 0 + store.list_pending.return_value = [] + printer = _make_printer() + queue = PrintQueue( + printers=[printer], + store=store, + ) + await queue.start() + try: + job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12) + await queue.wait_for_job(job_id, timeout_s=5) + finally: + await queue.stop() + + # job.id ist str — Store-Calls bekommen UUID(job_id) + expected_uuid = UUID(job_id) + store.mark_printing.assert_awaited_once_with(expected_uuid) + store.mark_done.assert_awaited_once_with(expected_uuid) + store.mark_failed.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_printqueue_calls_mark_failed_on_printer_error() -> None: + """Worker muss store.mark_failed aufrufen wenn printer.print_image wirft.""" + from app.printer_backends.exceptions import PrinterError + + store = AsyncMock(spec=MemoryJobStore) + # Recovery in start() konfigurieren — kein Absturz bei > 0-Vergleich + store.mark_interrupted.return_value = 0 + store.list_pending.return_value = [] + printer = _make_printer() + printer.print_image = AsyncMock(side_effect=PrinterError("tape_empty")) + queue = PrintQueue( + printers=[printer], + store=store, + ) + await queue.start() + try: + job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12) + await queue.wait_for_job(job_id, timeout_s=5) + finally: + await queue.stop() + + expected_uuid = UUID(job_id) + store.mark_printing.assert_awaited_once_with(expected_uuid) + store.mark_failed.assert_awaited_once() + # erstes Argument des einzigen Calls muss die richtige UUID sein + actual_uuid = store.mark_failed.call_args.args[0] + assert actual_uuid == expected_uuid + store.mark_done.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stop_marks_inflight_jobs_as_failed_in_db() -> None: + """stop() muss PRINTING-Jobs per store.mark_failed(id, 'shutdown') in DB persistieren. + + Spec-Errata C2: Der In-Memory-Zustand wurde bereits vor diesem Fix korrekt + auf FAILED gesetzt — aber der DB-Store-Aufruf fehlte (C-1 Fix). + Dieser Test verifiziert dass stop() await self._store.mark_failed(UUID(job.id), 'shutdown') + aufruft wenn ein Job beim Shutdown noch in PRINTING war. + """ + import asyncio + + store = AsyncMock(spec=MemoryJobStore) + # Recovery in start() konfigurieren — kein Absturz bei > 0-Vergleich + store.mark_interrupted.return_value = 0 + store.list_pending.return_value = [] + + # Printer der nie fertig wird — blockiert den Worker im print_image-Aufruf + # bis der Test stop() aufruft und der Task gecancelled wird. + printer = _make_printer() + + async def _blocking_print(*_args: object, **_kwargs: object) -> None: + await asyncio.sleep(60) # blockiert bis CancelledError via stop() + + printer.print_image = _blocking_print # type: ignore[assignment] + + queue = PrintQueue(printers=[printer], store=store) + await queue.start() + + job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12) + + # Kurz warten bis der Worker den Job in PRINTING übernommen hat. + from app.services.job_lifecycle import JobState as InMemState + + for _ in range(50): + job = await queue.get(job_id) + if job.state == InMemState.PRINTING: + break + await asyncio.sleep(0.05) + else: + pytest.fail("Job erreichte PRINTING-State nicht innerhalb der Wartezeit") + + # stop() soll den Worker cancellen und dann PRINTING→FAILED + mark_failed aufrufen. + await queue.stop(timeout_s=0.1) + + # Verifizierung: mark_failed muss mit der richtigen UUID und + # error='shutdown' aufgerufen worden sein. + expected_uuid = UUID(job_id) + # mark_failed kann mehrfach aufgerufen werden (einmal durch Worker-CancelledError-Pfad + # und einmal durch stop()-Cleanup) — mindestens ein Call muss (uuid, 'shutdown') sein. + shutdown_calls = [ + call + for call in store.mark_failed.call_args_list + if call.args == (expected_uuid, "shutdown") + ] + assert shutdown_calls, ( + f"store.mark_failed(UUID(job_id), 'shutdown') wurde nicht aufgerufen. " + f"Tatsächliche Calls: {store.mark_failed.call_args_list}" + ) diff --git a/backend/tests/unit/services/test_print_service.py b/backend/tests/unit/services/test_print_service.py index 3a5c6ce..63aaa79 100644 --- a/backend/tests/unit/services/test_print_service.py +++ b/backend/tests/unit/services/test_print_service.py @@ -1,7 +1,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock -from uuid import UUID +from uuid import UUID, uuid4 import pytest from app.printer_backends.exceptions import ( @@ -54,10 +54,16 @@ def renderer(image): return m +_FAKE_JOB_UUID = uuid4() + + @pytest.fixture def queue(): m = AsyncMock() m.submit.return_value = "job-1" + # Phase 2: submit_with_id und submit_paused_with_id werden jetzt genutzt + m.submit_with_id.return_value = _FAKE_JOB_UUID + m.submit_paused_with_id.return_value = _FAKE_JOB_UUID return m @@ -89,15 +95,18 @@ def backend(): _PRINTER_ID = UUID("bbbbbbbb-0000-0000-0000-000000000001") -def _service(loader, renderer, queue, lookup_service, backend): - return PrintService( - template_loader=loader, - renderer=renderer, - print_queue=queue, - lookup_service=lookup_service, - printer_id=_PRINTER_ID, - backend=backend, - ) +def _service(loader, renderer, queue, lookup_service, backend, store=None): + kwargs = { + "template_loader": loader, + "renderer": renderer, + "print_queue": queue, + "lookup_service": lookup_service, + "printer_id": _PRINTER_ID, + "backend": backend, + } + if store is not None: + kwargs["store"] = store + return PrintService(**kwargs) # --------------------------------------------------------------------------- @@ -120,8 +129,9 @@ async def test_lookup_path_calls_lookup_and_renders( job_id = await svc.submit_print_job(req) lookup_service.lookup.assert_awaited_once_with("snipeit", "42") renderer.render.assert_called_once() - queue.submit.assert_awaited_once() - assert job_id == "job-1" + # Phase 2: submit_print_job ruft submit_with_id statt submit + queue.submit_with_id.assert_awaited_once() + assert isinstance(job_id, UUID) async def test_data_path_bypasses_lookup_and_marks_source_manual( @@ -143,7 +153,8 @@ async def test_data_path_bypasses_lookup_and_marks_source_manual( assert isinstance(label_data, LabelData) assert label_data.source_app == "manual" assert label_data.secondary == ("a",) - assert job_id == "job-1" + # Phase 2: submit_print_job gibt UUID zurück + assert isinstance(job_id, UUID) async def test_template_not_found_raises_synchronously( @@ -174,7 +185,8 @@ async def test_options_passed_to_queue(loader, renderer, queue, lookup_service, options=PrintOptions(copies=2, auto_cut=False, high_resolution=True), ) await svc.submit_print_job(req) - _, kwargs = queue.submit.call_args + # Phase 2: submit_with_id statt submit + _, kwargs = queue.submit_with_id.call_args assert kwargs["tape_mm"] == 24 assert kwargs["auto_cut"] is False assert kwargs["high_resolution"] is True @@ -197,9 +209,10 @@ async def test_preflight_match_proceeds_normally( data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), ) job_id = await svc.submit_print_job(req) - assert job_id == "job-1" + assert isinstance(job_id, UUID) backend.preflight_check.assert_awaited_once() - queue.submit.assert_awaited_once() + # Phase 2: submit_print_job ruft submit_with_id statt submit + queue.submit_with_id.assert_awaited_once() # --------------------------------------------------------------------------- @@ -263,13 +276,13 @@ async def test_preflight_mismatch_queue_creates_paused_job( loaded_tape_mm=12, error_flags=[], ) - # submit_paused() returns the job_id; queue.get returns the job object. - # The job starts PAUSED (submit_paused transitions it before registering). + # Phase 2: submit_paused_with_id statt submit_paused. + # queue.get gibt ein in-memory Job-Objekt zurück auf das Metadaten gesetzt werden. job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={}) from app.services.job_lifecycle import JobStateMachine JobStateMachine.transition(job, JobState.PAUSED) - queue.submit_paused = AsyncMock(return_value="job-1") + queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID queue.get.return_value = job svc = _service(loader, renderer, queue, lookup_service, backend) req = PrintRequest( @@ -278,11 +291,12 @@ async def test_preflight_mismatch_queue_creates_paused_job( on_tape_mismatch="queue", ) job_id = await svc.submit_print_job(req) - assert job_id == "job-1" - # submit_paused() was called (not submit()) - queue.submit_paused.assert_awaited_once() + assert isinstance(job_id, UUID) + # Phase 2: submit_paused_with_id() wurde aufgerufen (nicht submit/submit_with_id) + queue.submit_paused_with_id.assert_awaited_once() + queue.submit_with_id.assert_not_awaited() queue.submit.assert_not_awaited() - # tape-mismatch metadata attached after submit_paused + # tape-mismatch metadata attached after submit_paused_with_id assert job.state == JobState.PAUSED assert job.error_code == "tape_mismatch" assert job.error_message is not None @@ -302,7 +316,7 @@ async def test_preflight_mismatch_queue_none_tape_loaded( from app.services.job_lifecycle import JobStateMachine JobStateMachine.transition(job, JobState.PAUSED) - queue.submit_paused = AsyncMock(return_value="job-1") + queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID queue.get.return_value = job svc = _service(loader, renderer, queue, lookup_service, backend) req = PrintRequest( @@ -311,8 +325,9 @@ async def test_preflight_mismatch_queue_none_tape_loaded( on_tape_mismatch="queue", ) job_id = await svc.submit_print_job(req) - assert job_id == "job-1" - queue.submit_paused.assert_awaited_once() + assert isinstance(job_id, UUID) + # Phase 2: submit_paused_with_id() aufgerufen + queue.submit_paused_with_id.assert_awaited_once() assert job.state == JobState.PAUSED assert job.error_code == "tape_mismatch" assert job.error_detail == {"expected_mm": 24, "loaded_mm": None} @@ -388,16 +403,19 @@ async def test_tape_mismatch_queue_job_never_enters_asyncio_queue() -> None: """ from unittest.mock import AsyncMock, MagicMock + from uuid import UUID as _UUID from app.printer_backends.snmp_helper import PreflightStatus from app.services.print_queue import PrintQueue from app.services.print_service import PrintService from PIL import Image as _Image + _race_printer_id = _UUID("aaaaaaaa-0000-0000-0000-000000000001") + class _NeverPrint: """Printer that must never be called in this test.""" - id = "pt@race" + id = _race_printer_id async def print_image(self, image, *, tape_mm, **kw): raise AssertionError("Worker dequeued the paused job — race is present!") @@ -427,7 +445,7 @@ async def print_image(self, image, *, tape_mm, **kw): renderer=renderer, print_queue=real_queue, lookup_service=AsyncMock(), - printer_id="pt@race", + printer_id=_race_printer_id, backend=backend, ) @@ -441,7 +459,8 @@ async def print_image(self, image, *, tape_mm, **kw): # The asyncio.Queue MUST be empty — job was submitted in PAUSED state, # not enqueued. If this fails, the race-prone code path is still active. - queue_size = real_queue._queues["pt@race"].qsize() + # Phase 2: Printer-Key ist jetzt UUID, nicht "pt@race". + queue_size = real_queue._queues[_race_printer_id].qsize() assert queue_size == 0, ( f"Job was placed in asyncio.Queue (qsize={queue_size}) — " "race-prone submit+pause path still active, fix not applied!" @@ -453,3 +472,87 @@ async def print_image(self, image, *, tape_mm, **kw): assert job.state == JobState.PAUSED, f"Expected PAUSED, got {job.state}" assert job.error_code == "tape_mismatch" assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12} + + +# --------------------------------------------------------------------------- +# Fix C-1: PAUSED-Pfad ruft save_queued NICHT auf +# --------------------------------------------------------------------------- + + +async def test_tape_mismatch_queue_path_does_not_persist_db_job( + loader, renderer, queue, lookup_service, backend +) -> None: + """C-1-Fix: on_tape_mismatch=queue → save_queued wird NICHT aufgerufen. + + Vorher wurde der Job als QUEUED persistiert, obwohl er in PAUSED-State + versetzt wurde. Nach Hub-Restart würde list_pending() ihn als QUEUED + finden und sofort drucken — Doppel-Druck-Risiko. + Fix: PAUSED-Jobs bleiben in-memory-only, kein DB-Persist. + """ + backend.preflight_check.return_value = PreflightStatus( + hr_printer_status="idle", + loaded_tape_mm=12, # mismatch: template wants 24mm + error_flags=[], + ) + job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={}) + from app.services.job_lifecycle import JobStateMachine + + JobStateMachine.transition(job, JobState.PAUSED) + queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID + queue.get.return_value = job + + mock_store = AsyncMock() + + svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store) + req = PrintRequest( + template_id="qr-only-24mm", + data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), + on_tape_mismatch="queue", + ) + + job_id = await svc.submit_print_job(req) + + assert isinstance(job_id, UUID) + # save_queued darf NICHT aufgerufen worden sein — kein DB-Persist für PAUSED + mock_store.save_queued.assert_not_awaited() + # submit_paused_with_id muss aber aufgerufen worden sein + queue.submit_paused_with_id.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Fix I-1: Rollback bei queue.submit_with_id Fehler +# --------------------------------------------------------------------------- + + +async def test_submit_with_failing_queue_marks_db_job_failed( + loader, renderer, queue, lookup_service, backend +) -> None: + """I-1-Fix: Wenn queue.submit_with_id wirft, muss der DB-Job auf FAILED gesetzt werden. + + Ohne Rollback bliebe eine stale QUEUED-Row in der DB ohne Worker-Gegenstück. + Nach Hub-Restart würde list_pending() sie finden und re-enqueuen — aber der + Job hat keinen gültigen Zustand mehr. + Fix: try/except um submit_with_id, bei Exception → mark_failed + re-raise. + """ + submit_error = RuntimeError("asyncio.Queue voll oder andere Fehlerursache") + queue.submit_with_id.side_effect = submit_error + + mock_store = AsyncMock() + + svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store) + req = PrintRequest( + template_id="qr-only-24mm", + data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), + ) + + with pytest.raises(RuntimeError): + await svc.submit_print_job(req) + + # save_queued muss aufgerufen worden sein (DB-Persist vor submit) + mock_store.save_queued.assert_awaited_once() + # mark_failed muss aufgerufen worden sein mit passendem error-String + mock_store.mark_failed.assert_awaited_once() + call_args = mock_store.mark_failed.call_args + error_msg: str = call_args.args[1] if call_args.args else call_args.kwargs["error"] + assert "submit_failed" in error_msg + assert "RuntimeError" in error_msg diff --git a/docs/superpowers/plans/2026-05-31-phase-2-job-persistence.md b/docs/superpowers/plans/2026-05-31-phase-2-job-persistence.md new file mode 100644 index 0000000..b8f1ab9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-phase-2-job-persistence.md @@ -0,0 +1,2176 @@ +# Hub Phase 2 — Job Persistence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist all PrintQueue Job-Lifecycle-Transitions in the SQLite `jobs` table so `GET /api/jobs/{id}` und `GET /api/batches/{id}` aktuelle Zustaende liefern, Hub-Restart Recovery sauber arbeitet und Hangar's Result-Page Live-Updates bekommt. + +**Architecture:** `JobStore` Protocol mit Dependency-Injection als Boundary zwischen `PrintQueue` (in-memory Lifecycle) und `jobs`-Tabelle (DB). `SQLiteJobStore` delegiert an existierende `jobs_repo` Funktionen. Recovery rerendert Bilder aus `template_key + payload` (kein Blob in DB). Neuer `GET /api/batches/{id}` liefert Snapshot fuer Hangar Result-Page Initial-Render. `CleanupTask` raeumt terminal Jobs aelter als `PRINTER_HUB_JOB_RETENTION_DAYS` (Default 30). + +**Tech Stack:** Python 3.12 + FastAPI + SQLModel + SQLAlchemy AsyncSession + Pydantic v2 + pytest + asyncio. Bestehende Codebase: `app/services/print_queue.py`, `app/services/print_service.py`, `app/repositories/jobs.py`, `app/models/job.py`. + +**Spec:** [`docs/superpowers/specs/2026-05-31-phase-2-job-persistence-design.md`](../specs/2026-05-31-phase-2-job-persistence-design.md) — inklusive Errata zu Job-Klassen und Repo-Wiederverwendung. + +**Branch:** `feat/phase-2-job-persistence` (existiert, mit Spec gepusht). + +**Issue:** [strausmann/Label-Printer-Hub#93](https://github.com/strausmann/Label-Printer-Hub/issues/93). + +**Constraints:** + +- **Conventional Commits** auf Deutsch mit echten Umlauten (ae/oe/ue/ss als Quelltext-Fallback nur wenn ASCII-only Tool noetig) +- **Git Identity:** `Björn Strausmann ` via `-c user.name=/-c user.email=` +- **KEIN** `Co-Authored-By: Claude` in Commits +- **TDD-Pflicht:** Jeder Code-Task hat RED-Test zuerst, dann GREEN-Implementation +- **Refs-Konvention:** `Refs #93` in jedem Commit +- Bestehende **831 Tests** muessen weiter gruen bleiben — kein Breaking-Change ausser intern in `PrintQueue.__init__` + +--- + +## File Structure + +**New files:** + +``` +backend/app/services/job_store.py # Protocol + Memory impl +backend/app/services/job_store_sqlite.py # SQLite impl +backend/app/services/cleanup_task.py # Background retention +backend/app/api/routes/batches.py # GET /api/batches/{id} +backend/app/schemas/batch_read.py # BatchRead + BatchSummary (separate from existing BatchRequest) +backend/tests/unit/services/test_job_store_protocol.py +backend/tests/unit/services/test_job_store_sqlite.py +backend/tests/unit/services/test_cleanup_task.py +backend/tests/integration/test_print_queue_persistence.py +backend/tests/integration/test_print_queue_recovery.py +backend/tests/integration/test_batch_snapshot_endpoint.py +``` + +**Modified files:** + +``` +backend/app/repositories/jobs.py # +mark_printing_as_failed_restart, +evict_terminal_older_than, list_active accepts printer_id +backend/app/services/print_queue.py # store param, recovery in start(), save() calls in worker +backend/app/services/print_service.py # store param, create_queued before queue.submit +backend/app/main.py # lifespan: instantiate JobStore + CleanupTask +backend/app/config.py # +job_retention_days field +backend/tests/unit/services/test_print_queue_*.py # adapt to new constructor (existing tests) +backend/tests/integration/test_batch_endpoint_happy.py # verify DB rows now exist +``` + +--- + +## Task 1: Repository Helpers extend + +**Files:** +- Modify: `backend/app/repositories/jobs.py` +- Test: `backend/tests/unit/repositories/test_jobs_phase2.py` (neu) + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/unit/repositories/test_jobs_phase2.py` + +```python +"""Phase 2: neue jobs_repo Helper fuer JobStore Adapter.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.repositories import jobs as jobs_repo + + +@pytest.mark.asyncio +async def test_mark_printing_as_failed_restart_only_printing(db_session): + """mark_printing_as_failed_restart darf QUEUED-Jobs NICHT aendern.""" + printer_id = uuid4() + other_printer_id = uuid4() + + queued = await jobs_repo.create_queued( + db_session, printer_id=printer_id, + template_key="t", payload={"k": "v"}, + ) + printing = await jobs_repo.create_queued( + db_session, printer_id=printer_id, + template_key="t", payload={"k": "v"}, + ) + await jobs_repo.mark_printing(db_session, printing.id) + + other_printing = await jobs_repo.create_queued( + db_session, printer_id=other_printer_id, + template_key="t", payload={"k": "v"}, + ) + await jobs_repo.mark_printing(db_session, other_printing.id) + + affected = await jobs_repo.mark_printing_as_failed_restart( + db_session, printer_id, + ) + assert affected == 1 # nur das eine PRINTING auf unserem printer + + await db_session.refresh(queued) + await db_session.refresh(printing) + await db_session.refresh(other_printing) + + assert queued.state == JobState.QUEUED.value + assert printing.state == JobState.FAILED_RESTART.value + assert printing.error == "printer_interrupted" + assert printing.finished_at is not None + assert other_printing.state == JobState.PRINTING.value # anderer printer unangetastet + + +@pytest.mark.asyncio +async def test_list_active_filterable_by_printer(db_session): + """list_active(printer_id=...) liefert nur Jobs des Druckers.""" + p1, p2 = uuid4(), uuid4() + j1 = await jobs_repo.create_queued(db_session, printer_id=p1, template_key="t", payload={}) + j2 = await jobs_repo.create_queued(db_session, printer_id=p2, template_key="t", payload={}) + + all_active = await jobs_repo.list_active(db_session) + assert {j.id for j in all_active} == {j1.id, j2.id} + + p1_only = await jobs_repo.list_active(db_session, printer_id=p1) + assert {j.id for j in p1_only} == {j1.id} + + +@pytest.mark.asyncio +async def test_evict_terminal_older_than(db_session): + """evict loescht DONE/FAILED/CANCELLED/FAILED_RESTART aelter als age.""" + printer_id = uuid4() + old_done = await jobs_repo.create_queued(db_session, printer_id=printer_id, template_key="t", payload={}) + await jobs_repo.mark_done(db_session, old_done.id, result={}) + # backdate finished_at by hand for test + old_done.finished_at = datetime.now(UTC) - timedelta(days=35) + await db_session.commit() + + young_done = await jobs_repo.create_queued(db_session, printer_id=printer_id, template_key="t", payload={}) + await jobs_repo.mark_done(db_session, young_done.id, result={}) # finished_at is now() + + queued = await jobs_repo.create_queued(db_session, printer_id=printer_id, template_key="t", payload={}) # not terminal + + deleted = await jobs_repo.evict_terminal_older_than(db_session, age=timedelta(days=30)) + assert deleted == 1 + + assert await jobs_repo.get(db_session, old_done.id) is None + assert await jobs_repo.get(db_session, young_done.id) is not None + assert await jobs_repo.get(db_session, queued.id) is not None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/unit/repositories/test_jobs_phase2.py -v +``` +Expected: FAIL `AttributeError: module 'app.repositories.jobs' has no attribute 'mark_printing_as_failed_restart'`. + +- [ ] **Step 3: Add `mark_printing_as_failed_restart` to repo** + +Datei: `backend/app/repositories/jobs.py` — am Ende anhaengen, nach `mark_inflight_as_failed_restart`: + +```python +async def mark_printing_as_failed_restart( + session: AsyncSession, + printer_id: UUID, +) -> int: + """Phase 2: UPDATE only PRINTING jobs for a specific printer to + FAILED_RESTART with error='printer_interrupted'. + + Used at PrintQueue.start() — QUEUED jobs are NOT affected because + they will be re-enqueued cleanly. Only PRINTING jobs are ambiguous + (printer may have completed before crash but Hub couldn't update DB). + + Returns the count of affected rows. + """ + stmt = ( + update(Job) + .where( + col(Job.printer_id) == printer_id, + col(Job.state) == JobState.PRINTING.value, + ) + .values( + state=JobState.FAILED_RESTART.value, + error="printer_interrupted", + finished_at=datetime.now(UTC), + ) + .execution_options(synchronize_session="fetch") + ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount) # type: ignore[attr-defined] +``` + +- [ ] **Step 4: Extend `list_active` with optional printer_id** + +Replace existing `list_active` in `backend/app/repositories/jobs.py:163`: + +```python +async def list_active( + session: AsyncSession, + *, + printer_id: UUID | None = None, +) -> list[Job]: + """Return all jobs in QUEUED or PRINTING state (covered by ix_jobs_state). + + Phase 2: optional printer_id filter for PrintQueue.start() recovery. + """ + inflight = (JobState.QUEUED.value, JobState.PRINTING.value) + stmt = ( + select(Job) + .where(col(Job.state).in_(inflight)) + .order_by(col(Job.created_at)) + ) + if printer_id is not None: + stmt = stmt.where(col(Job.printer_id) == printer_id) + result = await session.execute(stmt) + return list(result.scalars()) +``` + +- [ ] **Step 5: Add `evict_terminal_older_than` to repo** + +Anhaengen nach `list_active`: + +```python +async def evict_terminal_older_than( + session: AsyncSession, + age: timedelta, +) -> int: + """Phase 2 cleanup: DELETE terminal jobs older than age. + + Terminal = DONE | FAILED | FAILED_RESTART | CANCELLED. + Comparison is on finished_at (set whenever a job leaves a non-terminal state). + + Returns the count of deleted rows. + """ + terminal = ( + JobState.DONE.value, + JobState.FAILED.value, + JobState.FAILED_RESTART.value, + JobState.CANCELLED.value, + ) + cutoff = datetime.now(UTC) - age + stmt = ( + delete(Job) + .where(col(Job.state).in_(terminal)) + .where(col(Job.finished_at) < cutoff) + ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount) # type: ignore[attr-defined] +``` + +- [ ] **Step 5a: Import-Zeilen in `jobs.py` ergaenzen (R2-m1)** + +Oeffne `backend/app/repositories/jobs.py`. Suche den Block mit den bestehenden Imports am Dateianfang. + +Bestehendes Statement `from sqlalchemy import select` ersetzen durch: + +```python +from sqlalchemy import delete, select, update +``` + +Bestehendes Statement `from datetime import datetime` (oder `from datetime import UTC, datetime`) ersetzen durch (sofern nicht schon vollstaendig vorhanden): + +```python +from datetime import UTC, datetime, timedelta +``` + +Sicherstellen dass `from sqlmodel import col, select, SQLModel` oder aequivalent `col` exportiert — `col` wird von `sqlmodel` re-exportiert und ist bereits in den bestehenden `list_*`-Funktionen in Gebrauch. Kein neuer Import noetig, nur `delete` + `update` zur `sqlalchemy`-Zeile hinzufuegen. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/unit/repositories/test_jobs_phase2.py -v +``` +Expected: 3 PASS. + +- [ ] **Step 7: Run full unit-test suite — no regressions** + +Run: +```bash +cd backend && pytest tests/unit/ -q +``` +Expected: alle gruen. + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/repositories/jobs.py backend/tests/unit/repositories/test_jobs_phase2.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(repo): jobs_repo Helper fuer Phase 2 JobStore + +- mark_printing_as_failed_restart(printer_id) — nur PRINTING affected +- list_active(printer_id=None) — optionaler Filter +- evict_terminal_older_than(age) — Cleanup-Helper + +Refs #93" +``` + +--- + +## Task 2: JobStore Protocol + MemoryJobStore + +**Files:** +- Create: `backend/app/services/job_store.py` +- Test: `backend/tests/unit/services/test_job_store_memory.py` + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/unit/services/test_job_store_memory.py` + +```python +"""MemoryJobStore Protocol-Conformance Tests.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store import JobStore, MemoryJobStore + + +def _make_job(printer_id, state=JobState.QUEUED, finished_at=None): + return Job( + printer_id=printer_id, + template_key="t", + payload={}, + state=state.value, + finished_at=finished_at, + ) + + +@pytest.mark.asyncio +async def test_memory_store_save_and_get_round_trip(): + store = MemoryJobStore() + job = _make_job(uuid4()) + await store.save_queued(job) + fetched = await store.get(job.id) + assert fetched is job + + +@pytest.mark.asyncio +async def test_memory_store_implements_protocol(): + store = MemoryJobStore() + assert isinstance(store, JobStore) + + +@pytest.mark.asyncio +async def test_memory_store_mark_interrupted_only_printing(): + store = MemoryJobStore() + p1 = uuid4() + queued = _make_job(p1, state=JobState.QUEUED) + printing = _make_job(p1, state=JobState.PRINTING) + await store.save_queued(queued) + await store.save_queued(printing) + + affected = await store.mark_interrupted(p1) + assert affected == 1 + assert (await store.get(queued.id)).state == JobState.QUEUED.value + interrupted = await store.get(printing.id) + assert interrupted.state == JobState.FAILED_RESTART.value + assert interrupted.error == "printer_interrupted" + assert interrupted.finished_at is not None + + +@pytest.mark.asyncio +async def test_memory_store_list_pending_returns_queued_and_paused_not_terminal(): + store = MemoryJobStore() + p1, p2 = uuid4(), uuid4() + q1 = _make_job(p1, state=JobState.QUEUED) + pr1 = _make_job(p1, state=JobState.PRINTING) + d1 = _make_job(p1, state=JobState.DONE) + q2 = _make_job(p2, state=JobState.QUEUED) + for j in (q1, pr1, d1, q2): + await store.save_queued(j) + + p1_pending = await store.list_pending(p1) + assert {j.id for j in p1_pending} == {q1.id, pr1.id} + + +@pytest.mark.asyncio +async def test_memory_store_evict_terminal_older_than(): + store = MemoryJobStore() + old = _make_job(uuid4(), state=JobState.DONE, finished_at=datetime.now(UTC) - timedelta(days=40)) + young = _make_job(uuid4(), state=JobState.DONE, finished_at=datetime.now(UTC) - timedelta(days=5)) + queued = _make_job(uuid4(), state=JobState.QUEUED) + for j in (old, young, queued): + await store.save_queued(j) + + deleted = await store.evict_terminal_older_than(timedelta(days=30)) + assert deleted == 1 + assert await store.get(old.id) is None + assert await store.get(young.id) is not None + assert await store.get(queued.id) is not None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/unit/services/test_job_store_memory.py -v +``` +Expected: FAIL `ModuleNotFoundError: No module named 'app.services.job_store'`. + +- [ ] **Step 3: Create JobStore Protocol + MemoryJobStore** + +**Wichtig (R2-C1):** `job_store.py` importiert `from app.models.job import Job, JobState` — das +ist der **SQLModel-DB-Job** aus `app/models/job.py` (UUID-id, `template_key`, `payload: dict`). +**NICHT** `app.services.job_lifecycle.Job` (Dataclass mit `image_payload`, `_done_event`). +Der Worker bridged via `str(job.id)` (Dataclass-id ist str) → `UUID(job.id)` im Store-Call. + +Datei: `backend/app/services/job_store.py` + +```python +"""Phase 2: JobStore Protocol + MemoryJobStore in-memory Implementation. + +JobStore is the persistence boundary that PrintQueue uses to save job +state transitions. SQLiteJobStore (production) implements this Protocol +by delegating to jobs_repo. MemoryJobStore is the test/migration impl. + +Klärung (R2-C1): Alle Store-Methoden arbeiten auf app.models.job.Job +(SQLModel, UUID-id). Der Worker-Code in print_queue.py verwendet +app.services.job_lifecycle.Job (Dataclass, str-id). Bridge: + worker ruft self._store.mark_printing(str(job.id)) + Store konvertiert intern: UUID(job_id_str) +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Protocol, runtime_checkable +from uuid import UUID + +from app.models.job import Job, JobState + +_NON_TERMINAL = (JobState.QUEUED.value, JobState.PRINTING.value) +_TERMINAL = ( + JobState.DONE.value, + JobState.FAILED.value, + JobState.FAILED_RESTART.value, + JobState.CANCELLED.value, +) + + +@runtime_checkable +class JobStore(Protocol): + """Persistente Backing-Store fuer Jobs. + + All methods are async and may perform I/O. Implementations must be + safe to call from multiple asyncio tasks concurrently. + """ + + async def save_queued(self, job: Job) -> None: + """Persist a newly-created QUEUED job (insert). + + Called from PrintService.submit_print_job BEFORE handing off + to the queue. After this returns, the job is durable. + """ + + async def get(self, job_id: UUID) -> Job | None: + """Load a job by ID. None if not found.""" + + async def mark_printing(self, job_id: UUID) -> None: + """Transition QUEUED -> PRINTING. Called by worker when it picks up the job.""" + + async def mark_done(self, job_id: UUID) -> None: + """Transition PRINTING -> DONE. Called by worker after successful print.""" + + async def mark_failed(self, job_id: UUID, error: str) -> None: + """Transition any non-terminal -> FAILED with given error message.""" + + async def mark_interrupted(self, printer_id: UUID) -> int: + """Recovery: set all PRINTING jobs of this printer to FAILED_RESTART + with error='printer_interrupted'. + + Called from PrintQueue.start() BEFORE list_pending. + + Returns the count of affected rows. + """ + + async def list_pending(self, printer_id: UUID) -> list[Job]: + """Return all non-terminal jobs for this printer, sorted by created_at (FIFO). + + Called from PrintQueue.start() AFTER mark_interrupted to find + QUEUED jobs that need to be re-enqueued. + """ + + async def evict_terminal_older_than(self, age: timedelta) -> int: + """Delete terminal jobs (DONE/FAILED/FAILED_RESTART/CANCELLED) with + finished_at older than `age` ago. Used by CleanupTask. + + Returns the count of deleted rows. + """ + + +class MemoryJobStore(JobStore): + """In-Memory JobStore for tests and PrintService boot-phase. + + Holds Job objects in a dict keyed by id. Not thread-safe but + safe for single-event-loop asyncio use. + """ + + def __init__(self) -> None: + self._jobs: dict[UUID, Job] = {} + + async def save_queued(self, job: Job) -> None: + self._jobs[job.id] = job + + async def get(self, job_id: UUID) -> Job | None: + return self._jobs.get(job_id) + + async def mark_printing(self, job_id: UUID) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.PRINTING.value + job.started_at = datetime.now(UTC) + + async def mark_done(self, job_id: UUID) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.DONE.value + job.finished_at = datetime.now(UTC) + + async def mark_failed(self, job_id: UUID, error: str) -> None: + job = self._jobs.get(job_id) + if job is None: + return + job.state = JobState.FAILED.value + job.error = error + job.finished_at = datetime.now(UTC) + + async def mark_interrupted(self, printer_id: UUID) -> int: + count = 0 + for job in self._jobs.values(): + if job.printer_id == printer_id and job.state == JobState.PRINTING.value: + job.state = JobState.FAILED_RESTART.value + job.error = "printer_interrupted" + job.finished_at = datetime.now(UTC) + count += 1 + return count + + async def list_pending(self, printer_id: UUID) -> list[Job]: + items = [ + j for j in self._jobs.values() + if j.printer_id == printer_id and j.state in _NON_TERMINAL + ] + return sorted(items, key=lambda j: j.created_at) + + async def evict_terminal_older_than(self, age: timedelta) -> int: + cutoff = datetime.now(UTC) - age + to_delete = [ + jid for jid, j in self._jobs.items() + if j.state in _TERMINAL and j.finished_at is not None and j.finished_at < cutoff + ] + for jid in to_delete: + del self._jobs[jid] + return len(to_delete) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/unit/services/test_job_store_memory.py -v +``` +Expected: 5 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/job_store.py backend/tests/unit/services/test_job_store_memory.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(services): JobStore Protocol + MemoryJobStore (Phase 2) + +JobStore ist die Persistierungs-Boundary die PrintQueue nutzt um +Lifecycle-Transitionen zu speichern. MemoryJobStore ist die Test-Impl +mit gleicher Semantik wie spaeterer SQLiteJobStore. + +Refs #93" +``` + +--- + +## Task 3: SQLiteJobStore + +**Files:** +- Create: `backend/app/services/job_store_sqlite.py` +- Test: `backend/tests/unit/services/test_job_store_sqlite.py` + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/unit/services/test_job_store_sqlite.py` + +```python +"""SQLiteJobStore Protocol-Conformance Tests gegen echte SQLite-Session.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store import JobStore +from app.services.job_store_sqlite import SQLiteJobStore + + +@pytest.mark.asyncio +async def test_sqlite_store_implements_protocol(async_session_factory): + store = SQLiteJobStore(async_session_factory) + assert isinstance(store, JobStore) + + +@pytest.mark.asyncio +async def test_sqlite_store_save_and_get_round_trip(async_session_factory): + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + job = Job(printer_id=printer_id, template_key="t", payload={"foo": "bar"}) + await store.save_queued(job) + fetched = await store.get(job.id) + assert fetched is not None + assert fetched.id == job.id + assert fetched.payload == {"foo": "bar"} + assert fetched.state == JobState.QUEUED.value + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_transitions(async_session_factory): + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + + await store.mark_printing(job.id) + fetched = await store.get(job.id) + assert fetched.state == JobState.PRINTING.value + assert fetched.started_at is not None + + await store.mark_done(job.id) + fetched = await store.get(job.id) + assert fetched.state == JobState.DONE.value + assert fetched.finished_at is not None + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_failed(async_session_factory): + store = SQLiteJobStore(async_session_factory) + job = Job(printer_id=uuid4(), template_key="t", payload={}) + await store.save_queued(job) + await store.mark_printing(job.id) + + await store.mark_failed(job.id, "tape_empty") + fetched = await store.get(job.id) + assert fetched.state == JobState.FAILED.value + assert fetched.error == "tape_empty" + assert fetched.finished_at is not None + + +@pytest.mark.asyncio +async def test_sqlite_store_mark_interrupted_only_printing(async_session_factory): + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + queued = Job(printer_id=p1, template_key="t", payload={}) + printing = Job(printer_id=p1, template_key="t", payload={}) + await store.save_queued(queued) + await store.save_queued(printing) + await store.mark_printing(printing.id) + + affected = await store.mark_interrupted(p1) + assert affected == 1 + + q = await store.get(queued.id) + p = await store.get(printing.id) + assert q.state == JobState.QUEUED.value + assert p.state == JobState.FAILED_RESTART.value + assert p.error == "printer_interrupted" + + +@pytest.mark.asyncio +async def test_sqlite_store_list_pending_fifo(async_session_factory): + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + j1 = Job(printer_id=p1, template_key="t", payload={"order": 1}) + await store.save_queued(j1) + j2 = Job(printer_id=p1, template_key="t", payload={"order": 2}) + await store.save_queued(j2) + # Note: SQLModel sets created_at via default_factory at construction; + # tests rely on save order matching id assignment via uuid. + + pending = await store.list_pending(p1) + assert [j.id for j in pending] == [j1.id, j2.id] + + +@pytest.mark.asyncio +async def test_sqlite_store_evict_terminal_older_than(async_session_factory): + store = SQLiteJobStore(async_session_factory) + p1 = uuid4() + old = Job(printer_id=p1, template_key="t", payload={}) + young = Job(printer_id=p1, template_key="t", payload={}) + queued = Job(printer_id=p1, template_key="t", payload={}) + await store.save_queued(old) + await store.mark_printing(old.id) + await store.mark_done(old.id) + # backdate finished_at + async with async_session_factory() as s: + fetched = await s.get(Job, old.id) + fetched.finished_at = datetime.now(UTC) - timedelta(days=40) + await s.commit() + + await store.save_queued(young) + await store.mark_printing(young.id) + await store.mark_done(young.id) + await store.save_queued(queued) + + deleted = await store.evict_terminal_older_than(timedelta(days=30)) + assert deleted == 1 + assert await store.get(old.id) is None + assert await store.get(young.id) is not None + assert await store.get(queued.id) is not None +``` + +`async_session_factory` ist eine bestehende Fixture aus `tests/integration/conftest.py` — falls in `unit/services/conftest.py` nicht vorhanden, kopiere die Definition. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/unit/services/test_job_store_sqlite.py -v +``` +Expected: FAIL `ModuleNotFoundError: No module named 'app.services.job_store_sqlite'`. + +- [ ] **Step 3: Create SQLiteJobStore** + +Datei: `backend/app/services/job_store_sqlite.py` + +```python +"""SQLite-backed JobStore — delegates to jobs_repo for actual SQL. + +Uses async_sessionmaker for per-operation sessions so we get clean +transactions and no connection-pool starvation. +""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import UUID + +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.models.job import Job +from app.repositories import jobs as jobs_repo +from app.services.job_store import JobStore + + +class SQLiteJobStore(JobStore): + """SQLite-backed JobStore implementation.""" + + def __init__(self, session_factory: async_sessionmaker) -> None: + self._session_factory = session_factory + + async def save_queued(self, job: Job) -> None: + async with self._session_factory() as session: + session.add(job) + await session.commit() + await session.refresh(job) + + async def get(self, job_id: UUID) -> Job | None: + async with self._session_factory() as session: + return await jobs_repo.get(session, job_id) + + async def mark_printing(self, job_id: UUID) -> None: + async with self._session_factory() as session: + await jobs_repo.mark_printing(session, job_id) + + async def mark_done(self, job_id: UUID) -> None: + async with self._session_factory() as session: + await jobs_repo.mark_done(session, job_id, result={}) + + async def mark_failed(self, job_id: UUID, error: str) -> None: + async with self._session_factory() as session: + await jobs_repo.mark_failed(session, job_id, error) + + async def mark_interrupted(self, printer_id: UUID) -> int: + async with self._session_factory() as session: + return await jobs_repo.mark_printing_as_failed_restart( + session, printer_id, + ) + + async def list_pending(self, printer_id: UUID) -> list[Job]: + async with self._session_factory() as session: + return await jobs_repo.list_active(session, printer_id=printer_id) + + async def evict_terminal_older_than(self, age: timedelta) -> int: + async with self._session_factory() as session: + return await jobs_repo.evict_terminal_older_than(session, age) +``` + +- [ ] **Step 4: Add async_session_factory fixture to root conftest (OBLIGATORISCH — R2-C2+M8)** + +**NICHT** in `tests/unit/services/conftest.py` eintragen! Die Fixture muss in +`backend/tests/conftest.py` (Root-Level) definiert werden, damit sie sowohl von +Unit-Tests (Task 3) als auch von Integration-Tests (Task 6 `test_print_queue_recovery.py`) +sichtbar ist. pytest-conftest-Sichtbarkeit: eine conftest.py gilt nur für Tests im gleichen +Verzeichnis und darunter. + +Die bestehende `tests/integration/conftest.py` hat eine `db_session`-Fixture (einzelne +AsyncSession), aber **keine** `async_sessionmaker`-Fixture namens `async_session_factory`. +Die `tests/unit/services/conftest.py` hat ebenfalls keine solche Fixture. + +Füge folgendes in `backend/tests/conftest.py` ein (nach den bestehenden `pytest_addoption`/ +`pytest_collection_modifyitems` Funktionen): + +```python +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +@pytest_asyncio.fixture +async def async_session_factory(tmp_path): + """Async sessionmaker-Fixture für SQLiteJobStore Tests. + + Erzeugt eine per-Test SQLite-DB (temp file, nicht :memory: für + Task-Isolation). Sichtbar für alle Tests unterhalb von tests/. + """ + db_path = tmp_path / "job_store_test.db" + url = f"sqlite+aiosqlite:///{db_path}" + engine = create_async_engine(url, echo=False, connect_args={"check_same_thread": False}) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + yield factory + await engine.dispose() +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/unit/services/test_job_store_sqlite.py -v +``` +Expected: 7 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/job_store_sqlite.py backend/tests/unit/services/test_job_store_sqlite.py backend/tests/unit/services/conftest.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(services): SQLiteJobStore delegiert an jobs_repo (Phase 2) + +Per-operation Sessions via async_sessionmaker. Implementiert das +gleiche JobStore Protocol wie MemoryJobStore — Protocol-Conformance +durch isinstance-Check verifiziert. + +Refs #93" +``` + +--- + +## Task 4: PrintQueue Refactor — store dependency + worker-saves + +**Files:** +- Modify: `backend/app/services/print_queue.py:140-180` (Konstruktor + start()) +- Modify: `backend/app/services/print_queue.py:516-560` (Worker-Loop) +- Test: `backend/tests/unit/services/test_print_queue_persistence.py` (neu) + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/unit/services/test_print_queue_persistence.py` + +```python +"""PrintQueue must call store.mark_* on every state transition.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store import MemoryJobStore +from app.services.print_queue import PrintQueue + + +@pytest.mark.asyncio +async def test_printqueue_constructor_accepts_store(mock_backend_factory, event_bus): + """PrintQueue requires a JobStore in constructor.""" + store = MemoryJobStore() + queue = PrintQueue( + printer_ids=[uuid4()], + backend_factory=mock_backend_factory, + bus=event_bus, + store=store, + ) + assert queue._store is store + + +@pytest.mark.asyncio +async def test_printqueue_calls_mark_printing_then_mark_done( + mock_backend_factory, event_bus, sample_image, +): + """Worker must call store.mark_printing then store.mark_done on success.""" + store = AsyncMock(spec=MemoryJobStore) + printer_id = uuid4() + queue = PrintQueue( + printer_ids=[printer_id], + backend_factory=mock_backend_factory, + bus=event_bus, + store=store, + ) + await queue.start() + job_id = await queue.submit(printer_id, sample_image, tape_mm=12) + await queue.wait_for_job(job_id) + await queue.stop() + + store.mark_printing.assert_awaited_once_with(job_id) + store.mark_done.assert_awaited_once_with(job_id) + store.mark_failed.assert_not_awaited() +``` + +Helper-Fixtures `mock_backend_factory`, `event_bus`, `sample_image` existieren bereits in `tests/unit/services/conftest.py` (siehe bestehende `print_queue` tests). Falls nicht: anlegen analog zu bestehenden Tests. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/unit/services/test_print_queue_persistence.py -v +``` +Expected: FAIL `TypeError: __init__() got an unexpected keyword argument 'store'`. + +- [ ] **Step 3: Add `store` parameter to PrintQueue.__init__** + +In `backend/app/services/print_queue.py` finde den `__init__`. Aktuelle Signatur enthaelt `bus`. Erweitere um `store`: + +```python +def __init__( + self, + printer_ids: list[UUID], + backend_factory: Callable[[UUID], PrinterBackend], + bus: EventBus, + store: JobStore, # NEU +) -> None: + self._store = store + # ... rest unveraendert ... +``` + +Import oben in `print_queue.py`: +```python +from app.services.job_store import JobStore +``` + +- [ ] **Step 4: Add store.mark_printing/mark_done/mark_failed calls in worker (R2-C3)** + +**KEIN** vereinfachter Pseudocode-Skelett — verwende die echte Datei-Struktur. +Zeilennummern beziehen sich auf `backend/app/services/print_queue.py` (Stand Branch-Basis): + +**Insert 1 — nach Zeile ~513 (`if job.state != JobState.QUEUED: continue`):** +Zwischen Skip-Check und `try:`-Block, direkt VOR `_from = job.state`: + +```python + # Phase 2: Skip-Check MUSS vor mark_printing bleiben (R2-C3, Spec R1-C3) + if job.state != JobState.QUEUED: + continue + + # Phase 2: DB-State QUEUED->PRINTING persistieren (bridge: dataclass.id ist str) + await self._store.mark_printing(job.id) + + try: + _from = job.state + JobStateMachine.transition(job, JobState.PRINTING) +``` + +**Insert 2 — PRINTING->COMPLETED (nach `JobStateMachine.transition(job, JobState.COMPLETED)`, ca. Zeile 534):** + +```python + JobStateMachine.transition(job, JobState.COMPLETED) + await self._store.mark_done(job.id) # Phase 2: DB-State persistieren + self._notify_state_change( +``` + +**Insert 3 — PrinterError Handler (nach `JobStateMachine.transition(job, JobState.FAILED)`, ca. Zeile 553):** + +```python + except InvalidStateTransitionError: + logger.warning(...) + # Phase 2: DB-State persistieren + await self._store.mark_failed(job.id, f"{code}: {msg}") + logger.exception("Job %s failed on %s (printer error)", ...) +``` + +**Insert 4 — Generic Exception Handler (nach `JobStateMachine.transition(job, JobState.FAILED)`, ca. Zeile 577):** + +```python + except InvalidStateTransitionError: + logger.warning(...) + # Phase 2: DB-State persistieren + await self._store.mark_failed(job.id, str(exc)) + logger.exception("Job %s failed on %s", ...) +``` + +**Bewahre vollständig:** +- SNMP-Preflight Checks (`job.tape_mm is None`, `job.image_payload is None`) +- Pause-Logik (`while self._worker_states[...] == PrinterWorkerState.PAUSED`) +- Differenzierte PrinterError vs. Exception Handler inkl. `_RECOVERABLE_PRINTER_ERRORS` pause_printer-Call +- `asyncio.CancelledError` re-raise +- `_notify_state_change` Calls an allen Transitionen + +Kein `queue.task_done()` — der reale Worker ruft das nicht auf. + +- [ ] **Step 5: Run existing print_queue tests — adapt SOFORT nach Konstruktor-Änderung (R2-M1)** + +**Reihenfolge (R2-M1):** Bestehende Tests adapten DIREKT NACH Step 3, BEVOR neuer Test grün gemacht wird. +Nach Step 3 schlägt die Suite mit `TypeError: __init__() got unexpected keyword argument` fehl — +das muss zuerst behoben werden. + +Run: +```bash +cd backend && pytest tests/unit/services/ -k print_queue -v +``` +Adapte alle bestehenden `PrintQueue(...)` Aufrufe in Tests indem `store=MemoryJobStore()` ergänzt wird. +Nutze `grep -r "PrintQueue(" tests/` um alle Stellen zu finden. + +Expected danach: bestehende Tests wieder grün. + +- [ ] **Step 6: Run new unit test to verify it passes** + +Run: +```bash +cd backend && pytest tests/unit/services/test_print_queue_persistence.py -v +``` +Expected: 2 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/services/print_queue.py backend/tests/unit/services/ +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(queue): PrintQueue ruft JobStore bei jeder State-Transition (Phase 2) + +QUEUED -> PRINTING -> DONE/FAILED wird synchron in der DB persistiert. +Konstruktor bekommt store: JobStore als neuen Pflicht-Parameter. + +Refs #93" +``` + +--- + +## Task 5: PrintService bekommt JobStore + create_queued vor queue.submit + +**Files:** +- Modify: `backend/app/services/print_service.py:67-110` +- Test: `backend/tests/integration/test_print_service_persistence.py` (neu) + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/integration/test_print_service_persistence.py` + +```python +"""PrintService must persist Job-row BEFORE handing off to PrintQueue.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from app.models.job import JobState +from app.schemas.print_request import PrintRequest + + +@pytest.mark.asyncio +async def test_submit_persists_queued_job_before_queue( + print_service, sqlite_store, sample_request, +): + """After submit_print_job, the job must exist in DB with state=QUEUED.""" + job_id = await print_service.submit_print_job(sample_request) + persisted = await sqlite_store.get(job_id) + assert persisted is not None + assert persisted.state == JobState.QUEUED.value + assert persisted.template_key == sample_request.template_id + assert persisted.printer_id == print_service._printer_id +``` + +(Helper fixtures `print_service`, `sqlite_store`, `sample_request` definieren in dieser Test-Datei oder conftest.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/integration/test_print_service_persistence.py -v +``` +Expected: FAIL — entweder `AttributeError: PrintService has no attribute '_store'` oder `assert persisted is None`. + +- [ ] **Step 3: Refactor PrintService** + +In `backend/app/services/print_service.py` finde `__init__`. Ergaenze `store: JobStore` Parameter: + +```python +def __init__( + self, + printer_id: UUID, + queue: PrintQueue, + renderer: LabelRenderer, + loader: TemplateLoader, + backend: PrinterBackend, + store: JobStore, # NEU +) -> None: + self._printer_id = printer_id + self._queue = queue + self._renderer = renderer + self._loader = loader + self._backend = backend + self._store = store # NEU +``` + +Imports oben: +```python +from app.models.job import Job +from app.services.job_store import JobStore +``` + +In `submit_print_job` ersetze die letzte Zeile (`return await self._queue.submit(...)`) durch: + +```python +# Render bleibt unveraendert ... +label_data = await self._resolve_label_data(request) +image = self._renderer.render(template, label_data) + +# Phase 2: Persist BEFORE queue.submit +# R2-M3: PrintRequest (app/schemas/print_request.py) hat KEINE api_key_id/source_ip Felder. +# Diese kommen aus AuthContext (auth.api_key_id, auth.ip) — der Endpoint-Layer muss +# submit_print_job(request, auth_context) erweitern oder PrintService bekommt auth_context. +# Bis zur Klärung: None setzen, KEIN hasattr-Workaround. +db_job = Job( + printer_id=self._printer_id, + template_key=request.template_id, + payload={ + "label_data": label_data.model_dump(), + "tape_mm": template.tape_mm, + "options": { + "auto_cut": request.options.auto_cut, + "high_resolution": request.options.high_resolution, + }, + }, + api_key_id=None, # TODO: aus AuthContext übergeben wenn Endpoint-Layer angepasst + source_ip=None, # TODO: aus AuthContext übergeben wenn Endpoint-Layer angepasst +) +await self._store.save_queued(db_job) + +# Hand off to queue with the DB id +await self._queue.submit_with_id( + db_job.id, + self._printer_id, + image, + tape_mm=template.tape_mm, + auto_cut=request.options.auto_cut, + high_resolution=request.options.high_resolution, +) +return db_job.id +``` + +Analog `on_tape_mismatch="queue"` Pfad: `submit_paused_with_id` aufrufen mit `db_job.id` und vorher `store.save_queued`. + +- [ ] **Step 4: Add `submit_with_id` to PrintQueue** + +In `backend/app/services/print_queue.py` ergaenze nach existierendem `submit`: + +```python +async def submit_with_id( + self, + job_id: UUID, + printer_id: UUID, + image: Image.Image, + tape_mm: int, + **options: Any, +) -> str: + """Phase 2: like submit(), but uses an externally-generated job_id. + + Used by PrintService which creates the DB-row first (via store.save_queued) + and passes the resulting id here. + """ + if printer_id not in self._queues: + raise KeyError(f"Unknown printer: {printer_id}") + payload = await asyncio.to_thread(_serialize_image_to_png, image) + job = Job( # dataclass + id=str(job_id), + printer_id=printer_id, + image_payload=payload, + tape_mm=tape_mm, + options=dict(options), + ) + self._jobs[str(job_id)] = job # ... oder bei Phase-2-Refactor: nur Queue.put + await self._queues[printer_id].put(job) + return str(job_id) +``` + +Analog `submit_paused_with_id` falls noetig. + +- [ ] **Step 5: Update PrintService instantiation in lifespan** + +In `backend/app/main.py` finde wo `PrintService` instantiiert wird. Ergaenze `store=job_store` (wird in Task 7 vollstaendig gewired — hier nur Parameter durchreichen). + +- [ ] **Step 6: Run integration test to verify it passes** + +Run: +```bash +cd backend && pytest tests/integration/test_print_service_persistence.py -v +``` +Expected: PASS. + +- [ ] **Step 7: Run existing PrintService tests — adapt** + +Run: +```bash +cd backend && pytest tests/integration/ -k print_service -v +``` +Adapte bestehende `PrintService(...)` constructor-Aufrufe (test-fixtures) um `store=MemoryJobStore()` zu uebergeben. + +Expected: alle gruen. + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/services/print_service.py backend/app/services/print_queue.py backend/tests/integration/test_print_service_persistence.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(service): PrintService persistiert Job-Row vor queue.submit (Phase 2) + +Neue submit_with_id() in PrintQueue erlaubt extern-generierte job_id. +PrintService legt erst die DB-Row an (store.save_queued) und reicht die +id durch — Hand-off ist atomisch und persistiert. + +Refs #93" +``` + +--- + +## Task 6: PrintQueue Recovery in start() + +**Files:** +- Modify: `backend/app/services/print_queue.py` — `start()` method +- Test: `backend/tests/integration/test_print_queue_recovery.py` (neu) + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/integration/test_print_queue_recovery.py` + +```python +"""PrintQueue.start() must recover pending jobs from DB.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from app.models.job import Job, JobState +from app.services.job_store_sqlite import SQLiteJobStore +from app.services.print_queue import PrintQueue + + +@pytest.mark.asyncio +async def test_start_marks_printing_as_failed_restart( + async_session_factory, mock_backend_factory, event_bus, +): + """Jobs in PRINTING before start() must be marked FAILED_RESTART.""" + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + # Pre-seed: a job in PRINTING state + interrupted_job = Job( + printer_id=printer_id, template_key="t", payload={}, + state=JobState.PRINTING.value, + ) + await store.save_queued(interrupted_job) + # Force PRINTING state manually (since save_queued always sets QUEUED) + async with async_session_factory() as s: + from sqlalchemy import update # R2-M4: update kommt aus sqlalchemy, NICHT sqlmodel + from sqlmodel import col + await s.execute( + update(Job).where(col(Job.id) == interrupted_job.id).values( + state=JobState.PRINTING.value, + ) + ) + await s.commit() + + queue = PrintQueue( + printer_ids=[printer_id], + backend_factory=mock_backend_factory, + bus=event_bus, + store=store, + ) + await queue.start() + + fetched = await store.get(interrupted_job.id) + assert fetched.state == JobState.FAILED_RESTART.value + assert fetched.error == "printer_interrupted" + + await queue.stop() + + +@pytest.mark.asyncio +async def test_start_reenqueues_queued_jobs_in_fifo_order( + async_session_factory, mock_backend_factory, event_bus, +): + """Jobs in QUEUED state must be re-enqueued in created_at order.""" + store = SQLiteJobStore(async_session_factory) + printer_id = uuid4() + + j1 = Job(printer_id=printer_id, template_key="t", payload={"o": 1}) + j2 = Job(printer_id=printer_id, template_key="t", payload={"o": 2}) + await store.save_queued(j1) + await store.save_queued(j2) + + queue = PrintQueue( + printer_ids=[printer_id], + backend_factory=mock_backend_factory, + bus=event_bus, + store=store, + ) + await queue.start() + + # asyncio.Queue order + recovered_ids = [] + while not queue._queues[printer_id].empty(): + recovered_ids.append((await queue._queues[printer_id].get()).id) + + assert recovered_ids == [str(j1.id), str(j2.id)] + await queue.stop() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/integration/test_print_queue_recovery.py -v +``` +Expected: FAIL — start() does not call recovery yet, FAILED_RESTART assertion fails. + +- [ ] **Step 3: Add recovery to PrintQueue.start()** + +In `backend/app/services/print_queue.py` modifiziere `start()`: + +```python +async def start(self) -> None: + if self._running: + return + + # Phase 2 Recovery: mark interrupted PRINTING jobs + reenqueue QUEUED + for printer_id in self._queues: + interrupted = await self._store.mark_interrupted(printer_id) + if interrupted > 0: + logger.warning( + "Recovery: %d printing jobs on printer %s marked as interrupted", + interrupted, printer_id, + ) + pending_db_jobs = await self._store.list_pending(printer_id) + for db_job in pending_db_jobs: + if db_job.state == JobState.QUEUED.value: + # Rerender image from template_key + payload + image = await self._rerender_from_db_job(db_job) + payload_bytes = await asyncio.to_thread(_serialize_image_to_png, image) + wrapper = Job( # dataclass + id=str(db_job.id), + printer_id=db_job.printer_id, + image_payload=payload_bytes, + tape_mm=db_job.payload.get("tape_mm"), + options=db_job.payload.get("options", {}), + ) + await self._queues[printer_id].put(wrapper) + logger.info("Recovery: re-enqueued QUEUED job %s on %s", + db_job.id, printer_id) + + # Original Worker-Spawn + for printer_id in self._queues: + self._workers[printer_id] = asyncio.create_task( + self._worker(printer_id), name=f"printer-worker-{printer_id}" + ) + self._running = True + + +async def _rerender_from_db_job(self, db_job) -> Image.Image: + """Phase 2: rerender label image from persisted template_key + payload. + + Called during start() recovery. Requires renderer + loader to be wired + via PrintQueue constructor — see lifespan. + """ + if self._renderer is None or self._loader is None: + raise RuntimeError( + "PrintQueue recovery requires renderer + loader (pass via constructor)" + ) + template = self._loader.get(db_job.template_key) + # R2-C4: payload["label_data"] ist ein rohes dict (model_dump()). + # renderer.render() erwartet ein LabelData-Objekt — KEIN dict. + from app.schemas.label_data import LabelData + label_data = LabelData.model_validate(db_job.payload["label_data"]) + return self._renderer.render(template, label_data) +``` + +PrintQueue.__init__ erweitern um `renderer` und `loader` als Optional: + +```python +def __init__( + self, + printer_ids: list[UUID], + backend_factory: ..., + bus: ..., + store: JobStore, + renderer: LabelRenderer | None = None, # NEU, optional + loader: TemplateLoader | None = None, # NEU, optional +) -> None: + # ... + self._renderer = renderer + self._loader = loader +``` + +- [ ] **Step 4: Run recovery tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/integration/test_print_queue_recovery.py -v +``` +Expected: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/print_queue.py backend/tests/integration/test_print_queue_recovery.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(queue): PrintQueue.start() Recovery (Phase 2) + +mark_interrupted(printer_id) markiert verloren-gegangene PRINTING jobs +als FAILED_RESTART. QUEUED jobs werden aus template_key+payload neu +gerendert und in FIFO-Reihenfolge re-enqueued. + +Refs #93" +``` + +--- + +## Task 7: CleanupTask + +**Files:** +- Create: `backend/app/services/cleanup_task.py` +- Test: `backend/tests/unit/services/test_cleanup_task.py` + +- [ ] **Step 1: Write the failing test** + +Datei: `backend/tests/unit/services/test_cleanup_task.py` + +```python +"""CleanupTask runs evict_terminal_older_than periodically.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest +from app.services.cleanup_task import CleanupTask + + +@pytest.mark.asyncio +async def test_cleanup_validates_retention_days(): + store = AsyncMock() + with pytest.raises(ValueError, match="retention_days must be >= 1"): + CleanupTask(store=store, retention_days=0) + + +@pytest.mark.asyncio +async def test_cleanup_initial_run_on_start(): + store = AsyncMock() + store.evict_terminal_older_than.return_value = 3 + task = CleanupTask(store=store, retention_days=30, interval=timedelta(seconds=99)) + await task.start() + # stop() wartet intern auf den laufenden Task — nach stop() ist der + # erste evict_terminal_older_than-Call garantiert erfolgt. + await task.stop(timeout_s=1.0) + + store.evict_terminal_older_than.assert_awaited() + args, _ = store.evict_terminal_older_than.call_args + assert args[0] == timedelta(days=30) + + +@pytest.mark.asyncio +async def test_cleanup_fail_soft_on_exception(): + store = AsyncMock() + store.evict_terminal_older_than.side_effect = RuntimeError("boom") + task = CleanupTask(store=store, retention_days=30, interval=timedelta(seconds=99)) + await task.start() + await task.stop(timeout_s=1.0) + # No exception propagated; loop survives +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/unit/services/test_cleanup_task.py -v +``` +Expected: FAIL `ModuleNotFoundError`. + +- [ ] **Step 3: Create CleanupTask** + +Datei: `backend/app/services/cleanup_task.py` + +```python +"""Phase 2: periodic background task to delete terminal jobs older than retention_days.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import timedelta + +from app.services.job_store import JobStore + +logger = logging.getLogger(__name__) + +_DEFAULT_INTERVAL = timedelta(hours=24) + + +class CleanupTask: + """Background asyncio task that periodically calls + store.evict_terminal_older_than(retention). + """ + + def __init__( + self, + store: JobStore, + retention_days: int, + interval: timedelta = _DEFAULT_INTERVAL, + ) -> None: + if retention_days < 1: + raise ValueError("retention_days must be >= 1") + self._store = store + self._retention = timedelta(days=retention_days) + self._interval = interval + self._task: asyncio.Task[None] | None = None + self._stopping = asyncio.Event() + + async def start(self) -> None: + if self._task is not None: + return + self._task = asyncio.create_task(self._loop(), name="job-cleanup") + + async def stop(self, timeout_s: float = 5.0) -> None: + self._stopping.set() + if self._task is not None: + try: + await asyncio.wait_for(self._task, timeout=timeout_s) + except asyncio.TimeoutError: + self._task.cancel() + logger.warning("CleanupTask did not stop in %ss, cancelled", timeout_s) + self._task = None + + async def _loop(self) -> None: + await self._run_once() + while not self._stopping.is_set(): + try: + await asyncio.wait_for( + self._stopping.wait(), + timeout=self._interval.total_seconds(), + ) + except asyncio.TimeoutError: + await self._run_once() + + async def _run_once(self) -> None: + try: + deleted = await self._store.evict_terminal_older_than(self._retention) + if deleted > 0: + logger.info( + "CleanupTask: deleted %d terminal jobs older than %d days", + deleted, self._retention.days, + ) + except Exception: + logger.exception("CleanupTask: run_once failed") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/unit/services/test_cleanup_task.py -v +``` +Expected: 3 PASS. + +- [ ] **Step 5: Add config field** + +In `backend/app/config.py` ergaenze in `class Settings`: + +```python +job_retention_days: int = Field( + default=30, + ge=1, + description="Terminal Jobs (DONE/FAILED/FAILED_RESTART/CANCELLED) werden nach diesem Zeitraum vom CleanupTask geloescht", +) +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/cleanup_task.py backend/app/config.py backend/tests/unit/services/test_cleanup_task.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(services): CleanupTask + PRINTER_HUB_JOB_RETENTION_DAYS config (Phase 2) + +Background asyncio task laeuft initial bei start() und dann alle 24h, +ruft store.evict_terminal_older_than(retention). Fail-soft bei +DB-Errors — Loop survives. + +Refs #93" +``` + +--- + +## Task 8: GET /api/batches/{batch_id} Snapshot Endpoint + +**Files:** +- Create: `backend/app/schemas/batch_read.py` +- Create: `backend/app/api/routes/batches.py` +- Modify: `backend/app/repositories/jobs.py` (add `list_by_ids`) +- Modify: `backend/app/main.py` (register batches router) +- Test: `backend/tests/integration/test_batch_snapshot_endpoint.py` + +- [ ] **Step 1: Write the failing test (R2-C6)** + +Datei: `backend/tests/integration/test_batch_snapshot_endpoint.py` + +**Wichtig (R2-C6):** Fixtures `auth_client`, `sqlite_store`, `sample_batch_done`, `batch_with_ghost_ids` +existieren NICHT in der integration conftest. Stattdessen: +- `auth_client` → **`client`** (existiert in `tests/integration/conftest.py:222`) +- `sample_batch_done` und `batch_with_ghost_ids` müssen **explizit in dieser Datei** definiert werden + +```python +"""GET /api/batches/{id} liefert Snapshot mit Jobs + Summary.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.models.job import Job, JobState +from app.models.print_batch import PrintBatch +from app.repositories import jobs as jobs_repo + + +# --- Fixtures (R2-C6: explizit definiert, nicht aus nicht-existenter conftest) --- + +@pytest_asyncio.fixture +async def sample_batch_done(db_session): + """2 DONE-Jobs + PrintBatch in der Test-DB.""" + printer_id = uuid4() + j1 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + j2 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j1.id) + await jobs_repo.mark_done(db_session, j1.id, result={}) + await jobs_repo.mark_printing(db_session, j2.id) + await jobs_repo.mark_done(db_session, j2.id, result={}) + + batch = PrintBatch( + printer_id=printer_id, + job_ids=[str(j1.id), str(j2.id)], + created_by="test@example.com", + ) + db_session.add(batch) + await db_session.commit() + await db_session.refresh(batch) + return batch + + +@pytest_asyncio.fixture +async def batch_with_ghost_ids(db_session): + """PrintBatch mit 3 job_ids, davon 2 nach Anlage gelöscht (Geister-IDs).""" + printer_id = uuid4() + j1 = await jobs_repo.create_queued( + db_session, printer_id=printer_id, template_key="t", payload={} + ) + await jobs_repo.mark_printing(db_session, j1.id) + await jobs_repo.mark_done(db_session, j1.id, result={}) + + ghost_id_1 = str(uuid4()) # nie in DB eingetragen + ghost_id_2 = str(uuid4()) # nie in DB eingetragen + + batch = PrintBatch( + printer_id=printer_id, + job_ids=[str(j1.id), ghost_id_1, ghost_id_2], + created_by="test@example.com", + ) + db_session.add(batch) + await db_session.commit() + await db_session.refresh(batch) + return batch + + +# --- Tests --- + +@pytest.mark.asyncio +async def test_get_batch_returns_404_for_unknown(client): + # client kommt aus tests/integration/conftest.py:222 (fake-auth, kein eigenes auth_client) + resp = await client.get(f"/api/batches/{uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_batch_returns_summary_with_all_terminal( + client, sample_batch_done, +): + resp = await client.get(f"/api/batches/{sample_batch_done.id}") + assert resp.status_code == 200 + body = resp.json() + assert body["id"] == str(sample_batch_done.id) + assert body["summary"]["total"] == 2 + assert body["summary"]["done"] == 2 + assert body["summary"]["queued"] == 0 + assert body["summary"]["all_terminal"] is True + assert len(body["jobs"]) == 2 + + +@pytest.mark.asyncio +async def test_get_batch_jobs_in_batch_order( + client, sample_batch_done, +): + """Job-Reihenfolge im Response entspricht batch.job_ids Array, nicht DB-default.""" + resp = await client.get(f"/api/batches/{sample_batch_done.id}") + body = resp.json() + received_ids = [j["id"] for j in body["jobs"]] + expected_ids = [str(jid) for jid in sample_batch_done.job_ids] + assert received_ids == expected_ids + + +@pytest.mark.asyncio +async def test_get_batch_handles_missing_jobs(client, batch_with_ghost_ids): + """Wenn Jobs vom Cleanup geloescht sind (Geister-IDs), werden sie uebersprungen.""" + resp = await client.get(f"/api/batches/{batch_with_ghost_ids.id}") + body = resp.json() + assert body["summary"]["total"] < len(batch_with_ghost_ids.job_ids) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cd backend && pytest tests/integration/test_batch_snapshot_endpoint.py -v +``` +Expected: FAIL `404 from FastAPI` weil Route nicht existiert. + +- [ ] **Step 3: Add `list_by_ids` to jobs_repo** + +In `backend/app/repositories/jobs.py` anhaengen: + +```python +async def list_by_ids( + session: AsyncSession, + job_ids: list[UUID], +) -> list[Job]: + """Bulk-Fetch jobs by ids — order not guaranteed, caller re-orders.""" + if not job_ids: + return [] + result = await session.execute( + select(Job).where(col(Job.id).in_(job_ids)) + ) + return list(result.scalars()) +``` + +- [ ] **Step 4: Create BatchRead schema** + +Datei: `backend/app/schemas/batch_read.py` + +```python +"""Phase 2: BatchRead Schema fuer GET /api/batches/{id}.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, computed_field + +from app.schemas.job import JobRead + + +class BatchSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + total: int + queued: int + printing: int + done: int + failed: int + + @computed_field + @property + def all_terminal(self) -> bool: + """True if no jobs are in queued or printing state. + + Hangar's Result-Page uses this to skip opening an SSE-connection + when there's nothing live to update. + """ + return (self.queued + self.printing) == 0 + + +class BatchRead(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: UUID + printer_id: UUID + created_by: str | None # R2-C5: PrintBatch.created_by ist str (SSO-Email oder API-Key-ID), kein UUID + created_at: datetime + jobs: list[JobRead] + summary: BatchSummary +``` + +- [ ] **Step 5: Create batches route** + +Datei: `backend/app/api/routes/batches.py` + +```python +"""Phase 2: GET /api/batches/{id} — Snapshot fuer Hangar Result-Page Initial-Render.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_read +from app.db.session import get_session +from app.models.job import JobState +from app.repositories import jobs as jobs_repo +from app.repositories import print_batches as batches_repo +from app.schemas.batch_read import BatchRead, BatchSummary +from app.schemas.job import JobRead + +router = APIRouter(prefix="/api/batches", tags=["batches"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +ReadAuthDep = Annotated[AuthContext, Depends(require_read)] + + +@router.get("/{batch_id}", response_model=BatchRead) +async def get_batch( + batch_id: UUID, + session: SessionDep, + auth: ReadAuthDep, +) -> BatchRead: + """Snapshot of a batch + all its current job states. + + Used by Hangar's /admin/print/result/{batch_id} for the initial render. + summary.all_terminal == false means Hangar should open an SSE stream + to /api/events?batch_id=... for live updates. + """ + batch = await batches_repo.get(session, batch_id) + if batch is None: + raise HTTPException(status_code=404, detail="Batch not found") + + fetched_jobs = await jobs_repo.list_by_ids(session, list(batch.job_ids)) + job_map = {j.id: j for j in fetched_jobs} + + # Order matches batch.job_ids; missing (cleanup-evicted) jobs are skipped + ordered = [job_map[jid] for jid in batch.job_ids if jid in job_map] + + summary = BatchSummary( + total=len(ordered), + queued=sum(1 for j in ordered if j.state == JobState.QUEUED.value), + printing=sum(1 for j in ordered if j.state == JobState.PRINTING.value), + done=sum(1 for j in ordered if j.state == JobState.DONE.value), + failed=sum( + 1 for j in ordered + if j.state in (JobState.FAILED.value, JobState.FAILED_RESTART.value) + ), + ) + + return BatchRead( + id=batch.id, + printer_id=batch.printer_id, + created_by=batch.created_by, + created_at=batch.created_at, + jobs=[JobRead.model_validate(j) for j in ordered], + summary=summary, + ) +``` + +- [ ] **Step 6: Register router in app** + +In `backend/app/main.py` finde wo andere Router registriert werden (`app.include_router(...)`). Ergaenze: + +```python +from app.api.routes import batches as batches_routes +app.include_router(batches_routes.router) +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: +```bash +cd backend && pytest tests/integration/test_batch_snapshot_endpoint.py -v +``` +Expected: 4 PASS. + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/repositories/jobs.py backend/app/schemas/batch_read.py backend/app/api/routes/batches.py backend/app/main.py backend/tests/integration/test_batch_snapshot_endpoint.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(api): GET /api/batches/{id} Snapshot-Endpoint (Phase 2) + +Liefert Batch-Metadaten + alle Job-States. summary.all_terminal sagt +Hangar ob noch ein SSE-Stream geoeffnet werden muss. Job-Reihenfolge +entspricht batch.job_ids; cleanup-geister werden uebersprungen. + +Entblockt strausmann/hangar#81. + +Refs #93" +``` + +--- + +## Task 9: Lifespan-Wiring + +**Files:** +- Modify: `backend/app/main.py` — `lifespan` context + +- [ ] **Step 1: Wire JobStore + CleanupTask + PrintQueue in lifespan (R2-M5)** + +**Konkrete Änderungen in `backend/app/main.py`.** + +Lese die Datei ZUERST um die realen Zeilennummern zu verifizieren (Stand 2026-05-31 ca. Zeile 304-344). + +Reale Variable aus `app/db/engine.py` importiert: `async_session` (eine `async_sessionmaker`). +**NICHT** `async_session_factory` — diese Variable existiert nicht im Lifespan (R2-M5). + +Imports oben in `main.py` ergänzen: +```python +from app.services.job_store_sqlite import SQLiteJobStore +from app.services.cleanup_task import CleanupTask +``` + +Änderungen im Lifespan (Insertion NACH `recover_inflight_jobs` entfernen, BEFORE queue-Setup, +ca. um Zeile 273 nach dem `async with async_session() as s:` Block): + +```python + # Phase 2: recover_inflight_jobs() ENTFERNT (Spec R1-C1) — PrintQueue.start() übernimmt + + # Phase 2: JobStore + CleanupTask + # 'async_session' ist die async_sessionmaker aus app.db.engine (R2-M5 — NICHT async_session_factory) + job_store = SQLiteJobStore(async_session) + + cleanup_task = CleanupTask( + store=job_store, + retention_days=settings.job_retention_days, + ) + await cleanup_task.start() + app.state.cleanup_task = cleanup_task +``` + +PrintQueue-Instantiierung (echte Signatur — R2-M5): +```python + # Reale PrintQueue-Signatur: PrintQueue(printers=[printer], on_state_change=...) + # KEIN printer_ids / backend_factory — die Signatur existiert nicht + queue = PrintQueue( + printers=[printer], # existiert bereits + on_state_change=pq_producer.handle_transition, # existiert bereits + store=job_store, # NEU + renderer=shared_renderer, # NEU — für Recovery + loader=TemplateLoader, # NEU — für Recovery + ) + await queue.start() # ruft intern mark_interrupted + list_pending +``` + +PrintService-Instantiierung (echte Parameter-Namen aus `print_service.py`): +```python + app.state.print_service = PrintService( + template_loader=TemplateLoader, # bestehend + renderer=shared_renderer, # bestehend + print_queue=queue, # bestehend + lookup_service=AppLookupService(), # bestehend + printer_id=printer.id, # bestehend + backend=backend, # bestehend + store=job_store, # NEU + ) +``` + +Shutdown-Block (vor `queue.stop()`): +```python + finally: + if status_producer is not None: + await status_producer.stop() + await cleanup_task.stop() # NEU — CleanupTask vor Queue stoppen + await queue.stop(timeout_s=settings.printer_queue_timeout_s) + await engine.dispose() + ... +``` + +- [ ] **Step 2: Run full test suite — verify no regressions** + +Run: +```bash +cd backend && pytest -q +``` +Expected: alle gruen, inkl. der 6 neuen Test-Dateien. Erwartung ~860 tests passed. + +Falls integration tests brechen weil sie `print_service` ohne `store` instantiieren: adapt diese tests indem sie `store=MemoryJobStore()` oder `store=SQLiteJobStore(...)` mitgeben. + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/main.py backend/tests +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(lifespan): wire JobStore + CleanupTask in app startup (Phase 2) + +SQLiteJobStore aus async_session_factory; CleanupTask laeuft initial +beim Start + alle 24h. PrintQueue + PrintService bekommen store via +DI fuer Recovery und Persistierung. + +Refs #93" +``` + +--- + +## Task 10: Manuelle Verification + Push + PR + +- [ ] **Step 1: Lokaler Smoke gegen Mock-Backend** + +```bash +cd backend && PRINTER_HUB_PRINTER_BACKEND=mock uv run uvicorn app.main:app --reload --port 8001 +``` + +In zweitem Terminal: +```bash +# Mock-Drucker existiert nach erstem Lifespan-Run +sqlite3 /tmp/printer-hub-test.db "SELECT id, slug FROM printers" +# Submit batch +curl -s -X POST -H "X-Label-Hub-Key: ..." -H "Content-Type: application/json" \ + -d '{"items":[{"template_id":"hangar-furniture-24mm","data":{"title":"x","primary_id":"x","qr_payload":"q"}}]}' \ + http://localhost:8001/api/print//batch +# Antwort: {"batch_id": "...", "job_ids": ["..."]} + +# Snapshot +curl -s -H "X-Label-Hub-Key: ..." http://localhost:8001/api/batches/ | jq +# erwartet: summary mit total=1, done=1, all_terminal=true + +# DB check +sqlite3 /tmp/printer-hub-test.db "SELECT id, state, finished_at FROM jobs ORDER BY created_at DESC LIMIT 3" +``` + +Expected: Job-Row existiert, state=done, finished_at gesetzt. + +- [ ] **Step 2: Restart-Recovery Smoke** + +In Server-Terminal: `Ctrl-C`. Submit Batch mit 3 items in einer Schleife. Mitten drin: Server-Terminal `Ctrl-C`. Neu starten: + +```bash +cd backend && PRINTER_HUB_PRINTER_BACKEND=mock uv run uvicorn app.main:app --reload --port 8001 +``` + +In Logs erwartet: +``` +Recovery: 1 printing jobs on printer ... marked as interrupted +Recovery: re-enqueued QUEUED job ... on ... +``` + +- [ ] **Step 3: Mypy + ruff** + +```bash +cd backend && uv run mypy app && uv run ruff check app && uv run ruff format --check app +``` +Expected: alles gruen. Falls Findings: fixen und committen. + +- [ ] **Step 4: Push** + +```bash +git push origin feat/phase-2-job-persistence +``` + +- [ ] **Step 5: PR erstellen** + +```bash +gh pr create --title "feat: Phase 2 Job Persistence (DB-backed + Restart Recovery + Cleanup)" \ + --body "$(cat <<'EOF' +## Was + +Hub Phase 2 — Jobs werden jetzt in der `jobs`-Tabelle persistiert. + +- `JobStore` Protocol mit `MemoryJobStore` und `SQLiteJobStore` +- `PrintQueue` ruft `store.mark_*` bei jeder State-Transition synchron +- `PrintService` legt erst die DB-Row an, dann queue.submit (atomisch) +- `PrintQueue.start()` Recovery: PRINTING → FAILED_RESTART, QUEUED → re-enqueued via Rerender +- `CleanupTask` raeumt terminal Jobs aelter als `PRINTER_HUB_JOB_RETENTION_DAYS` (Default 30) +- `GET /api/batches/{id}` Snapshot-Endpoint mit `summary.all_terminal` + +## Tests + +- 5 neue Unit-Tests fuer `MemoryJobStore` Protocol-Conformance +- 7 neue Unit-Tests fuer `SQLiteJobStore` gegen echte SQLite +- 3 neue Unit-Tests fuer `CleanupTask` (validate, initial-run, fail-soft) +- 3 neue Repository-Tests +- 1 Integration-Test fuer `PrintService.submit` Persistierung +- 2 Integration-Tests fuer `PrintQueue.start()` Recovery +- 4 Integration-Tests fuer `GET /api/batches/{id}` + +Bestehende ~831 Tests bleiben gruen. + +## Spec & Plan + +- Spec: `docs/superpowers/specs/2026-05-31-phase-2-job-persistence-design.md` +- Plan: `docs/superpowers/plans/2026-05-31-phase-2-job-persistence.md` + +## Bezug + +Closes #93. Entblockt strausmann/hangar#81 (Result-Page Live-Updates). +EOF +)" \ + --base main +``` + +- [ ] **Step 6: Watch CI** + +Pipeline auf GitHub: warten bis alle Checks gruen. Bei Failures: fixen, committen, push. + +--- + +## Self-Review + +### Spec Coverage + +| Spec-Section | Plan-Task | Status | +|--------------|-----------|--------| +| JobStore Protocol | Task 2 | gedeckt | +| SQLiteJobStore | Task 3 | gedeckt | +| MemoryJobStore | Task 2 | gedeckt | +| PrintQueue Refactor (constructor + worker saves) | Task 4 | gedeckt | +| PrintService persist before queue.submit | Task 5 | gedeckt | +| Worker-Restart-Recovery in start() | Task 6 | gedeckt | +| GET /api/batches/{id} Snapshot | Task 8 | gedeckt | +| BatchSummary.all_terminal | Task 8 | gedeckt | +| CleanupTask | Task 7 | gedeckt | +| Config: PRINTER_HUB_JOB_RETENTION_DAYS | Task 7 | gedeckt | +| Lifespan-Integration | Task 9 | gedeckt | +| Repository-Helper neu | Task 1 | gedeckt | +| Errata 1 (zwei Job-Klassen) | Task 4/5 (dataclass bleibt) | gedeckt | +| Errata 2 (Wiederverwendung jobs_repo) | Task 3 | gedeckt | +| Errata 3 (FAILED_RESTART + error field) | Task 1/3 | gedeckt | +| Errata 4 (PrintService bekommt store) | Task 5 | gedeckt | + +Edge-Cases aus Spec: + +| Edge-Case | Test | +|-----------|------| +| Recovery: PAUSED stays PAUSED | Spec sagt PAUSED bleibt — Task 6 nutzt `list_pending` der QUEUED+PRINTING zurueckgibt (PAUSED ist nicht in `_NON_TERMINAL`) — implizit gedeckt | +| Recovery: DONE unchanged | `list_pending` returnt nicht DONE — implizit gedeckt | +| Cleanup keeps recent terminal | Task 1 evict-Test, Task 7 | +| Cleanup skips non-terminal | Task 1 evict-Test asserts non-terminal not deleted | +| Concurrent state transitions | Per-operation Sessions in Task 3 — Race-Test optional ergaenzbar | +| Batch with missing jobs (ghost ids) | Task 8 test_get_batch_handles_missing_jobs | + +### Placeholder Scan + +Keine TODO/TBD/FIXME im Plan. Code-Snippets sind vollstaendig. Commit-Messages sind konkret. + +### Type Consistency + +- `JobStore.mark_interrupted(printer_id: UUID) -> int` — konsistent in Memory + SQLite + Tests +- `mark_printing_as_failed_restart` (repo) vs `mark_interrupted` (store) — bewusste Differenz: repo ist generisch, store ist semantisch +- `JobState.FAILED_RESTART` durchgaengig (nicht FAILED) fuer interrupted recovery +- `error="printer_interrupted"` durchgaengig (nicht error_code) — matched DB-Schema mit `error: str | None` + +Plan ist konsistent zur Spec inkl. Errata. Ready for execution. diff --git a/docs/superpowers/specs/2026-05-31-phase-2-job-persistence-design.md b/docs/superpowers/specs/2026-05-31-phase-2-job-persistence-design.md new file mode 100644 index 0000000..767a60e --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-phase-2-job-persistence-design.md @@ -0,0 +1,873 @@ +# Phase 2 — Job Persistence Design + +**Status:** Draft +**Datum:** 2026-05-31 +**Issue:** [strausmann/Label-Printer-Hub#93](https://github.com/strausmann/Label-Printer-Hub/issues/93) +**Bezug:** Entblockt strausmann/hangar#81 (Result-Page Live-Updates) +**Spec Author:** Orchestrator (Brainstorming-Session 26 Continuation) + +--- + +## Motivation + +Phase 1 (PR #92) lieferte einen Batch-Endpoint der physisch erfolgreich druckt. Trotzdem zeigt die Hangar-Result-Page nach erfolgreichem Druck dauerhaft "ausstehend" — `GET /api/jobs/{id}` liefert 404, die `jobs`-Tabelle bleibt leer obwohl Etiketten phyisch rausgekommen sind. + +Ursache: `PrintQueue._jobs` ist ein In-Memory `dict[UUID, Job]` ohne DB-Persistierung. Der Header-Comment in `app/services/print_queue.py` plante dieses Refactor bereits explizit: + +> Jobs live in-memory (MVP). Phase 5 will add SQLite persistence behind a JobStore protocol that this module will accept by dependency injection. + +Folgen des aktuellen Designs: + +- `GET /api/jobs/{id}` und `GET /api/batches/{id}` liefern keinen Stand +- Container-Restart loescht alle Jobs (Audit-Trail unmoeglich) +- SSE-Stream-Reconnect kann nichts nachholen +- Hangar's Result-Page bleibt auf Initial-Render-Default "ausstehend" + +## Ziele + +1. **Persistenz** — Job-State `queued → printing → done/failed` wird in der `jobs`-Tabelle persistiert, jede Transition synchron geschrieben +2. **Restart-Recovery** — beim Hub-Restart werden `PRINTING`-Jobs als `FAILED` mit `error_code=printer_interrupted` markiert; `QUEUED`-Jobs werden in FIFO-Reihenfolge erneut in die Worker-Queue gelegt +3. **Snapshot-Endpoint** — neuer `GET /api/batches/{batch_id}` liefert kompletten aktuellen Stand aller Jobs einer Batch, inklusive `summary.all_terminal` damit Clients entscheiden ob ein SSE-Stream noch Sinn macht +4. **Retention** — automatisches Cleanup der `jobs`-Tabelle: terminal Jobs (`done`, `failed`, `cancelled`) aelter als ein konfigurierbares Fenster (Default 30 Tage) werden taeglich vom `CleanupTask` geloescht + +## Nicht-Ziele + +- **Hangar Phase 1d Implementation** — eigener MR im Hangar-Repo nach Hub-Merge (hangar#81) +- **Worker resume von PRINTING-Jobs** — explizit als `FAILED` markiert, kein Auto-Retry (User-Entscheidung Q3) +- **Per-Drucker-Konfigurierbarkeit** der Recovery-Strategie — alle Drucker nutzen gleichen `JobStore` +- **Multi-Process-Support** — `JobStore` ist async-safe fuer SQLite-WAL, aber Single-Process. Multi-Worker via gunicorn nicht in Scope +- **Schema-Migrationen fuer Job-Modell** — `jobs`-Tabelle existiert bereits in Migration `b2668b6e8845`, kein neuer Alembic-Revision noetig +- **SSE-Event-Replay** — User-Entscheidung Q5 fuer Snapshot-GET + Live-SSE; Hub haelt keine Event-Historie + +## Architektur + +```mermaid +graph TB + API["POST /api/print/{slug}/batch"] --> Dispatcher[batch_dispatch.py] + Dispatcher --> PrintService[PrintService.submit_print_job] + PrintService --> Queue[PrintQueue] + Queue --> JobStore[JobStore Protocol] + JobStore --> SQLiteStore[SQLiteJobStore] + JobStore -.->|tests| MemStore[MemoryJobStore] + SQLiteStore --> JobsTable[(jobs Table)] + Queue --> Worker[Async Worker per Printer] + Worker --> JobStore + Worker --> Bus[EventBus] + Bus --> SSE["GET /api/events"] + + BatchAPI["GET /api/batches/{id}"] --> BatchRepo[print_batches_repo] + BatchAPI --> JobsRepo[jobs_repo] + JobsRepo --> JobsTable + + Cleanup[CleanupTask] --> JobStore + + style JobStore fill:#fff3e0 + style JobsTable fill:#e0f7fa +``` + +**Boundaries:** + +- `PrintQueue` kennt nur das `JobStore`-Interface (keine SQLAlchemy-Imports) +- `JobStore` ist ein `Protocol` mit `save`, `get`, `list_pending`, `evict_terminal_older_than`, `mark_interrupted` +- `SQLiteJobStore` implementiert `JobStore` und schreibt direkt mit `AsyncSession` (kein `jobs_repo`, eigene Layer-Semantik) +- `MemoryJobStore` ist die Test- und Migration-Phase Implementation; gleiches Protocol +- `GET /api/batches/{id}` ist ein neuer Endpoint der `print_batches` + alle zugehoerigen Jobs joined +- `CleanupTask` ist ein Background `asyncio.Task` der `evict_terminal_older_than(retention_days)` periodisch aufruft + +## Komponenten + +### JobStore Protocol + +Datei: `backend/app/services/job_store.py` (neu). + +```python +from typing import Protocol, runtime_checkable +from uuid import UUID +from datetime import timedelta +from app.models.job import Job + + +@runtime_checkable +class JobStore(Protocol): + """Persistente Backing-Store fuer Jobs.""" + + async def save(self, job: Job) -> None: + """Persistiert den aktuellen Job-State (upsert nach id). + + Wird bei jeder State-Transition aufgerufen: + QUEUED -> PRINTING -> DONE/FAILED. Synchron im Worker-Loop. + """ + + async def get(self, job_id: UUID) -> Job | None: + """Laedt einen Job aus dem Store. None wenn nicht vorhanden.""" + + async def list_pending(self, printer_id: UUID | None = None) -> list[Job]: + """Alle Jobs in nicht-terminal States (QUEUED, PRINTING, PAUSED). + + Wird beim Worker-Start aufgerufen fuer Restart-Recovery. + Optional gefiltert nach Drucker. Order: created_at (FIFO). + """ + + async def mark_interrupted(self, printer_id: UUID) -> int: + """Recovery-Helper: setzt alle PRINTING-Jobs des Druckers auf + FAILED mit error_code=printer_interrupted. + + Wird in PrintQueue.start() VOR list_pending aufgerufen. + Returns: Anzahl Jobs die als interrupted markiert wurden. + """ + + async def evict_terminal_older_than(self, age: timedelta) -> int: + """Loescht terminal Jobs (DONE, FAILED, CANCELLED) aelter als age. + + Returns: Anzahl geloeschter Rows. + """ +``` + +**Design-Entscheidungen:** + +- `@runtime_checkable` erlaubt `isinstance(obj, JobStore)` Checks in Tests +- Alle Methoden async fuer Konsistenz mit dem Rest des Codes +- Keine Streaming/Pagination in `list_pending` — wir erwarten <100 pending Jobs im Normalfall +- `mark_interrupted` als separater Step erlaubt sauberen Recovery-Ablauf + +### SQLiteJobStore + +Datei: `backend/app/services/job_store_sqlite.py` (neu). + +```python +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlmodel import delete, select, update + +from app.models.job import Job, JobState +from app.services.job_store import JobStore + +_NON_TERMINAL = {JobState.QUEUED, JobState.PRINTING, JobState.PAUSED} +_TERMINAL = {JobState.DONE, JobState.FAILED, JobState.CANCELLED} + + +class SQLiteJobStore(JobStore): + """SQLite-Backed JobStore via SQLModel/SQLAlchemy AsyncSession.""" + + def __init__(self, session_factory: async_sessionmaker) -> None: + self._session_factory = session_factory + + async def save(self, job: Job) -> None: + async with self._session_factory() as session: + await session.merge(job) + await session.commit() + + async def get(self, job_id: UUID) -> Job | None: + async with self._session_factory() as session: + result = await session.execute(select(Job).where(Job.id == job_id)) + return result.scalar_one_or_none() + + async def list_pending(self, printer_id: UUID | None = None) -> list[Job]: + async with self._session_factory() as session: + stmt = select(Job).where(Job.state.in_(_NON_TERMINAL)) + if printer_id is not None: + stmt = stmt.where(Job.printer_id == printer_id) + stmt = stmt.order_by(Job.created_at) + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def mark_interrupted(self, printer_id: UUID) -> int: + async with self._session_factory() as session: + stmt = ( + update(Job) + .where(Job.printer_id == printer_id, Job.state == JobState.PRINTING) + .values( + state=JobState.FAILED, + error_code="printer_interrupted", + error_message="Hub restarted while printing — printer state unclear", + finished_at=datetime.now(timezone.utc), + ) + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount or 0 + + async def evict_terminal_older_than(self, age: timedelta) -> int: + cutoff = datetime.now(timezone.utc) - age + async with self._session_factory() as session: + stmt = ( + delete(Job) + .where(Job.state.in_(_TERMINAL)) + .where(Job.finished_at < cutoff) + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount or 0 +``` + +**Design-Entscheidungen:** + +- `session.merge()` macht implizit Upsert via Primary Key — kein manuelles `get-then-update-or-insert` +- `session_factory` injected statt globaler Session — testbar, kein Singleton-Lock-in +- Eigene Session pro Operation — kurze Transaktionen, kein Connection-Pool-Starvation +- `order_by(created_at)` in `list_pending` — FIFO bleibt erhalten beim Restart-Recovery +- `mark_interrupted` setzt `finished_at` — sonst wuerden interrupted-Jobs nie vom Cleanup-Task erfasst +- Repository-Layer wird **nicht** genutzt — `jobs_repo` hat andere Patterns (Pydantic, Pagination); JobStore ist naeher am SQL + +### MemoryJobStore + +Datei: `backend/app/services/job_store_memory.py` (neu). + +```python +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from app.models.job import Job, JobState +from app.services.job_store import JobStore + +_NON_TERMINAL = {JobState.QUEUED, JobState.PRINTING, JobState.PAUSED} +_TERMINAL = {JobState.DONE, JobState.FAILED, JobState.CANCELLED} + + +class MemoryJobStore(JobStore): + """In-Memory JobStore fuer Tests und Migration-Phase.""" + + def __init__(self) -> None: + self._jobs: dict[UUID, Job] = {} + + async def save(self, job: Job) -> None: + self._jobs[job.id] = job + + async def get(self, job_id: UUID) -> Job | None: + return self._jobs.get(job_id) + + async def list_pending(self, printer_id: UUID | None = None) -> list[Job]: + items = [j for j in self._jobs.values() if j.state in _NON_TERMINAL] + if printer_id is not None: + items = [j for j in items if j.printer_id == printer_id] + return sorted(items, key=lambda j: j.created_at) + + async def mark_interrupted(self, printer_id: UUID) -> int: + count = 0 + for job in self._jobs.values(): + if job.printer_id == printer_id and job.state == JobState.PRINTING: + job.state = JobState.FAILED + job.error_code = "printer_interrupted" + job.error_message = "Hub restarted while printing" + job.finished_at = datetime.now(timezone.utc) + count += 1 + return count + + async def evict_terminal_older_than(self, age: timedelta) -> int: + cutoff = datetime.now(timezone.utc) - age + to_delete = [ + jid for jid, j in self._jobs.items() + if j.state in _TERMINAL and j.finished_at is not None and j.finished_at < cutoff + ] + for jid in to_delete: + del self._jobs[jid] + return len(to_delete) +``` + +### PrintQueue Refactor + +Aenderungen in `backend/app/services/print_queue.py`: + +1. **Konstruktor:** neuer Parameter `store: JobStore`, `renderer: LabelRenderer | None`, `loader: TemplateLoader | None` +2. **`_jobs` dict bleibt** — In-Memory-Store fuer laufende Dataclass-Jobs. **WICHTIG (R1-C2):** `_jobs` kann NICHT entfernt werden, weil es das `asyncio.Event` und `image_payload` (bytes) der Dataclass-Jobs hält — beides existiert nicht im SQLModel-DB-Job. Nach DONE/FAILED/CANCELLED wird der Eintrag aus `_jobs` bereinigt. `JobStore` ist *parallel* Source of Truth fuer den DB-State. +3. **`start()`** ruft VOR Worker-Spawn `mark_interrupted` und `list_pending` pro Printer; Recovery-Jobs werden sowohl in `_jobs` als auch in die asyncio.Queue eingetragen. +4. **`submit_with_id(job_id, ...)`** — neues öffentliches API: PrintService übergibt die extern erstellte DB-UUID; Dataclass-Job-Wrapper wird in `_jobs` eingetragen. +5. **`_worker()`** ruft `await self._store.mark_printing/mark_done/mark_failed` bei jeder State-Transition. +6. **`stop()`** ruft nach Dataclass-Transition auch `await self._store.mark_failed(job.id, "shutdown")` für PRINTING-Jobs (R1-M5). + +**Errata C1 — `recover_inflight_jobs` aus Lifespan entfernen (R1-C1):** + +Der bestehende Aufruf `recover_inflight_jobs()` in `db/lifespan.py` (Lifespan-Step 4 in `main.py:273`) markiert **alle** `QUEUED` und `PRINTING`-Jobs als `FAILED_RESTART` bevor die Queue startet. Das kollidiert direkt mit der Phase-2-Recovery in `PrintQueue.start()`, die QUEUED-Jobs re-enqueued: + +- Ohne Änderung: `start()` findet keine QUEUED-Jobs mehr (alle wurden bereits auf FAILED_RESTART gesetzt), die Re-Enqueue-Logik ist de facto tot. +- **Fix:** `recover_inflight_jobs()` aus dem Lifespan-Startup entfernen (oder zu einem No-Op machen). `PrintQueue.start()` übernimmt vollständig mit korrekter QUEUED/PRINTING-Trennung. + +**Pseudocode des refactored Worker:** + +```python +async def _worker(self, printer_id: UUID) -> None: + queue = self._queues[printer_id] + while not self._stopping: + job = await queue.get() + + JobStateMachine.transition(job, JobState.PRINTING) + await self._store.save(job) # NEU + self._bus.publish(...) + + try: + await self._backend.print(job.image_payload, ...) + JobStateMachine.transition(job, JobState.DONE) + await self._store.save(job) # NEU + self._bus.publish(...) + except PrinterError as exc: + JobStateMachine.transition(job, JobState.FAILED) + job.error_code = exc.code + job.error_message = str(exc) + await self._store.save(job) # NEU + self._bus.publish(...) + + queue.task_done() +``` + +**Recovery in `start()`:** + +```python +async def start(self) -> None: + if self._running: + return + + # Phase-2 Recovery + for printer_id in self._queues: + interrupted = await self._store.mark_interrupted(printer_id) + if interrupted > 0: + logger.warning( + "Recovery: %d printing jobs on printer %s marked as interrupted", + interrupted, printer_id, + ) + pending = await self._store.list_pending(printer_id=printer_id) + for job in pending: + if job.state == JobState.QUEUED: + await self._queues[printer_id].put(job) + logger.info("Recovery: re-enqueued QUEUED job %s on %s", + job.id, printer_id) + elif job.state == JobState.PAUSED: + logger.info("Recovery: PAUSED job %s found, awaiting resume", + job.id) + + # Original Worker-Spawn + for printer_id in self._queues: + self._workers[printer_id] = asyncio.create_task( + self._worker(printer_id), name=f"printer-worker-{printer_id}" + ) + self._running = True +``` + +### GET /api/batches/{batch_id} Endpoint + +Datei: `backend/app/api/routes/batches.py` (neu, oder in `batch.py` ergaenzt). + +```python +# router = APIRouter(prefix="/api/batches") — kein Doppel-/api in der Route-Dekorator-URL (R1-m4) +@router.get("/{batch_id}", response_model=BatchRead) +async def get_batch( + batch_id: UUID, + session: SessionDep, + auth: ReadAuthDep, +) -> BatchRead: + """Liefert Batch-Metadaten + aktuellen State aller zugehoerigen Jobs. + + Quelle fuer Hangar's Result-Page Initial-Render — wird VOR dem SSE-Connect + aufgerufen damit der User sofort den aktuellen Stand sieht. + """ + batch = await batches_repo.get(session, batch_id) + if batch is None: + raise HTTPException(404, detail="Batch not found") + + jobs = await jobs_repo.list_by_ids(session, batch.job_ids) + + # Order entspricht batch.job_ids damit Items in Reihenfolge angezeigt werden + job_map = {j.id: j for j in jobs} + ordered_jobs = [job_map[jid] for jid in batch.job_ids if jid in job_map] + + return BatchRead( + id=batch.id, + printer_id=batch.printer_id, + created_by=batch.created_by, + created_at=batch.created_at, + jobs=[JobRead.model_validate(j) for j in ordered_jobs], + summary=BatchSummary( + total=len(ordered_jobs), + queued=sum(1 for j in ordered_jobs if j.state == JobState.QUEUED.value), + printing=sum(1 for j in ordered_jobs if j.state == JobState.PRINTING.value), + done=sum(1 for j in ordered_jobs if j.state == JobState.DONE.value), + # R1-M3: FAILED_RESTART zaehlt ebenfalls als "fehlgeschlagen" fuer den User + failed=sum( + 1 for j in ordered_jobs + if j.state in (JobState.FAILED.value, JobState.FAILED_RESTART.value) + ), + ), + ) +``` + +**Pydantic-Schemas** (`backend/app/schemas/batch.py`): + +```python +class BatchSummary(BaseModel): + total: int + queued: int + printing: int + done: int + failed: int + + @computed_field + @property + def all_terminal(self) -> bool: + return (self.queued + self.printing) == 0 + + +class BatchRead(BaseModel): + id: UUID + printer_id: UUID + created_by: str | None # PrintBatch.created_by ist str (SSO-Email oder API-Key-ID), kein UUID (R1-M1) + created_at: datetime + jobs: list[JobRead] + summary: BatchSummary +``` + +**Repository-Erweiterung** (`backend/app/repositories/jobs.py`): + +```python +async def list_by_ids( + session: AsyncSession, + job_ids: list[str | UUID], # R1-M2: batch.job_ids ist list[str], Caller übergibt strings oder UUIDs +) -> list[Job]: + """Bulk-Fetch — Order ist nicht garantiert, Caller muss neu ordnen.""" + if not job_ids: + return [] + # Normalisierung: str-IDs zu UUID konvertieren fuer den IN-Vergleich mit UUID-Spalte + ids_as_uuid = [UUID(str(jid)) if not isinstance(jid, UUID) else jid for jid in job_ids] + result = await session.execute(select(Job).where(col(Job.id).in_(ids_as_uuid))) + return list(result.scalars().all()) +``` + +### CleanupTask + +Datei: `backend/app/services/cleanup_task.py` (neu). + +```python +import asyncio +import logging +from datetime import timedelta + +from app.services.job_store import JobStore + +logger = logging.getLogger(__name__) + +_CLEANUP_INTERVAL = timedelta(hours=24) + + +class CleanupTask: + """Background-Task der periodisch terminal Jobs aelter als retention_days loescht.""" + + def __init__( + self, + store: JobStore, + retention_days: int, + interval: timedelta = _CLEANUP_INTERVAL, + ) -> None: + if retention_days < 1: + raise ValueError("retention_days must be >= 1") + self._store = store + self._retention = timedelta(days=retention_days) + self._interval = interval + self._task: asyncio.Task[None] | None = None + self._stopping = asyncio.Event() + + async def start(self) -> None: + if self._task is not None: + return + self._task = asyncio.create_task(self._loop(), name="job-cleanup") + + async def stop(self, timeout_s: float = 5.0) -> None: + self._stopping.set() + if self._task is not None: + try: + await asyncio.wait_for(self._task, timeout=timeout_s) + except asyncio.TimeoutError: + self._task.cancel() + logger.warning("CleanupTask did not stop in %ss, cancelled", timeout_s) + self._task = None + + async def _loop(self) -> None: + await self._run_once() # initial run on start + while not self._stopping.is_set(): + try: + await asyncio.wait_for( + self._stopping.wait(), + timeout=self._interval.total_seconds(), + ) + except asyncio.TimeoutError: + await self._run_once() + + async def _run_once(self) -> None: + try: + deleted = await self._store.evict_terminal_older_than(self._retention) + if deleted > 0: + logger.info( + "CleanupTask: deleted %d terminal jobs older than %d days", + deleted, self._retention.days, + ) + except Exception: + logger.exception("CleanupTask: run_once failed") +``` + +**Config-Erweiterung** (`backend/app/config.py`): + +```python +class Settings(BaseSettings): + # Existing fields... + + job_retention_days: int = Field( + default=30, + ge=1, + description="Terminal Jobs werden nach diesem Zeitraum vom CleanupTask geloescht", + ) +``` + +Env-Var: `PRINTER_HUB_JOB_RETENTION_DAYS=30` (via BaseSettings prefix). + +### Lifespan Integration + +`backend/app/main.py` `lifespan`: + +```python +@asynccontextmanager +async def lifespan(app: FastAPI): + # ... existing setup (DB-Engine, EventBus) ... + + # Phase 2 NEU + job_store = SQLiteJobStore(session_factory=async_sessionmaker(engine)) + cleanup = CleanupTask( + store=job_store, + retention_days=settings.job_retention_days, + ) + await cleanup.start() + app.state.cleanup_task = cleanup + + queue = PrintQueue( + printer_ids=..., + backend_factory=..., + bus=event_bus, + store=job_store, # NEU + ) + await queue.start() # ruft intern mark_interrupted + list_pending + + yield + + await cleanup.stop() + await queue.stop() + await event_bus.aclose() +``` + +## State-Machine + +```mermaid +stateDiagram-v2 + [*] --> QUEUED: submit() + QUEUED --> PRINTING: Worker pickt auf + PRINTING --> DONE: Druck erfolgreich + PRINTING --> FAILED: Druck-Fehler + QUEUED --> PAUSED: pause() + PAUSED --> QUEUED: resume() + QUEUED --> CANCELLED: cancel() + + PRINTING --> FAILED: ⚡Hub-Restart\n(error_code=printer_interrupted) + QUEUED --> QUEUED: ⚡Hub-Restart\n(re-enqueued, FIFO preserved) + PAUSED --> PAUSED: ⚡Hub-Restart\n(bleibt PAUSED, User muss resume) + + DONE --> [*]: 30 Tage später vom CleanupTask geloescht + FAILED --> [*]: 30 Tage später vom CleanupTask geloescht + CANCELLED --> [*]: 30 Tage später vom CleanupTask geloescht +``` + +## Hangar Result-Page Datenfluss + +```mermaid +sequenceDiagram + participant Browser + participant Hangar as Hangar /admin/print/result + participant Hub as Hub /api/batches/{id} + participant DB as jobs + print_batches + participant Bus as EventBus + + Browser->>Hangar: GET Result-Page + Hangar->>Hub: GET /api/batches/{id} + Hub->>DB: SELECT batch + JOIN jobs + DB-->>Hub: Batch + Jobs (current states) + Hub-->>Hangar: BatchRead + summary + Hangar-->>Browser: HTML mit Initial-State + + alt summary.all_terminal == false + Browser->>Hangar: EventSource /admin/print/events/{batch_id} + Hangar->>Hub: GET /api/events?batch_id=... + Bus-->>Hub: live job.state_changed events + Hub-->>Hangar: SSE-Stream + Hangar-->>Browser: SSE Fragment-Updates (HTMX swap) + else summary.all_terminal == true + Note over Browser,Bus: Kein SSE — End-State wird angezeigt + end +``` + +## Edge-Cases + +| Szenario | Verhalten | +|----------|-----------| +| Hub-Crash zwischen `save(QUEUED)` und Worker-Pickup | Recovery findet Job in DB -> re-enqueue -> Worker bearbeitet weiter | +| Hub-Crash mitten in `_backend.print()` | Job ist PRINTING in DB -> Recovery markiert als FAILED `printer_interrupted` | +| Hub-Crash zwischen Print-Success und `save(DONE)` | Schlimmster Fall — Drucker hat gedruckt, DB sagt PRINTING. Recovery markiert FAILED. User sieht doppelten Druck wenn er retryed. **Trade-off akzeptiert** (User-Entscheidung Q3) | +| Hub-Crash zwischen `save(DONE)` und `bus.publish` | DB ist konsistent, nur live-SSE-Listener hat das Event verpasst. Beim Reconnect kommt Snapshot-GET mit DONE | +| Worker findet Job mit unknown state in DB | Soll nicht passieren (State-Machine hat alle States definiert); logger.error + skip | +| Batch hat job_ids wo manche schon vom Cleanup geloescht | `list_by_ids` ueberspringt fehlende; `ordered_jobs` kann kleiner sein als `batch.job_ids` | + +## Cleanup-Strategie + +| Tabelle | Cleanup? | +|---------|----------| +| `jobs` | Ja, terminal-States >= retention_days | +| `print_batches` | Nein, Audit-Trail. Bei Job-Delete bleiben `job_ids` Referenzen in Batches als Geister-IDs | +| `printers`, `templates`, `api_keys`, `tape` | Nein, Stamm-Daten | +| `printer_state`, `printer_status_cache` | Nein, aktuelles State | + +**Trade-off Batch-Geister-IDs:** wenn alte Jobs geloescht aber Batch-Row noch da, liefert `GET /api/batches/{id}` leere `jobs[]`. Akzeptiert weil nach 30 Tagen sowieso niemand die Result-Page oeffnet. + +## Test-Plan (TDD-Pflicht) + +**Unit-Tests** (`backend/tests/unit/services/`): + +| Datei | Was wird getestet | +|-------|-------------------| +| `test_job_store_protocol.py` | `MemoryJobStore` Round-Trip (save -> get), `list_pending` Filter, `mark_interrupted` Idempotenz, `evict_terminal_older_than` Cutoff-Boundary | +| `test_job_store_sqlite.py` | `SQLiteJobStore` mit echter SQLite-temp-DB, alle Methoden, gleiche Asserts wie `MemoryJobStore` (Protocol-Conformance) | +| `test_cleanup_task.py` | Start/Stop, Initial-Run, periodisches Aufrufen via mocked sleep, fail-soft bei Exception | + +**Integration-Tests** (`backend/tests/integration/`): + +| Datei | Was wird getestet | +|-------|-------------------| +| `test_print_queue_persistence.py` | Job-Lifecycle persist: submit -> DB hat QUEUED, worker pickt auf -> DB hat PRINTING, print fertig -> DB hat DONE. Jeder Transition-Save via DB-Query verifiziert | +| `test_print_queue_recovery.py` | PrintQueue-Restart-Szenario: Setup mit Jobs in QUEUED + PRINTING + PAUSED States, neue PrintQueue mit gleichem JobStore startet, prueft `mark_interrupted` + re-enqueue Verhalten | +| `test_batch_snapshot_endpoint.py` | `GET /api/batches/{id}` mit verschiedenen Job-States, summary computation, `all_terminal` field, Job-Reihenfolge entspricht `batch.job_ids` | + +**Edge-Case-Tests:** + +| Test | Szenario | +|------|----------| +| `test_recovery_paused_stays_paused` | PAUSED-Jobs werden beim Recovery NICHT re-enqueued | +| `test_recovery_done_unchanged` | DONE-Jobs bleiben in DB unveraendert, kommen nicht in Queue | +| `test_cleanup_keeps_recent_terminal` | DONE-Job 29 Tage alt bleibt, 31 Tage alt wird geloescht | +| `test_cleanup_skips_non_terminal` | QUEUED/PRINTING-Jobs werden nie geloescht, egal wie alt | +| `test_save_concurrent_state_transitions` | Race: zwei gleichzeitige `save()` fuer unterschiedliche Jobs blocken sich nicht (eigene Sessions) | +| `test_get_batch_with_missing_jobs` | Batch mit job_ids wo manche schon vom Cleanup geloescht sind -> `ordered_jobs` ueberspringt geister-IDs | + +**Erwartung:** ~25 neue Tests insgesamt. Bestehende 831 Tests laufen weiter gruen (kein Breaking-Change). + +## Migration (Deploy) + +Schema-Aenderung: **keine**. `jobs`-Tabelle existiert bereits in Migration `b2668b6e8845`. + +**Deploy-Checkliste:** + +1. **Pre-Deploy:** Mit `GET /api/jobs?state=printing` pruefen ob aktive Jobs laufen. Falls ja: 30s warten oder User informieren. +2. **Deploy:** Container restart via Dockhand `down/start` (CleanupTask + neue PrintQueue starten automatisch). +3. **Post-Deploy:** Logs pruefen auf `Recovery: re-enqueued QUEUED job ...` — sollte 0 sein bei erstem Deploy (keine alten Jobs in DB). +4. **Smoke:** SMOKE-001 + SMOKE-002 erneut, plus expliziter Crash-Recovery-Test: + - Batch mit 5 Items submitten + - Waehrend Job 2 druckt: Container hart restarten (`docker kill -s SIGKILL`) + - Erwartet: Job 1 = DONE, Job 2 = FAILED (printer_interrupted), Jobs 3-5 = QUEUED -> Worker prozesst weiter +5. **Hangar Result-Page:** verifizieren dass States in Echtzeit + nach Page-Reload korrekt angezeigt werden (per `GET /api/batches/{id}`). + +## Backward-Compatibility + +| Was | Status | +|-----|--------| +| `POST /api/print/{slug}/batch` | Unveraendert (gibt weiterhin `batch_id` + `job_ids` zurueck) | +| `GET /api/jobs` + `GET /api/jobs/{id}` | Unveraendert (waren schon DB-basiert) | +| `GET /api/events` (SSE) | Unveraendert | +| `GET /api/batches/{id}` | **Neu** — kein Breaking-Change | +| `PrintQueue` interne API | Breaking (neuer `store` Parameter) — aber nur interner Consumer ist `lifespan` selbst | +| Env-Var `PRINTER_HUB_JOB_RETENTION_DAYS` | **Neu**, Default 30, kein User-Action noetig | + +## Referenzen + +- Issue: [strausmann/Label-Printer-Hub#93](https://github.com/strausmann/Label-Printer-Hub/issues/93) +- Bezug: strausmann/hangar#81 (Hangar Result-Page entblockt) +- Phase 1 Spec: `docs/superpowers/specs/2026-05-30-hub-batch-endpoint-design.md` (hangar repo) +- Brainstorming-Q&A: HomeLab Session 26 (Memory `briefing_session26.md`) +- Code-Comment-Vorbereitung: `backend/app/services/print_queue.py:7-9` (Phase-5-Marker) +- Existing Migration: `b2668b6e8845_phase_7c_api_keys.py` + +## Offene Fragen + +Keine — alle Design-Entscheidungen sind durch Brainstorming-Q&A (Sessions 26) beantwortet: + +- Q1 Scope: Alles drei (Persist + Recovery + Snapshot) +- Q2 PRINTING-Recovery: FAILED mit `printer_interrupted` +- Q3 Write-Granularitaet: Jede Transition synchron +- Q4 Retention: Konfigurierbar, Default 30 Tage +- Q5 SSE-Replay: Snapshot-GET + Live-SSE (kein Replay im Hub) +- Q6 Approach: JobStore-Protocol mit DI + +--- + +## Errata (Stand 2026-05-31, post-spec) + +Beim Plan-Schreiben sind drei Codebase-Eigenheiten aufgefallen, die Spec-Snippets ueberschreiben. Plan und Implementation folgen dieser Errata, nicht den frueheren Code-Snippets. + +### Erratum 1 — Zwei Job-Klassen + zwei JobState-Enums + +Die Codebase hat **getrennt**: + +| Klasse | Typ | Felder | Zweck | +|--------|-----|--------|-------| +| `app/services/job_lifecycle.py:Job` | `@dataclass` | `image_payload: bytes`, `tape_mm`, `options`, `state`, `error_message` | In-Memory Lifecycle in PrintQueue | +| `app/models/job.py:Job` | `SQLModel(table=True)` | `template_key`, `payload: dict`, `state: str`, `error: str \| None`, `api_key_id`, `source_ip` | DB-Tabelle `jobs` | + +Plus zwei `JobState`-Enums mit unterschiedlichen Werten: +- `job_lifecycle.JobState`: `QUEUED`, `PAUSED`, `PRINTING`, `COMPLETED`, `FAILED`, `CANCELLED` +- `models/job.JobState`: `QUEUED`, `PRINTING`, `DONE`, `FAILED`, `CANCELLED`, `FAILED_RESTART` + +**Konsequenz fuer Phase 2:** JobStore arbeitet auf der **SQLModel-Klasse** (`app/models/job.py:Job`). Die Dataclass `Job` aus `job_lifecycle` bleibt In-Memory-Wrapper im Worker fuer den aktuellen Print-Vorgang. Beide referenzieren sich ueber `job.id`. + +Recovery rerendert das Bild aus `template_key + payload` (kein BLOB in DB). + +### Erratum 2 — Bestehende `jobs_repo` Funktionen wiederverwenden + +Das Repository hat bereits viele State-Transition-Helper. `SQLiteJobStore` nutzt diese statt eigene Queries zu bauen: + +| JobStore Methode | Existing repo function | Neu noetig? | +|------------------|------------------------|-------------| +| `save_queued(...)` | `jobs.create_queued(session, ...)` | Nein | +| `mark_printing(id)` | `jobs.mark_printing(session, id)` | Nein | +| `mark_done(id)` | `jobs.mark_done(session, id, result)` | Nein | +| `mark_failed(id, error)` | `jobs.mark_failed(session, id, error)` | Nein | +| `mark_interrupted(printer_id)` | `jobs.mark_inflight_as_failed_restart(session)` aber **ohne** QUEUED-Filter | **Ja**, neuer Helper `jobs.mark_printing_as_failed_restart(session, printer_id)` der NUR PRINTING affected (nicht QUEUED — die werden re-enqueued) | +| `list_pending(printer_id)` | `jobs.list_active(session)` liefert nur QUEUED+PRINTING, nicht PAUSED | **Ja**, neue eigene Funktion `jobs.list_pending(session, printer_id)` die QUEUED+PRINTING+PAUSED zurückgibt. `list_active` NICHT erweitern da andere Callers auf QUEUED+PRINTING-Semantik angewiesen sind (R1-M6). | +| `evict_terminal_older_than(age)` | nicht vorhanden | **Ja**, neuer Helper | +| `get(id)` | `jobs.get(session, id)` | Nein | + +`JobStore` Protocol bleibt unveraendert (gleiche Methoden-Namen wie Spec); SQLiteJobStore delegiert intern an `jobs_repo`. + +### Erratum 3 — State-Mapping und Error-Field + +Im JobStore wird intern uebersetzt: + +- Spec sagte `FAILED + error_code=printer_interrupted` -> Tatsaechlich: **`FAILED_RESTART` + `error="printer_interrupted"`** (kein separates error_code-Field im SQLModel) +- Spec sagte `DONE` als terminal-success -> bleibt `DONE` (matched models/job.JobState bereits) +- Spec sagte `printing` -> `PRINTING` (matched bereits) + +Worker-Pseudocode wird zu (real). **Wichtig (R1-C3):** Der Skip-Check `if job.state != JobState.QUEUED: continue` MUSS VOR `mark_printing` stehen — ein stale PAUSED-Job der aus der asyncio.Queue gepoppt wird soll niemals `mark_printing` aufrufen: + +```python +async def _worker(self, printer_id: UUID) -> None: + printer = self._printers[printer_id] + queue = self._queues[printer_id] + while True: + item = await queue.get() + if item is None: # Shutdown-Sentinel von stop() + return + job = item + + # Pause-Wait (unveraendert) + while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: + if self._stopping: + return + await self._worker_resume_events[printer_id].wait() + + # Skip-Check MUSS vor mark_printing kommen — stale PAUSED/CANCELLED jobs + if job.state != JobState.QUEUED: # job_lifecycle.JobState + continue + + # Persist QUEUED -> PRINTING in DB + await self._store.mark_printing(str(job.id)) # bridged: dataclass.id ist str -> UUID(str) + JobStateMachine.transition(job, JobState.PRINTING) + self._notify_state_change(job, JobState.QUEUED, JobState.PRINTING, ...) + + try: + await printer.print_image(image, tape_mm=job.tape_mm, **job.options) + JobStateMachine.transition(job, JobState.COMPLETED) + await self._store.mark_done(str(job.id)) # DB-write + self._notify_state_change(job, JobState.PRINTING, JobState.COMPLETED, ...) + except PrinterError as exc: + code, msg, detail = _printer_error_to_record(exc) + job.error_code = code + ... + JobStateMachine.transition(job, JobState.FAILED) + await self._store.mark_failed(str(job.id), str(exc)) # DB-write + self._notify_state_change(...) + except Exception as exc: + ... + JobStateMachine.transition(job, JobState.FAILED) + await self._store.mark_failed(str(job.id), str(exc)) # DB-write +``` + +Recovery in `PrintQueue.start()`: + +```python +for printer_id in self._queues: + interrupted = await self._store.mark_interrupted(printer_id) # PRINTING -> FAILED_RESTART + pending_db_jobs = await self._store.list_pending(printer_id) # QUEUED only (PAUSED bleibt PAUSED) + for db_job in pending_db_jobs: + if db_job.state == models_job.JobState.QUEUED.value: + # Rerender image aus template_key + payload + label_data = LabelData.model_validate(db_job.payload["label_data"]) + template = self._loader.get(db_job.template_key) + image = self._renderer.render(template, label_data) + payload_bytes = await asyncio.to_thread(_serialize_image_to_png, image) + wrapper = lifecycle_Job( # dataclass Job aus job_lifecycle + id=str(db_job.id), + printer_id=db_job.printer_id, + image_payload=payload_bytes, + tape_mm=db_job.payload.get("tape_mm"), + options=db_job.payload.get("options", {}), + ) + self._jobs[str(db_job.id)] = wrapper # in _jobs eintragen (s. R1-C2) + await self._queues[printer_id].put(wrapper) + elif db_job.state == models_job.JobState.PAUSED.value: + logger.info("Recovery: PAUSED job %s bleibt PAUSED, wartet auf resume", db_job.id) +``` + +`PrintService.submit_print_job(request)` flow wird: + +1. Render image (bestehend) +2. Create DB-row via `await self._store.save_queued(printer_id, template_key, payload, api_key_id, source_ip)` -> liefert `job.id` +3. Wrap as dataclass `Job(id=job.id, image_payload=png_bytes, tape_mm=..., options=...)` und an `queue.submit_existing(wrapper)` reichen +4. Return `job.id` + +### Erratum 4 — PrintService bekommt JobStore-Reference + +`PrintService` bekommt im Konstruktor jetzt auch `store: JobStore`, damit Schritt 2 oben moeglich ist. Lifespan-Wiring aktualisiert (kein Breaking-Change in API). + +`PrintRequest` (in `app/schemas/print_request.py`) hat **keine** `api_key_id`- oder `source_ip`-Felder — das sind Felder aus `AuthContext` die der Endpoint-Layer übergeben muss. Der DB-Job wird mit `api_key_id=auth.api_key_id` (UUID | None) und `source_ip=auth.ip` (str | None) angelegt, nicht per `hasattr`-Workaround auf `request` (R2-M3). + +### Erratum 5 — Payload-Schema fuer `_rerender_from_db_job` (R1-M4, R2-C4) + +Der `payload: dict` im DB-Job hat folgende Struktur (von `PrintService.submit_print_job` gespeichert): + +```python +payload = { + "label_data": label_data.model_dump(), # LabelData-Felder als dict + "tape_mm": template.tape_mm, # int + "options": { + "auto_cut": ..., # bool + "high_resolution": ..., # bool + }, +} +``` + +Recovery-Rerender MUSS deshalb `LabelData.model_validate(db_job.payload["label_data"])` aufrufen, nicht rohen dict an `renderer.render()` übergeben: + +```python +async def _rerender_from_db_job(self, db_job) -> Image.Image: + template = self._loader.get(db_job.template_key) + label_data = LabelData.model_validate(db_job.payload["label_data"]) # dict -> LabelData + return self._renderer.render(template, label_data) +``` + +### Erratum 6 — `stop()` muss DB-State für PRINTING-Jobs aktualisieren (R1-M5) + +`stop()` iteriert über `self._jobs.values()` (Dataclass-Jobs) und markiert PRINTING-Jobs per `JobStateMachine.transition(job, JobState.FAILED)`. Nach Phase-2-Refactor muss `stop()` zusätzlich den DB-State über den Store aktualisieren: + +```python +for job in self._jobs.values(): + if job.state == JobState.PRINTING: # job_lifecycle.JobState + job.error_code = "shutdown" + job.error_message = "Print queue stopped during job execution" + try: + JobStateMachine.transition(job, JobState.FAILED) + except InvalidStateTransitionError: + job._done_event.set() + # NEU (R1-M5): DB-State aktualisieren + await self._store.mark_failed(job.id, "shutdown") +```