Compare commits
2
Commits
6c0e7a4f8e
...
02b5b7b5a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b5b7b5a5 | ||
|
|
40014a47a3 |
@@ -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)
|
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("")
|
@router.get("")
|
||||||
async def list_calls(
|
async def list_calls(
|
||||||
node_id: Optional[str] = Query(None),
|
node_id: Optional[str] = Query(None),
|
||||||
@@ -153,13 +172,7 @@ async def search_calls(
|
|||||||
matches = [c for c in rows if _keep(c)]
|
matches = [c for c in rows if _keep(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
# Cursor advances over the SCANNED window, not the filtered page — otherwise
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
# 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"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)]
|
matches = [c for c in rows if _eligible(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
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():
|
def test_empty_is_none():
|
||||||
@@ -33,3 +33,27 @@ def test_garbage_is_400():
|
|||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
_parse_ts("yesterday", "date_from")
|
_parse_ts("yesterday", "date_from")
|
||||||
assert exc.value.status_code == 400
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user