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. 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 comfy_api.internal import prune_dict
from utils.cursor import ( from utils.cursor import (
@ -18,6 +18,14 @@ from utils.cursor import (
CURSOR_SORT_FIELD = 'created_at' 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: class JobStatus:
"""Job status constants.""" """Job status constants."""
PENDING = 'pending' PENDING = 'pending'
@ -293,6 +301,11 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
def _job_id_key(job: dict) -> str: 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 '' return job.get('id') or ''
@ -357,7 +370,7 @@ def get_all_jobs(
limit: Optional[int] = None, limit: Optional[int] = None,
offset: int = 0, offset: int = 0,
after: Optional[str] = None after: Optional[str] = None
) -> tuple[list[dict], int, bool, Optional[str]]: ) -> JobsPage:
""" """
Get all jobs (running, pending, completed) with filtering and sorting. Get all jobs (running, pending, completed) with filtering and sorting.
@ -376,7 +389,7 @@ def get_all_jobs(
InvalidCursorError on a malformed cursor. InvalidCursorError on a malformed cursor.
Returns: 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. next_cursor is non-None only for created_at sort when more rows remain.
""" """
jobs = [] jobs = []
@ -408,10 +421,22 @@ def get_all_jobs(
total_count = len(jobs) total_count = len(jobs)
use_cursor = after is not None and sort_by == CURSOR_SORT_FIELD 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' ascending = sort_order == 'asc'
payload = decode_cursor(after, [CURSOR_SORT_FIELD], expected_order=sort_order) cursor_key = (decode_cursor_int(cursor_payload), cursor_payload.id)
cursor_key = (decode_cursor_int(payload), payload.id)
jobs = [ jobs = [
j for j in jobs j for j in jobs
if (_job_keyset(j) > cursor_key if ascending else _job_keyset(j) < cursor_key) 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, 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]: def _job_keyset(job: dict) -> tuple[int, str]: