refactor(jobs): return NamedTuple page, early-out on empty job set
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled

Review feedback on the jobs cursor pagination:
- get_all_jobs now returns JobsPage, a NamedTuple, instead of a bare
  4-tuple (callers unpack positionally either way).
- Early-out when the filtered job set is empty so paging code never has
  to reason about indexing into an empty list. A malformed 'after'
  cursor is still decoded first and rejected with INVALID_CURSOR.
- Document that job ids are server-assigned UUIDs, always present and
  unique — the empty-string fallback in _job_id_key only shields
  sorted() from a malformed dict, it is not part of the keyset
  contract.
This commit is contained in:
Matt Miller 2026-06-09 21:19:51 -07:00
parent a8b24cb0bb
commit f4e51b9ef9

View File

@ -3,7 +3,7 @@ Job utilities for the /api/jobs endpoint.
Provides normalization and helper functions for job status tracking.
"""
from typing import Optional
from typing import NamedTuple, Optional
from comfy_api.internal import prune_dict
from utils.cursor import (
@ -18,6 +18,14 @@ from utils.cursor import (
CURSOR_SORT_FIELD = 'created_at'
class JobsPage(NamedTuple):
"""One page of the jobs listing, as returned by get_all_jobs."""
jobs: list[dict]
total_count: int
has_more: bool
next_cursor: Optional[str]
class JobStatus:
"""Job status constants."""
PENDING = 'pending'
@ -293,6 +301,11 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
def _job_id_key(job: dict) -> str:
# Job ids are server-assigned prompt UUIDs and are always present and
# unique, so the (sort_value, id) pair below is a valid keyset. The
# fallback is not part of that contract — it only keeps a malformed job
# dict from raising TypeError inside sorted() (None is unorderable
# against str).
return job.get('id') or ''
@ -357,7 +370,7 @@ def get_all_jobs(
limit: Optional[int] = None,
offset: int = 0,
after: Optional[str] = None
) -> tuple[list[dict], int, bool, Optional[str]]:
) -> JobsPage:
"""
Get all jobs (running, pending, completed) with filtering and sorting.
@ -376,7 +389,7 @@ def get_all_jobs(
InvalidCursorError on a malformed cursor.
Returns:
tuple: (jobs_list, total_count, has_more, next_cursor)
JobsPage: (jobs, total_count, has_more, next_cursor)
next_cursor is non-None only for created_at sort when more rows remain.
"""
jobs = []
@ -408,10 +421,22 @@ def get_all_jobs(
total_count = len(jobs)
use_cursor = after is not None and sort_by == CURSOR_SORT_FIELD
if use_cursor:
cursor_payload = (
decode_cursor(after, [CURSOR_SORT_FIELD], expected_order=sort_order)
if use_cursor
else None
)
# Early-out on an empty result set: nothing to page through and no cursor
# to mint, and downstream code never has to reason about indexing into an
# empty list. The cursor is still decoded above so a malformed `after` is
# rejected with INVALID_CURSOR even when there are no jobs.
if total_count == 0:
return JobsPage([], 0, False, None)
if cursor_payload is not None:
ascending = sort_order == 'asc'
payload = decode_cursor(after, [CURSOR_SORT_FIELD], expected_order=sort_order)
cursor_key = (decode_cursor_int(payload), payload.id)
cursor_key = (decode_cursor_int(cursor_payload), cursor_payload.id)
jobs = [
j for j in jobs
if (_job_keyset(j) > cursor_key if ascending else _job_keyset(j) < cursor_key)
@ -436,7 +461,7 @@ def get_all_jobs(
order=sort_order,
)
return (jobs, total_count, has_more, next_cursor)
return JobsPage(jobs, total_count, has_more, next_cursor)
def _job_keyset(job: dict) -> tuple[int, str]: