Skip to content

fix(leads): add id tiebreaker to score-ordered lead paging - #83

Merged
debpalash merged 1 commit into
mainfrom
fix/lead-paging-tiebreaker
Oct 12, 2026
Merged

debpalash merged 1 commit into
mainfrom
fix/lead-paging-tiebreaker

Conversation

@debpalash

Copy link
Copy Markdown
Owner

Problem

query_leads_page ordered only by score DESC with LIMIT/OFFSET. When scores tie, PostgreSQL does not guarantee a stable order between queries, so consecutive pages can overlap and skip rows. The legacy-lead migration added in #71 (migrate_workbook_to_v2, which reads 500-row pages) relies on this paging. It could duplicate some leads, drop others, and still mark the workbook as migrated, because the count check only compares migrated >= total.

SQLite hides the bug because tied rows come back in rowid order.

Fix

The unique lead id is now a secondary sort key, in the same direction as the primary key, everywhere leads are paged by a sort column:

  • PgLeadStore.query_leads_page and LeadDB.query_leads_page: score DESC, id DESC
  • PgLeadStore.get_leads and LeadDB.get_leads: <col> <dir>, id <dir>. These back the offset-paged /leads API.
  • LeadDB.get_job_leads: score DESC, id DESC. This query has LIMIT but no offset, so the change only makes results deterministic.

Callers of query_leads_page (workbook _query_leads and migration, and audiences/refresh.py) all get the fix through the store.

Index note (no migration added)

PostgreSQL already has ix_leads_ws_score (workspace_id, score). The planner can still use it for the workspace filter and score order, then do an incremental sort on id within ties. I did not add an index. If profiling of large workspaces shows the sort matters, a follow-up migration could replace it with (workspace_id, score, id); a backward scan of that index serves score DESC, id DESC exactly. SQLite's idx_leads_score behaves the same way.

Tests

  • tests/test_lead_store_sqlite.py: 1001 leads with the same score, paged 100 at a time through query_leads_page and get_leads. Each id appears exactly once.
  • tests/test_workbook_migration_paging.py: the migration over 1001 tied-score leads (500-row pages). Every lead is migrated exactly once.
  • tests/test_lead_paging_pg.py (new, postgres marker, gated on TEST_DATABASE_URL, added to the "PostgreSQL integrity and tenancy" CI job): the same two checks against PgLeadStore under FORCE RLS.
    • Against main's store.py, the direct paging test fails on local Postgres: 997 distinct ids out of 1001. With the fix it passes.
    • The PG migration test passed even without the fix in my local run, because 500-row pages happened to be stable there. It stays as coverage of the end-to-end path.

Local results: uv run pytest -q gives 2206 passed, 139 skipped. The PG CI job's files against local Postgres give 20 passed.

Refs #71

🤖 Generated with Claude Code

query_leads_page ordered only by score, so with tied scores PostgreSQL
could return overlapping/skipping LIMIT/OFFSET pages; the legacy workbook
migration (#71) could then duplicate some leads, drop others, and still
mark itself complete. Add the unique lead id as a secondary sort key on
both backends (query_leads_page, get_leads/query_leads, get_job_leads),
and add SQLite and PostgreSQL regression tests over 1001 tied scores.

Signed-off-by: debpalash <debash67@outlook.in>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@debpalash
debpalash merged commit 979aa1e into main Oct 12, 2026
4 checks passed
@debpalash
debpalash deleted the fix/lead-paging-tiebreaker branch October 13, 2026 04:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant