From 40014a47a354aeec135f908b9ef24cbc167e1ede Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Thu, 24 Sep 2026 01:05:31 -0400 Subject: [PATCH] c2-core: Archive "Load more" skipped 150 of every 200 calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /calls/search scans a 200-row window and returns 50, but the next cursor was always the last SCANNED row — so with an unfiltered list each page jumped past the 150 matches it had already read and not shown. Resume after the last RETURNED row when matches overflow the page; keep the last-scanned cursor only when the page holds every match (the sparse- filter case that cursor exists for). Same fix for /calls/eval-queue. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/routers/calls.py | 32 ++++++++++++++++-------- drb-c2-core/tests/test_calls_parse_ts.py | 26 ++++++++++++++++++- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index 1794e6f..870400f 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -40,6 +40,25 @@ def _parse_ts(value: Optional[str], field: str) -> Optional[datetime]: return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) +def _next_cursor(rows: list[dict], matches: list[dict], page: list[dict], window: int) -> Optional[str]: + """Where the next page of a bounded-window scan starts. + + More matches than fit on the page → resume right after the last row + returned, or every match between it and the end of the window is skipped + (a 200-row window shown 50 at a time lost 150 calls per "Load more"). + Otherwise resume after the last row SCANNED, not the last match — a page + whose last match sits early in the window would re-scan everything after + it and loop forever on a sparse filter. A short window is the end. + """ + if len(matches) > len(page): + last = page[-1].get("started_at") + elif len(rows) == window: + last = rows[-1].get("started_at") + else: + return None + return last.isoformat() if hasattr(last, "isoformat") else last + + @router.get("") async def list_calls( node_id: Optional[str] = Query(None), @@ -153,13 +172,7 @@ async def search_calls( matches = [c for c in rows if _keep(c)] page = matches[:limit] - # Cursor advances over the SCANNED window, not the filtered page — otherwise - # a page whose last match sits early in the window would re-scan everything - # after it on the next request and loop forever on a sparse filter. - next_cursor = None - if len(rows) == window: - last_scanned = rows[-1].get("started_at") - next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned + next_cursor = _next_cursor(rows, matches, page, window) return { "calls": [with_playback_url(c) for c in page], @@ -216,10 +229,7 @@ async def eval_queue( matches = [c for c in rows if _eligible(c)] page = matches[:limit] - next_cursor = None - if len(rows) == window: - last_scanned = rows[-1].get("started_at") - next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned + next_cursor = _next_cursor(rows, matches, page, window) return { "calls": [with_playback_url(c) for c in page], diff --git a/drb-c2-core/tests/test_calls_parse_ts.py b/drb-c2-core/tests/test_calls_parse_ts.py index b347c59..cf3e714 100644 --- a/drb-c2-core/tests/test_calls_parse_ts.py +++ b/drb-c2-core/tests/test_calls_parse_ts.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone import pytest from fastapi import HTTPException -from app.routers.calls import _parse_ts +from app.routers.calls import _next_cursor, _parse_ts def test_empty_is_none(): @@ -33,3 +33,27 @@ def test_garbage_is_400(): with pytest.raises(HTTPException) as exc: _parse_ts("yesterday", "date_from") assert exc.value.status_code == 400 + + +# ── _next_cursor ────────────────────────────────────────────────────────── + + +def _rows(n): + return [{"started_at": datetime(2026, 9, 20, 12, i // 60, i % 60, tzinfo=timezone.utc)} for i in range(n)] + + +def test_cursor_resumes_after_last_returned_row_when_matches_overflow(): + rows = _rows(200) + page = rows[:50] + assert _next_cursor(rows, rows, page, 200) == page[-1]["started_at"].isoformat() + + +def test_cursor_resumes_after_window_when_page_holds_every_match(): + rows = _rows(200) + matches = rows[:3] + assert _next_cursor(rows, matches, matches, 200) == rows[-1]["started_at"].isoformat() + + +def test_short_window_is_the_end(): + rows = _rows(20) + assert _next_cursor(rows, rows[:5], rows[:5], 200) is None -- 2.54.0