Skip to content

[draft] hnsw: URL→node index, durable zombie reclaim, persist-then-swap, async hnsw-compact - #47

Draft
andreimarinescu wants to merge 3 commits into
mainfrom
andrei/pr2-url-index-swap
Draft

[draft] hnsw: URL→node index, durable zombie reclaim, persist-then-swap, async hnsw-compact#47
andreimarinescu wants to merge 3 commits into
mainfrom
andrei/pr2-url-index-swap

Conversation

@andreimarinescu

Copy link
Copy Markdown
Collaborator

URL→node index (O(k) reclaim). HNSW keeps byURL map[string][]int32 of live node ids
(full-string keys sharing the node's string bytes; ~1 GB at 16M URLs). MarkURLPassagesInvalid
is O(k) instead of an O(N) scan under the write lock, LookupVectorByURL is O(1) and returns the
first live passage (previously the first match, which after an invalidation was a zombie's
empty vector — serve_search.go's callers only check ok), and valid/zombie counts are
maintained rather than walked (/stats no longer scans 73M nodes every 5 s).

Durable incremental persist. The 60 s checkpoint only wrote nodes[lastN:]; invalidations
and back-links on older nodes were lost on every restart until a full persist — so reclaim would
have silently undone itself at each restart (reconcile can't catch duplicates, the URL is still
live). Every node whose vector was cleared or whose neighbor list gained an entry is tracked in a
dirty set and rewritten by the next checkpoint (+~10 blobs per insert). Shutdown is one more
incremental checkpoint instead of the full persist that has never completed at production scale
(TimeoutStopSec=30). Persist encodes per ~1 GiB window under the read lock and writes outside
it, so writers stall for one window instead of the whole persist (which, via RWMutex writer
preference, would have stalled every new search for the duration of a full persist under live
crawl).

Persist-then-swap. Node blobs live in one of two slots ('v'+0x01, 'v'+0x02); a full persist
(hnsw-compact, hnsw-rebuild) writes the next generation into the inactive slot, points meta at
it (HSW2 = HSW1 + slot byte; HSW1 is still emitted while the graph sits in slot 0x01, so a store
that has never swapped stays readable by v0.2.x), then clears the old slot. This removes both the
no-graph-on-disk window (Op 1 wiped disk before writing) and the tombstone storm that made Op 1's
persist run at 12 MB/s for 5h49m: fresh writes never land under the DeleteRange. A crash mid-swap
leaves the old graph loadable; the stale slot is cleared after the next load.

Zombie-transit traversal + entry-point relocation. searchLayer traverses zombies but never
admits them to the ef window (they used to fill it with +Inf entries — the 08-26 "dense returns 0
candidates" failure), and invalidating the entry point relocates it to the highest-level live
node. Recall on an 80 %-zombie graph is now equal to clean.

Async /admin/hnsw-compact. Returns 202 and runs CompactPersist in the background (409 while
in flight, ?wait=1 for the old synchronous shape); phase / node counts / persist progress / ETA
in /stats.hnsw_compact and cosift_hnsw_compact_running. The weekly script polls that instead
of holding a connection (Op 1's response died on the ~6 h connection); threshold 30→15 %; the
box-only timer unit is now in deploy/systemd/. /admin/checkpoint and /admin/frontier-clear
lift the 60 s WriteTimeout like hnsw-compact already did.

COSIFT_PEBBLE_COMPACTIONS exposes Pebble MaxConcurrentCompactions (default 1, unchanged;
the single slot is the 128K-SSTable and persist-throughput cause on the box).

COSIFT_ZOMBIE_RECLAIM defaults to on (0/false/off disables); /stats.hnsw_reclaimed_total,
cosift_hnsw_reclaimed_total, cosift_hnsw_zombie_nodes.

…ot-swapped full persist, zombie-transit traversal

Per-URL node index makes MarkURLPassagesInvalid O(k) and LookupVectorByURL O(1)
(live nodes only); valid/zombie counts are maintained, not scanned.

Incremental persists now rewrite every node dirtied since the last persist
(invalidated, or handed a back-link), so reclaim and reconcile survive a
restart and shutdown no longer needs a full persist. Persist encodes per
window under the read lock instead of holding it for the whole write.

Full persists write into the inactive node slot ('v'+0x02) and only then
point meta at it (HSW2 carries the slot; HSW1 is still emitted for slot
0x01). The previous graph stays loadable throughout and the two key ranges
never share tombstones.

searchLayer treats zombies as transit-only (traversed, never admitted to the
ef window) and invalidating the entry point relocates it to the highest-level
live node.
…own checkpoint; reclaim on by default

/admin/hnsw-compact now returns 202 and runs CompactPersist in the background
(409 while in flight, ?wait=1 blocks for the result); state, phase, persist
progress and ETA are published as /stats.hnsw_compact. The old slot is
cleared only after the swap. /admin/checkpoint and /admin/frontier-clear
lift the 60 s WriteTimeout like hnsw-compact already did.

The crawler checkpoint uses TryPersistFrom (skips a tick while a full
persist runs) and also fires on dirty-only changes; shutdown is one more
incremental checkpoint instead of a full persist that never completed at
production scale. A stale slot left by a crash mid-swap is cleared after
load.

COSIFT_ZOMBIE_RECLAIM defaults to on (0/false/off disables); reclaim and
zombie counts are exposed in /stats and /metrics. COSIFT_PEBBLE_COMPACTIONS
sets Pebble MaxConcurrentCompactions (default 1).
…/PEBBLE docs for reclaim default, slots, compaction knob
@andreimarinescu
andreimarinescu marked this pull request as draft September 2, 2026 16:15
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