Skip to content

perf(blocklist): seek instead of scan, serve from a swapped copy, decay every 6h - #26

Open
jbrahy wants to merge 4 commits into
mainfrom
fix/blocklist-keyset-index
Open

perf(blocklist): seek instead of scan, serve from a swapped copy, decay every 6h#26
jbrahy wants to merge 4 commits into
mainfrom
fix/blocklist-keyset-index

Conversation

@jbrahy

@jbrahy jbrahy commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

A full blocklist sync against the 732k seeded numbers took long enough to look broken. Three independent problems compounded, none of which was visible when the table held three rows.

1. Every page was a full table scan plus a filesort

keysetPredicate wrapped the indexed column -- UNIX_TIMESTAMP(updated_at) > ? -- and a function on a column cannot drive an index seek:

type: ALL   key: NULL   rows: 715574   Extra: Using where; Using filesort

0.771s per 500-row page, ~1465 pages -- roughly a billion row reads and ~19 minutes of pure DB time for one sync.

Fixed by comparing the bare columns as a row value, with FROM_UNIXTIME applied to the parameter instead, plus migration 0007 indexing (updated_at, phone_number_id):

type: index   key: idx_phone_numbers_updated_at_id   rows: 1999   Extra: Using where

0.040s per 1000-row page. Filesort gone, ~358x fewer rows examined.

FROM_UNIXTIME(0) is 1970-01-01 00:00:00, not NULL, so the (0, 0) full-snapshot cursor still compares correctly instead of yielding NULL and returning nothing -- verified on the production MySQL 8.4 with time_zone=SYSTEM.

Why the index leads with the ordering columns

Rather than extending the existing (status, updated_at). The base query filters status IN ('blocked','overridden_block','suspected'), so a status-leading index produces three ranges whose union is not in global (updated_at, phone_number_id) order -- MySQL would still sort, which is the cost being removed. The migration documents when to revisit: if most rows ever become non-blockable (seeded scores decay), the residual status filter would have to skip many rows to fill a page.

2. The client asked for half the rows it was allowed

SyncService.pageLimit defaulted to 500 while the server's maxBlocklistLimit is 1000 -- double the round trips for nothing. Now defaults to 1000.

3. Saves were quadratic in disk writes

BlocklistStore.save serializes the whole state atomically, and sync() called it after every page. At 732k entries the file is ~8.8MB, so ~1465 per-page saves wrote ~6.4GB to flash for a single sync. State is now written every pagesPerSave (10) pages.

Durability is preserved rather than traded away: a page failure persists whatever was folded in before rethrowing, so progress survives a mid-sync error exactly as it did when every page was saved. The pre-existing test_sync_pageFailure_rethrows_doesNotReload_priorPageStillPersisted passes unchanged, and two new tests cover the cases batching introduces -- the tail save after the last batch boundary, and a failure mid-batch.

Net

before after
rows examined per page 715,574 1,999
time per page 0.771s / 500 rows 0.040s / 1000 rows
DB time, full sync ~19 min ~29 s
flash writes, full sync ~6.4 GB ~325 MB

Verification

  • Go suite green (it gates make deploy-server, which ran).
  • iOS suite green: 114 tests, 0 failures.
  • Deployed to production before opening this PR: migration 7 applied, EXPLAIN confirms the index is in use, timings above are measured on the live 732k-row table, and 17/17 production smoke checks passed.

jbrahy and others added 2 commits September 12, 2026 07:58
A full blocklist sync against the 732k seeded numbers took long enough
to look broken. Three independent problems compounded, none of which
was visible when the table held three rows.

1. Every page was a full table scan plus a filesort. keysetPredicate
   wrapped the indexed column -- UNIX_TIMESTAMP(updated_at) > ? -- and a
   function on a column cannot drive an index seek:

     type: ALL   key: NULL   rows: 715574   Using where; Using filesort

   0.771s per 500-row page, ~1465 pages, so roughly a billion row reads
   and ~19 minutes of pure DB time for one sync.

   Fixed by comparing the bare columns as a row value, with
   FROM_UNIXTIME applied to the PARAMETER instead, plus migration 0007
   indexing (updated_at, phone_number_id). Measured after:

     type: index   key: idx_phone_numbers_updated_at_id   rows: 1999

   0.040s per 1000-row page. Filesort gone, ~358x fewer rows examined.

