Repository navigation
fix(cascade): keep the rebuild clock across restarts - #507
gloryfromca wants to merge 3 commits into
Conversation
The rebuild loop swept every kind on worker start, so restart frequency set rebuild frequency: a daemon restarting hourly paid a full drop+create per kind per hour, and on production-sized tables that is GiB-scale native allocation spent before the server answers its first request. Dropping the startup sweep would be wrong for the opposite reason -- a daemon that keeps restarting never reaches the periodic sweep, and that sweep is the only lever bounding index UUID growth. The bug was not the sweep; it was that its clock restarted with the process. Persist the last successful rebuild per kind under index_dir and sweep only what is due. Wall clock, since the span crosses process lifetimes. Missing or malformed state reads as due, so a lost file costs one extra rebuild and never skips one. The startup sweep's own comment claimed rebuild was cheap at ~0.3s per 50k rows. That measured time, not memory, and startup is where memory costs most. Archaeology: the sweep arrived in the initial squashed import with no rationale recorded anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of the previous commit found two defects in it and one overclaim. A non-UTF-8 byte in the state file raised UnicodeDecodeError, which is a ValueError and not an OSError, out of the first statement of _rebuild_loop -- which _supervise escalates to a process exit. The file reproduces on the next boot, so that is a crashloop, and nothing read that file before this feature added it. _record_rebuild had the same hole and its docstring claimed it could not raise. Setting last_sweep unconditionally after the startup sweep restarted the periodic timer even when every kind was skipped, so a restart at 0.95x the interval produced a 1.95x worst-case gap -- the silent skip the docstring promised was impossible. Credit the oldest skipped age instead. Five more tests. Three sabotages kept the original six green: stamping time.monotonic() instead of wall clock (which disables the feature), dropping the backwards-clock guard, and stamping every kind rather than one. All four mutations are now caught. The docstring described the cost as drop+create and GiB-scale. Neither holds: rebuild_indexes has replaced in place since #392, and the only measured figure in this repo is a 3-6 MB resident steady state. Restated around the per-restart work, which is what is actually defensible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The startup sweep used to be the way to force a rebuild -- restart the server. The clock deliberately stops that, and nothing told operators what replaced it. Document .index_rebuild_state beside the marker it sits next to, including that deleting it (or one kind= line) sweeps those kinds again, and that losing it costs an extra sweep rather than skipping one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Closing this. The benefit is smaller and less certain than the description claimed, and the change introduced more risk than it removed. Recording the measurements here so a future attempt starts from data rather than from this PR's prose. Why the benefit is uncertainThe motivation was inherited from #491, which asserts "GiB-scale native allocations" for a startup rebuild three times and measures it zero times. Measured here instead, on a real index with the FTS and vector indexes
The cost is real and it does scale — roughly Worth noting in passing: rebuild is faster than the comment this PR deleted claimed. That comment said ~0.3 s per 50k rows, predicting 1.8 s at 300k; the measurement is 0.53 s. Also correcting myself: the description cited the repo's "3–6 MB resident steady state" as the only measured figure. That number is the index cache working set, and using it to imply rebuild is cheap was wrong — rebuild's transient is an order of magnitude larger, as above. Why the risk was not worth itAdversarial review of the first commit found two defects, both introduced by this change:
Both were fixed in If someone picks this up againThe underlying observation still stands: restart frequency currently sets rebuild frequency, because the startup sweep is unconditional and nothing remembers when the last rebuild happened. A deployment that restarts often pays O(rows) index work every time. What a new attempt should bring that this one did not:
The branch stays at |
What is wrong
_rebuild_loopsweeps every kind on worker start, unconditionally. So restart frequency is rebuild frequency: a daemon that restarts hourly does a fullrebuild_indexesper kind per hour — O(rows × indexed columns) of index work the configured 12h cadence never asked for, competing with the requests the freshly started process exists to serve.Why not just delete the startup sweep
It exists for the opposite failure, and that one is real: a daemon that keeps restarting never lives long enough to reach the periodic sweep, and that sweep is the only lever bounding active index UUID growth. Remove it and exactly the restart-heavy deployments it was written for get nothing.
Neither "always sweep" nor "never sweep" is right. The bug is that the clock restarts with the process.
The change
Persist the last successful rebuild per kind in
<index_dir>/.index_rebuild_state; the startup sweep skips kinds that are not due.time.monotonic()— the span crosses process lifetimes.memory_rootis an optional keyword-only argument; a worker built without one behaves exactly as before.What adversarial review found, and what changed
Two defects in the first commit, both fixed in
46b45b7:UnicodeDecodeErroris aValueError, not anOSError, and the read is the first statement of_rebuild_loop— which_superviseescalates to a process exit. The file reproduces on the next boot. Nothing read that file before this feature added it, so the crash vector was this change's own._record_rebuildhad the same hole while its docstring claimed it could not raise.last_sweepwas set unconditionally after the sweep, so a restart at 0.95× the interval gave a 1.95× worst-case gap — the silent skip this PR claims is impossible, on the mechanism it calls the only bound on index growth.And one overclaim, corrected: the original motivation said "drop + create" and "GiB-scale native allocation". Neither holds.
rebuild_indexeshas replaced in place since #392, and the only measured figure in this repo is a 3–6 MB resident steady state (LanceSettings.index_cache_size_bytes).LanceRepoBase.rebuild_indexesstates outright that rebuild frequency is bounded by cost, not correctness. The docstring and this description are now written around the per-restart work, which is what the evidence supports.Open, and worth saying plainly: no GiB-scale memory measurement has been reproduced here. If the real cost is closer to 3–6 MB than to GiB, this change is worth less than its first description implied, and the periodic sweep's existing 12h default may already be fine. The two defects above were worth finding either way.
Verification
Eleven tests. Four mutations, each applied alone and each caught:
except OSErrorinstead ofexcept (OSError, ValueError)test_corrupt_state_file_does_not_take_the_loop_downlast_sweep = time.monotonic()without the credittest_a_skipped_kind_does_not_restart_the_periodic_timertime.monotonic()instead of wall clocktest_clock_round_trips_between_two_workers,test_recorded_stamp_is_wall_clocktest_one_kind_does_not_stamp_the_othersThe first three of those sabotages passed the original six tests, which is why they are here.
Correcting the earlier Verification claim in this description: it said the two load-bearing tests "were confirmed to fail against the unchanged code:
2 failed, 51 passed". That number came from a targeted revert — the skip branch and the_record_rebuildcall, with thememory_rootplumbing kept — not fromorigin/main's files, which fail five tests withTypeErroron the new keyword argument. The conclusion held; the wording did not describe what was run.272 passedacrosstests/unit/test_memory/test_cascade;make lint,lint-imports(4 contracts),make check-datetime,make check-commitsall clean.Docs
.index_rebuild_stateis documented indocs/cascade_runbook.mdbeside.index_reconcile_version, including the escape hatch — deleting the file, or onekind=line, sweeps those kinds again. That matters because "restart the server" used to be how an operator forced a rebuild, and this change deliberately stops that.Not in scope
optimize_rebuild_interval_secondsand the periodic sweep are untouched; the periodic rebuild stays on at its 12h default.