| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120 |
- """A pipeline job keeps the final status of its queue entry.
- A dispatched job's own status stays ``queued``; while its queue entry exists
- the run view reads the entry's status. The entry can be deleted later (queue
- page, archive delete, user delete), which clears the job's link, and the job
- could then not tell a printed copy from one that never ran. So when the entry
- ends -- reaches a final status, or is deleted before that (a cancellation) --
- the job stores that status itself.
- Hooks on every session flush (status changes, ORM deletes) and on statements
- run through a session (bulk deletes and status updates of print_queue). A
- delete the database cascades on its own (a printer or batch removed with its
- pending entries) is not seen: such a job reads as unknown, never as failed.
- """
- from __future__ import annotations
- from datetime import datetime, timezone
- from typing import Any
- from sqlalchemy import Table, event, select
- from sqlalchemy.engine import Connection
- from sqlalchemy.orm import ORMExecuteState, Session, attributes
- from sqlalchemy.sql.dml import Delete, Update
- from backend.app.models.pipeline_run import PipelineJob
- from backend.app.models.print_queue import PrintQueueItem
- # Final job statuses, and the queue entry statuses that end a job.
- JOB_FINAL_STATUSES = ("completed", "failed", "cancelled")
- _FINAL_FROM_QUEUE = {"completed": "completed", "failed": "failed", "aborted": "failed", "cancelled": "cancelled"}
- _queue_table: Table = PrintQueueItem.__table__ # type: ignore[assignment]
- _job_table: Table = PipelineJob.__table__ # type: ignore[assignment]
- def final_job_status(queue_status: str | None, *, entry_deleted: bool) -> str | None:
- """The status a job ends with when its entry has ``queue_status``, or None if it doesn't end."""
- final = _FINAL_FROM_QUEUE.get(queue_status or "")
- if final is not None:
- return final
- # Deleted while still waiting or printing: the copy was cancelled.
- return "cancelled" if entry_deleted else None
- def _store(connection: Connection, entries: dict[int, str]) -> None:
- """Give the not yet ended jobs of each queue entry id its final status."""
- now = datetime.now(timezone.utc).replace(tzinfo=None)
- for status in set(entries.values()):
- ids = [entry_id for entry_id, final in entries.items() if final == status]
- connection.execute(
- _job_table.update()
- .where(_job_table.c.queue_entry_id.in_(ids), _job_table.c.status.not_in(JOB_FINAL_STATUSES))
- .values(status=status, completed_at=now)
- )
- @event.listens_for(Session, "before_flush")
- def _store_on_flush(session: Session, flush_context: Any, instances: Any) -> None:
- entries: dict[int, str] = {}
- for obj in session.dirty:
- if isinstance(obj, PrintQueueItem) and obj.id is not None:
- if attributes.get_history(obj, "status").has_changes():
- final = final_job_status(obj.status, entry_deleted=False)
- if final is not None:
- entries[obj.id] = final
- for obj in session.deleted:
- if isinstance(obj, PrintQueueItem) and obj.id is not None:
- final = final_job_status(obj.status, entry_deleted=True)
- if final is not None:
- entries[obj.id] = final
- if entries:
- _store(session.connection(), entries)
- def _is_queue_table(table: Any) -> bool:
- # ORM statements carry an annotated copy of the table, so compare by name.
- return getattr(table, "name", None) == _queue_table.name
- def _set_status(statement: Update) -> str | None:
- """The literal status a bulk update writes into print_queue, if any."""
- for column, value in (statement._values or {}).items(): # noqa: SLF001
- key = column if isinstance(column, str) else getattr(column, "key", None)
- if key == "status":
- literal = getattr(value, "value", value)
- return literal if isinstance(literal, str) else None
- return None
- # First among the statement hooks: core/sqlite_fk clears the jobs' link to a
- # deleted entry in its own hook, after which they could not be found.
- @event.listens_for(Session, "do_orm_execute", insert=True)
- def _store_on_bulk(state: ORMExecuteState) -> None:
- statement = state.statement
- if state.is_delete and isinstance(statement, Delete):
- if not _is_queue_table(statement.table):
- return
- new_status = None
- entry_deleted = True
- elif state.is_update and isinstance(statement, Update):
- if not _is_queue_table(statement.table):
- return
- new_status = _set_status(statement)
- if final_job_status(new_status, entry_deleted=False) is None:
- return
- entry_deleted = False
- else:
- return
- connection = state.session.connection()
- query = select(_queue_table.c.id, _queue_table.c.status)
- if statement.whereclause is not None:
- query = query.where(statement.whereclause)
- entries: dict[int, str] = {}
- for entry_id, status in connection.execute(query):
- final = final_job_status(new_status or status, entry_deleted=entry_deleted)
- if final is not None:
- entries[entry_id] = final
- if entries:
- _store(connection, entries)
|