2. The client asked for 500 entries per page while the server's
   maxBlocklistLimit is 1000, paying double the round trips for nothing.
   pageLimit now defaults to 1000.

3. Saves were quadratic in disk writes. BlocklistStore.save serializes
   the WHOLE state atomically, and sync() called it after every page. At
   732k entries the file is ~8.8MB, so ~1465 per-page saves wrote ~6.4GB
   to flash for a single sync. State is now written every pagesPerSave
   (10) pages.

   Durability is preserved rather than traded away: a page failure
   persists whatever was folded in before rethrowing, so progress
   survives a mid-sync error exactly as it did when every page was
   saved. Two tests cover it -- the tail save after the last batch
   boundary, and a failure mid-batch.

Net: ~19 minutes of DB time becomes ~29 seconds, and ~6.4GB of flash
writes becomes ~325MB.

The index leads with the ordering columns rather than extending
(status, updated_at), because the base query's status IN (...) would
otherwise produce three ranges whose union is not in global keyset
order -- MySQL would still sort, which is the cost being removed. The
migration documents when to revisit that: if most rows ever become
non-blockable, the residual status filter would have to skip many rows
to fill a page.

Deployed and verified on production before this commit: migration 7
applied, index in use, 17/17 smoke checks passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
The decay pass rewrites derived state for every number. At 732k numbers a
pass measured 58m52s against a 15-minute timer, so it fired, found the
unit still active, skipped, and effectively ran back-to-back forever --
saturating MySQL at ~618 selects/sec and competing with the blocklist
reads devices sync from. For that whole hour readers were also querying
a table mid-rewrite.

Split populate from serve:

- Migration 0008 adds blocklist_serving and blocklist_serving_next, the
  derived read model the delta serves.
- RecomputeAll publishes both passes at once via SwapBlocklistServing:
  rebuild the standby copy, then one atomic RENAME TABLE. A reader sees
  the whole old snapshot or the whole new one, never a partial rewrite.
- BlocklistDelta reads blocklist_serving instead of phone_numbers.
- The timer drops to 6 hours.

phone_numbers stays the single source of truth and is never swapped.
Only the derived projection is, so a community report, attestation or
admin override landing mid-rebuild cannot be lost -- which a swap of the
whole schema would have done.

RecomputeNumberServing keeps the request path immediate: a reported
number patches its own row in the live serving copy inside the report
transaction, so it reaches devices on the next sync rather than waiting
up to 6 hours for a swap. The bulk pass deliberately does not do this --
patching 732k rows one at a time would both double its work and destroy
the isolation the swap exists to provide.

6 hours is not a compromise. This pass only applies time decay; reports
and overrides recompute their own number synchronously. With a 30-day
half-life a score moves 0.58% in 6 hours.

The serving table's updated_at carries NO "ON UPDATE CURRENT_TIMESTAMP"
and is copied verbatim, because it IS the sync cursor. A restamp would
put every device's stored cursor behind the entire table at once and
make every client re-download all 732k entries after every swap. There
is a test that fails if that regresses.

Verified on production: migration 8 applied in 23s, all three tables at
732,382 rows, blocklist_serving serving 732,379 blocked, EXPLAIN shows
idx_blocklist_serving_updated_at_id with no filesort, 17/17 smoke checks
passed, and DB load fell from ~12,362 selects/20s to 1.

Note for whoever deploys next: migration 0008 takes ~23s, which exceeds
the ~14s health-check window in scripts/deploy.sh. Deploy with
SKIP_RESTART=1 and restart by hand, or widen that window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
@jbrahy jbrahy changed the title perf(blocklist): make sync a seek instead of a scan perf(blocklist): seek instead of scan, serve from a swapped copy, decay every 6h Sep 12, 2026
jbrahy and others added 2 commits September 12, 2026 08:56
0008 published a rebuilt snapshot with an atomic RENAME TABLE. That is
correct but heavier than the job needs: RENAME takes an EXCLUSIVE
metadata lock, so it waits on in-flight queries and every new reader
queues behind it. Nothing is physically moved -- both copies just sit
there -- so the switch should be a pointer change, not DDL.

