diff --git a/SPARC/api.py b/SPARC/api.py index e4b7d42..8ae9c7d 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -77,6 +77,13 @@ class JobStatus(BaseModel): error: str | None = None +class PaginatedJobsResponse(BaseModel): + """Paginated response for job listings.""" + + items: list["JobStatus"] + next_cursor: str | None = None + + class HealthResponse(BaseModel): """Health check response.""" @@ -609,24 +616,51 @@ async def get_job_status( return _job_row_to_status(job_row) -@app.get("/jobs", response_model=list[JobStatus], tags=["Jobs"]) +@app.get("/jobs", response_model=PaginatedJobsResponse, tags=["Jobs"]) async def list_jobs( status: Annotated[ str | None, Query(description="Filter by status: pending, running, completed, failed"), ] = None, limit: Annotated[int, Query(ge=1, le=100)] = 10, + cursor: Annotated[ + str | None, + Query(description="Opaque cursor from a previous response's next_cursor field"), + ] = None, _: UserResponse = Depends(get_current_user), ): - """List all analysis jobs. + """List analysis jobs with cursor-based pagination. + + Pass ``limit`` to control page size. The response includes a ``next_cursor`` + field; pass it back as the ``cursor`` query parameter to fetch the next page. + When ``next_cursor`` is ``null``, there are no more results. + + Existing clients that use only ``limit`` (without ``cursor``) continue to + work without modification. Args: status: Optional filter by job status limit: Maximum number of jobs to return (default 10, max 100) + cursor: Opaque pagination cursor from a previous response Returns: - List of job statuses + Paginated list of job statuses """ db = _get_job_db() - job_rows = db.list_jobs(status=status, limit=limit) - return [_job_row_to_status(row) for row in job_rows] + # Fetch one extra to determine if there is a next page + job_rows = db.list_jobs(status=status, limit=limit + 1, cursor=cursor) + + has_next = len(job_rows) > limit + if has_next: + job_rows = job_rows[:limit] + + items = [_job_row_to_status(row) for row in job_rows] + + next_cursor = None + if has_next and job_rows: + last = job_rows[-1] + created = last["created_at"] + ts = created.isoformat() if hasattr(created, "isoformat") else str(created) + next_cursor = f"{ts}|{last['job_id']}" + + return PaginatedJobsResponse(items=items, next_cursor=next_cursor) diff --git a/SPARC/database.py b/SPARC/database.py index 4492311..23bdacc 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -568,20 +568,45 @@ class DatabaseClient: self, status: Optional[str] = None, limit: int = 10, + cursor: Optional[str] = None, ) -> List[Dict]: - """List jobs, optionally filtered by status.""" - query = "SELECT * FROM jobs" + """List jobs with optional status filter and cursor-based pagination. + + Args: + status: Optional status filter (pending, running, completed, failed). + limit: Maximum number of jobs to return. + cursor: Opaque cursor (``created_at|job_id``) from a previous + response. When provided, only jobs older than the cursor are + returned. + + Returns: + List of job dicts ordered by created_at descending. + """ + conditions: list[str] = [] params: list = [] + if status: - query += " WHERE status = %s" + conditions.append("status = %s") params.append(status) - query += " ORDER BY created_at DESC LIMIT %s" + + if cursor: + try: + ts_str, cursor_job_id = cursor.rsplit("|", 1) + conditions.append("(created_at, job_id) < (%s, %s)") + params.extend([ts_str, cursor_job_id]) + except ValueError: + pass # Ignore malformed cursors; return from start + + query = "SELECT * FROM jobs" + if conditions: + query += " WHERE " + " AND ".join(conditions) + query += " ORDER BY created_at DESC, job_id DESC LIMIT %s" params.append(limit) with self.get_conn() as conn: - with conn.cursor(cursor_factory=RealDictCursor) as cursor: - cursor.execute(query, params) - return [dict(row) for row in cursor.fetchall()] + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] def mark_stale_jobs_failed(self) -> int: """Mark any jobs in 'running' or 'pending' state as 'failed'.