Skip to content

Root id lookups: throttled repairs (full re-lookup, NULL fill) that keep last_updated - #256

Closed
fcollman wants to merge 4 commits into
masterfrom
fix-dense-missing-root-lookup
Closed

fcollman wants to merge 4 commits into
masterfrom
fix-dense-missing-root-lookup

Conversation

@fcollman

@fcollman fcollman commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The dense missing root id lookup (/materialize/run/dense_lookup_root_ids/...) could not run:
    • one NULL row crashed every task ('int' object is not subscriptable);
    • more than one could not be sent at all (range is not JSON serializable);
    • over a wide id span, it queued one chord with a task per 500 ids, enough to run Redis out of memory.
  • It could also leave other rows stale. It re-looked-up every row in the span, then moved the table's last_updated to now. The next update then skipped roots that had expired in between.
  • There was no throttled way to re-look-up every root of a table, for when roots may be stale without knowing which.
  • Each hourly update looked up NULL roots repeatedly. The rows with a NULL root were added to every expired-root chunk query. When nothing had expired, they were skipped entirely.

Change

Two repair tools, both through update_root_ids, so both write only rows that changed.

Both repairs leave last_updated alone. They look roots up as of the table's last_updated (or as of now if the table has none) and never move it. The next regular update therefore still covers everything that expired since then.

  • Full re-lookup (lookup_all_root_ids): every row of a table is looked up from its supervoxel id.
    • New per-table endpoint: POST /materialize/run/lookup_all_root_ids/datastack/<ds>/table/<table>.
    • The existing POST /materialize/run/update_roots/datastack/<ds>?lookup_all_root_ids=true does the same for a whole datastack.
    • Chunks are 50,000 rows, and each chunk task streams its rows in chunk_size batches.
  • NULL roots: every regular update now looks up the rows with a supervoxel id and a NULL root in one chunk of their own, MISSING_ROOTS, alongside the expired roots, even when nothing expired.
    • The existing dense (per datastack) and sparse (per table) missing-root endpoints run only that chunk, as a repair (only_missing_roots). They do not search for expired roots.
    • The hourly and build chains no longer need the separate, gated find_missing_root_ids_workflow step.
  • Throttling: update_root_ids_workflow sends its chunk tasks through a new run_with_limit, with at most UPDATE_ROOT_IDS_MAX_IN_FLIGHT (default 20) unfinished at a time, instead of one chord of all of them. Each chunk task still checks the process queue length before queuing each batch.
  • get_new_root_ids writes only rows whose root changed. It no longer writes the supervoxel column back.
  • update_metadata runs only after every chunk of a regular update succeeded, as before. A repair never runs it.
  • monitor_task_states polls only the tasks still pending.

Testing

New tests:

  • the chunk queries: expired roots without OR IS NULL, the NULL-root chunk, and full-lookup id ranges, including the open last chunk;
  • the workflow's chunk sequence in each mode, including that NULL roots are filled when nothing expired, and that a failed chunk leaves last_updated alone;
  • that repairs look up as of last_updated, fall back to now when there is none, and never call update_metadata;
  • that only changed rows are written;
  • that run_with_limit never exceeds its limit;
  • that the dense and sparse entry points run the update.

Full suite on fresh containers: 449 passed, 2 skipped. Ruff is clean.

🤖 Generated with Claude Code

fcollman and others added 2 commits October 15, 2026 12:47
…dated

The dense lookup queued one chord over the whole span between the lowest and highest
row with a NULL root, 500 ids per task, all at once. A large span filled Redis; a span
of one row crashed every task ('int' object is not subscriptable) and any longer span
could not be sent at all (range is not JSON serializable). Each task also re-looked-up
every row in its range, not only the NULL ones, and the chain ended with
update_metadata, moving the table's last_updated to now: the next update_root_ids run
then skipped the roots of other rows that expired in between, leaving them stale.

It now streams the ids of rows with a supervoxel id and a NULL root, and queues small
batches as the process queue drains (batch_missing_root_ids_query, throttled), like the
sparse lookup but without its "table modified recently" check. Roots are looked up at
the table's last update, which stays as it is.

monitor_task_states polls only the tasks still pending, instead of every task every
0.2 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ar update

Two tools, both through update_root_ids (so both write only what changed and keep the
table's last_updated right):

- Full re-lookup, for a table whose roots may be stale without knowing which: every row
  is looked up from its supervoxel id (lookup_all_root_ids). New per-table endpoint
  /materialize/run/lookup_all_root_ids/datastack/<ds>/table/<table>; the existing
  update_roots?lookup_all_root_ids=true does it for a whole datastack.
- NULL roots: every update now looks up the rows with a supervoxel id and a NULL root in
  one chunk of their own (they used to be added to every expired-root chunk, and were
  skipped entirely when nothing had expired). The dense and sparse missing-root
  endpoints now run that update right away; the hourly and build chains no longer need
  the separate (gated) missing-root step.

update_root_ids_workflow queues its chunk tasks with at most UPDATE_ROOT_IDS_MAX_IN_FLIGHT
(default 20) unfinished at a time (run_with_limit) instead of one chord of all of them,
so a full re-lookup of a large table, in 50k-row chunks, cannot flood Redis.
get_new_root_ids writes only rows whose root changed. update_metadata runs only after
every chunk succeeded, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fcollman fcollman changed the title Dense missing root id lookup: stream and throttle, never move last_updated Root id lookups: a throttled full re-lookup, and NULL roots filled by the regular update Oct 15, 2026
…of it

A full re-lookup (lookup_all_root_ids) or a NULL fill (only_missing_roots, from the dense
and sparse missing-root endpoints) is a repair: it looks roots up as of the table's
last_updated (now if it has none) and leaves last_updated as it is, so the next regular
update still covers everything that expired since. The NULL fill no longer searches for
expired roots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fcollman fcollman changed the title Root id lookups: a throttled full re-lookup, and NULL roots filled by the regular update Root id lookups: throttled repairs (full re-lookup, NULL fill) that keep last_updated Oct 15, 2026
@fcollman

Copy link
Copy Markdown
Collaborator Author

@copilot resolve the merge conflicts in this pull request

…oot-lookup

# Conflicts:
#	tests/test_ingest_new_annotations.py

Co-authored-by: fcollman <782341+fcollman@users.noreply.github.com>

Copilot AI commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved in 77531ff by merging current master, preserving both its scoped test mocks and the root-ID repair tests. Syntax, whitespace, and secret checks passed. Pytest/Ruff were unavailable locally; CodeQL reported four existing upload API findings unchanged from master.

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.

2 participants