- Migration 0009 renames the two copies to blocklist_serving_a and
  blocklist_serving_b and adds blocklist_serving_slot, a singleton row
  naming the live slot. The CHECK pins the primary key to one value so a
  second row cannot exist and "which slot is live" can never be
  ambiguous. active_slot is an ENUM, so an unexpected value cannot even
  be stored.
- SwapBlocklistServing fills whichever slot is NOT live, then updates
  the pointer. One row, no DDL lock. A request either resolves the
  pointer before the update and serves the whole previous snapshot, or
  after and serves the whole new one.
- BlocklistDelta resolves the pointer once per call and substitutes the
  table name into its query templates.

The table name comes only from servingTable, which accepts nothing but
the two known slots, so a pointer value can never reach a query as
arbitrary text. There is a test asserting an unknown slot is rejected,
because this is the one place a database value becomes part of a query
string rather than a bound parameter.

Index names are now per-slot. They did not have to be -- index names are
per-table -- but with the tables no longer swapping identities, an
EXPLAIN naming idx_blocklist_serving_a_* tells you which slot you read.

Tests cover what is distinctive here: the active slot alternates a->b->a
so a rebuild never overwrites the slot being served, bulk changes stay
invisible until the pointer moves, a reported number is servable without
waiting for a switch, and a switch does not restamp updated_at (which
would put every device's cursor behind the whole table and force a full
732k re-download).

Verified on production: migration 9 applied in 3s, pointer at slot a,
both slots holding 732,382 rows, EXPLAIN using
idx_blocklist_serving_a_updated_at_id with no filesort, 17/17 smoke
checks passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
A first sync pages through the entire blocklist. With only a spinner on
the button, a screen that is working looks like a screen that is hung.

Server publishes the denominator:

- Migration 0010 adds servable_count to blocklist_serving_slot, and
  SwapBlocklistServing counts while rebuilding and publishes it in the
  same UPDATE that moves the pointer -- so the count a reader sees always
  describes the slot it was pointed at.
- The blocklist response gains "total". Additive, so an older client
  ignores it.

Not a COUNT(*) per request: the delta's status filter is not the leading
column of any index, so a count scans, and a full sync asks ~733 times.
The rebuild already walks every row, making the count free there.

Client reports and renders it:

- SyncProgress{applied, total} with a clamped fraction, emitted after
  every page through an optional @sendable handler on SyncService.sync.
  Optional so the extensions and tests pay nothing.
- BlocklistData.total is OPTIONAL, because a server predating the field
  omits it and decoding must not fail against one. No total means no
  fraction, an indeterminate bar, and a bare count instead of a
  percentage.
- StatusScreen shows a bar, a percentage and "302,400 of 732,379
  numbers" while syncing.

Two deliberate imprecisions, both documented where they live:

The fraction is CLAMPED. total counts servable rows, but a delta also
carries "unblock" tombstones that are not in that count, so a
removal-heavy sync legitimately applies more entries than the total. A
bar that runs past its end reads as a bug; clamped reads as done.

The denominator DRIFTS. Per-row patches from UpsertServingRow do not
adjust servable_count, so between switches it is off by however many
numbers were reported in that window -- a handful out of 732k, which is
not worth a write on every report.

Progress is cleared on the way INTO a sync, not on the way out. The
handler hands updates over through a MainActor task, so the last page's
update can land after syncNow() returns; clearing on exit would race it
and sometimes lose, leaving a value set with no sync running. Clearing
on entry is race-free, and the screen only renders progress while
isSyncing.

Verified: 118 SpamFilterKit tests and the SyncStatusViewModel suite pass
with 0 failures; go test ./... green across 15 packages; migration 10
applied to production with servable_count backfilled to 732,380, which
is exactly the servable set (732,379 blocked + 1 overridden_block).

Pre-existing and untouched: SpamFilterUITests testLookupFlow and
testReportHappyPath fail on a pristine main checkout too -- confirmed by
running them against a clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmEXErCWYt3WZCZKaS7m9
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