feat(jobs): cursor-based pagination on GET /api/jobs (BE-943) (#14363)

* refactor(pagination): hoist cursor codec to utils/ for cross-domain reuse

The keyset cursor codec was asset-namespaced (app/assets/services/cursor.py)
but the wire format and encode/decode logic are domain-agnostic. Move it to
utils/cursor.py so the jobs endpoint can share one codec instead of importing
across domains or duplicating it.

* feat(jobs): cursor-based pagination on GET /api/jobs (BE-943)

Mirror the cloud jobs cursor (BE-885) on the OSS Python server so the
frontend sees one contract across runtimes.

- apply_sorting now appends the job id as a tiebreaker, making (create_time,
  id) a stable keyset; without it, ties could reorder between pages.
- get_all_jobs accepts an opaque 'after' cursor (honored only for created_at
  sort, like cloud), keyset-filters the sorted in-memory list, and returns
  has_more + a next_cursor. Minted in offset mode too so a client can bootstrap
  into keyset pagination.
- server.py /api/jobs parses 'after', returns next_cursor in the pagination
  object, and maps a malformed cursor to 400 INVALID_CURSOR.
- Reuses the shared utils.cursor codec (base64url JSON {s,v,id,o}) so the wire
  format matches cloud and assets exactly.

Tests: asc/desc multi-page round-trip, same-create_time tiebreaker, last-page
no-cursor, offset-mode bootstrap, execution_duration ignores cursor, malformed
cursor raises.

* refactor(jobs): return NamedTuple page, early-out on empty job set

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:28:25 -07:00
committed by GitHub
parent 71a20886ec
commit 33109e0a04
7 changed files with 218 additions and 31 deletions

View File

@@ -9,6 +9,7 @@ import nodes
import folder_paths
import execution
from comfy_execution.jobs import JobStatus, get_job, get_all_jobs
from utils.cursor import InvalidCursorError
import uuid
import urllib
import json
@@ -785,6 +786,8 @@ class PromptServer():
sort_order: Sort direction: asc, desc (default)
limit: Max items to return (positive integer)
offset: Items to skip (non-negative integer, default 0)
after: Opaque keyset cursor from a prior next_cursor; takes
precedence over offset. Honored only for created_at sort.
"""
query = request.rel_url.query
@@ -792,6 +795,7 @@ class PromptServer():
workflow_id = query.get('workflow_id')
sort_by = query.get('sort_by', 'created_at').lower()
sort_order = query.get('sort_order', 'desc').lower()
after = query.get('after')
status_filter = None
if status_param:
@@ -850,26 +854,35 @@ class PromptServer():
running = _remove_sensitive_from_queue(running)
queued = _remove_sensitive_from_queue(queued)
jobs, total = get_all_jobs(
running, queued, history,
status_filter=status_filter,
workflow_id=workflow_id,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
offset=offset
)
try:
jobs, total, has_more, next_cursor = get_all_jobs(
running, queued, history,
status_filter=status_filter,
workflow_id=workflow_id,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
offset=offset,
after=after
)
except InvalidCursorError:
return web.json_response(
{"error": "Invalid pagination cursor", "code": "INVALID_CURSOR"},
status=400
)
has_more = (offset + len(jobs)) < total
pagination = {
'offset': offset,
'limit': limit,
'total': total,
'has_more': has_more
}
if next_cursor is not None:
pagination['next_cursor'] = next_cursor
return web.json_response({
'jobs': jobs,
'pagination': {
'offset': offset,
'limit': limit,
'total': total,
'has_more': has_more
}
'pagination': pagination
})
@routes.get("/api/jobs/{job_id}")