Track external source roots instead of copying, with parallel discovery and non-destructive removal#590
Merged
Merged
Conversation
The discovery/hash planning pass ran single-threaded, so a multi-million-file corpus stalled one core while the GPUs sat idle. Fan the independent per-file classification across a thread pool and assemble the plan in sorted order so the result is identical to the serial pass. Size the pool to available_cpu_count(), which folds in the cgroup CFS quota and CPU affinity instead of trusting os.cpu_count() (which reports the host, not the pod). New ingest_workers config (0 = auto) and add --max-cpus cap the pool per run.
…ob patterns add now symlinks a source into the documents dir instead of copying it. A prepared corpus already lives on local disk, so linking it in place removes the single-threaded byte copy and the doubled disk footprint that a large ingest paid before the GPUs got any work. Discovery follows these top-level links, keying each linked source under its label so every downstream consumer stays documents_dir-relative; the symlink-escape guard is relaxed only for these self-describing linked roots, and a nested link that points outside them is still skipped. remove no longer deletes files on disk. Source bytes are the user's, and a linked-in corpus must never be removed, so the delete_files parameter and the --delete flag are gone from every surface (store, CLI, MCP, HTTP, the Python API, and the skill docs). Removal is index-only plus a durable skip-marker; removing a top-level linked root instead unlinks that symlink. Matching now expands beyond an exact name: a folder removes every source indexed beneath it (whole-segment boundary), and a glob (a name with * ? or []) removes every fnmatch. The CLI confirms before a multi-document removal.
Tighten _walk_into's ignore_dirs annotation to frozenset[str], note why the cgroup CPU quota is read by hand (os.process_cpu_count is 3.13+, floor is 3.11), and update the usage/tutorial docs for symlink-based add and folder/glob remove.
…ingesting Moving indexed files used to cost a full re-ingest: the old paths were removed and the new paths embedded from scratch, even though the content was identical. Sync now hash-indexes the sources whose files just disappeared and matches them against the new files in the same pass. A match is a move, so the source is re-keyed in place (chunks, page texts, concepts, entities, and citations all repoint to the new name, the sources row keeps its hash and vectors) rather than deleted and re-embedded. Pairing is one-to-one and deterministic when hashes collide. The result carries a `relocated` count across the CLI, TUI, HTTP, and MCP surfaces. add stops demanding --force when a source moved: a dangling link (its old target gone) auto-relinks to the new path, and the TUI reports "already indexed, location changed" instead of treating it as new.
tobocop2
marked this pull request as draft
July 22, 2026 02:41
… then copy Symlink creation needs privilege on Windows (Developer Mode or admin), so a symlink-only add would fail for an unprivileged Windows user. Degrade instead of breaking, preferring each option that still avoids a byte copy: - symlink where the platform allows it (POSIX, or privileged Windows); - a directory junction (no privilege) for a directory on Windows, created via _winapi.CreateJunction and verified; - a same-volume file hard link for a file on Windows; - a plain copy as the last resort (cross-volume, UNC, or any failure). A junction is not a symlink, so link detection is unified on os.readlink (which recognizes both and works on the 3.11 floor, where os.path.isjunction is absent), discovery follows junctions and prunes the linked roots from its main walk so a junction tree is not walked twice, and a link is removed with rmdir for a junction or unlink for a symlink. Capability is probed once and cached.
…cleanup Two cleanup paths matched links with is_symlink() and rmtree'd anything else that was a directory. A Windows junction is not a symlink but is a directory, so both would have recursed through it and deleted the real linked-in corpus: the knowledge-base reset (app.reset._clear_dir) and the /add cancel/failure cleanup (remove_linked_sources). Both now detect a link with is_link() (symlink or junction) and detach it with remove_link() (rmdir for a junction, unlink for a symlink), never following it to the target. The detach primitive moves next to is_link in core.system so every consumer shares one junction-aware implementation.
…anups Parallel planning now cancels queued-but-unstarted work when cancel trips mid-pass, restoring the prompt cancellation the serial path had; the docstring no longer overclaims the plan is identical under cancel. relocate_sources opens each table once instead of per move (LanceDB's update SQL has no CASE, so a single batched statement is not available, and a delete+re-add across the vector tables is not worth its risk for a rare mass relabel). Smaller fixes: the remove confirmation counts only names that actually exist; the CLI expands remove targets once and passes them through; expand_remove_targets and remove_documents_durably take the pre-read known set / targets to avoid a second sources read; --max-cpus is available on sync and rebuild, not just add; the relocated count now shows in the background/auto-sync summary line; get_services moves to app.ingest's module top (AGENTS forbids lazy-importing it); a stale copy_result name and a narrating discovery comment are cleaned up; and the relocate test now covers page_texts and citations so dropping a re-keyed table fails.
Register external source roots in config (label -> absolute path) and index their files where they live: no copy, no symlink, no junction. discover_files walks documents_dir plus each registered root; resolve_source_path maps a stored key back to its file for every consumer (search, citations, wiki, HTTP serve, durable remove). A vanished file is no longer removed on sync: its chunks stay searchable and the dead path surfaces only when something tries to open it. Move detection is preserved, now pairing a reappeared identical file to an absent source and relocating it in place. remove un-registers a root instead of unlinking; the TUI add-cancel path un-registers too. Deletes the whole symlink/junction/hardlink/copy shim (symlinks_supported, _place_source, _create_junction, _hardlink, is_link, remove_link) and the discovery symlink-escape guard; cross-platform reduces to abspaths + os.walk.
Rework discovery, registration, removal, move-detection, and cancel-cleanup tests for the registered-roots model: a vanished file stays indexed, add registers a root instead of copying/symlinking, remove un-registers it, and the wiki source-resolution helpers resolve through resolve_source_path. Swap the mock targets (register_sources / unregister_added_roots) and drop the deleted symlink/junction/copy coverage. Also wrap pre-existing over-length test lines so ruff on tests is clean.
…lver A crafted citation key that climbs out of every allowed root is rejected before any file is read; registered roots (legitimately outside documents_dir) pass. Fix the format-source test to inject the resolver failure at its new site.
Registration records a root instead of copying into documents_dir, so the tests assert the registry (linked_roots) and exercise root-vs-root collision. Set data_root in the fixture so the registry persists to a temp config.toml.
- Reject registering a root that nests under/over an existing root or is an ancestor of documents_dir, which would index the same file under two keys. - Never let a registered label shadow an owned documents_dir entry, even under --force, so resolve_source_path can't diverge from how discovery keyed it. - Make register/unregister an atomic read-modify-write of the persisted registry under the config lock (settings.mutate_value), so two processes registering roots concurrently can't lose each other's entry. - Replace a tautological reindex test with one asserting the store's recorded hash reflects the edit; add nesting/ancestor/owned-shadow/merge coverage. - Fix stale symlink-era wording in api.py and docs, and fold function-local app.ingest re-imports into the existing top-level import.
…t in its fixture Force now re-points a registered root instead of overwriting an owned documents_dir file (a label never shadows an owned entry). Also set data_root in the server test fixture so registration persists to a temp config.toml, not the real one.
…aken, empty no-ops, remove confirm
…nder, cgroup-v2 malformed, TUI relocated notify
…s tunable (#596) * Keep the embed fleet warm during ingest, and make its saturation knobs tunable bb-opgxa: a bulk ingest fans embed requests unevenly across replicas, so a briefly-idle replica hit engine_idle_ttl_minutes, unloaded, and reloaded cold mid-run — a connection-refused that snowballed into a fleet collapse. sync() now holds the whole fleet resident (ttl 0) for the batch via a ContextVar the fleet spawn reads (propagates into the ingest thread pool), so no replica idle-unloads while ingest is running. bb-emkt0: the two hardcoded knobs that starve a multi-GPU embed fleet are now config fields — embed_batch_sequences (was a fixed 64; larger batches keep a card's continuous-batching slots full) and ingest_max_inflight (0 = the CPU-bound auto cap, or an explicit override so more files run their compute phase and every least-loaded replica stays fed). Defaults preserve current behavior; the values that lift multi-GPU utilization are set from fleet measurement. * Make ingest_max_inflight lift the worker-pool ceiling so it works in adaptive mode The admission gate's permit_max is max_workers() in adaptive mode, so a raised ingest_max_inflight alone was clamped back to 32 and a multi-GPU fleet stayed starved. Size the pool to the override too, so the single knob lifts admission in both static and adaptive concurrency. * Auto-scale ingest admission with the detected embed fleet (no manual cap) The auto admission cap was CPU-bound (max(cores//2, replicas), then clamped to 32 by the worker pool), so a many-core multi-GPU box pinned ~4 files per card and starved the fleet. Now, when ingest_max_inflight is unset, admission and the worker pool scale with the detected embed replicas (replicas x per-replica in-flight), so an 8-GPU box is kept fed automatically. The config/env override stays for expert tuning; the single-card path is unchanged (CPU sizing fits).
…observable (#599) * Reserve the picked embed replica atomically to stop the dispatch thundering herd The fleet router picks the least-in-flight replica but only bumps that replica's counter once the HTTP call starts, so under a bulk ingest many threads select the same momentarily-idlest replica at once and pile onto it -- leaving the other cards idle (measured 10-98% per-card util on 8x A100, capping throughput well short of linear). Reserve the slot inside the same critical section as the pick so concurrent selectors see the assignment and spread across replicas; release it when the call resolves. Applies to the embed/rerank failover path. * Log plan-pass progress so a large piped ingest is not silent The plan pass (walk + stat + hash of every file) can run for tens of minutes on a multi-million-file corpus, while the Rich progress bar renders nothing without a TTY and piped stdout is block-buffered, so a headless run looks hung. It now logs periodic progress for both the discovery walk and the classify/hash pass: files examined, rate, and (once the total is known) an ETA. The one-shot line reporting the auto-chosen ingest concurrency moves to the same level for the same reason. The lines go out at WARNING, not INFO. The default LILBEE_LOG_LEVEL is WARNING, so an INFO line would be filtered before reaching any handler and a headless sync would stay exactly as silent, which is the failure this exists to fix. The same reasoning already governs the sync-time hint in wiki/ingest.py. Both heartbeats are interval-gated, so a short sync stays quiet. * Fix the admission-scaling test and cover the fleet-probe fallback The auto-scaling admission change (#596) raised the no-vision concurrency to roughly eight in-flight per embed replica, but its unit test still asserted the old one-per-replica value and the fleet-probe except path had no coverage, so the 100% gate was red on both. Update the assertion to the per-replica target and add a test that forces the replica probe to raise and asserts the fallback to zero.
Brings in the shared-engine work (#547), the raised single-machine fleet ceilings and off-daemon crawl conversion (#583), the orphaned-engine cleanup (#589), and the deterministic vision-dispatch tests (#591). Conflict resolutions: - settings_map.py: additive; keep ingest_workers alongside main's new mcp_tool_threads and crawl_convert_workers. - provider.py routing helpers: additive; keep the dispatch reserve (_ROUTE_LOCK / _reserve_least_in_flight) beside main's shared-engine ladder helpers. - provider.py _warm_ttl_seconds: adopt main's decoupling (keep_engine_warm now controls bind_lifetime, not the idle timer) and layer only the ingest-warmth override on top, so an active ingest still forces ttl 0 while a merely-warm idle engine unloads on the normal window. - test_fleet_provider._FakeReplica: additive; keep reserve/release beside main's close. - test_ingest_warmth: update the keep_engine_warm case to main's semantics (governs lifetime, not ttl).
tobocop2
marked this pull request as ready for review
July 23, 2026 03:51
tobocop2
added a commit
that referenced
this pull request
Jul 23, 2026
8x A100 full-corpus ingest degraded from 138 to ~58 docs/sec (8->3 active GPUs) at 26%. Root cause: embed fleet launches llama-servers with ttl=300 (llama-swap idle-unload), and the single-threaded sync dispatcher (admission capped at 32) does not keep all 8 replicas warm -- idle replicas hit the TTL, unload, then throw connection-refused on cold reload, collapsing the fleet. Not OOM, not a crash. PR #590 (parallel dispatch) plus ttl=0 during ingest is the fix. Pod stopped natively (disk preserved, sync is resumable). Big raw logs gitignored; kept local for troubleshooting.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Adding local files copied or symlinked them into the documents directory. The copy wasted disk and stalled ingest behind a serial byte-copy before any GPU work; the symlink variant needed a Windows privilege and a junction/hard-link/copy fallback ladder to stay cross-platform. Either way lilbee managed a second on-disk representation of files the user already had. Removal also hard-deleted source bytes, discovery/hashing ran single-threaded on large corpora, and a moved file was torn down and re-embedded.
Solution
addnow registers a source root in config (label to absolute path) and lilbee indexes the files where they live: no copy, no symlink, no junction. Discovery walks the documents directory plus each registered root, and oneresolve_source_pathmaps a stored key back to its file for every consumer (search, citations, wiki, the HTTP serve endpoint, durable remove). Cross-platform reduces to abspaths andos.walk.A vanished file is no longer removed on sync: its chunks stay searchable and the dead path only surfaces when something tries to open it. Move detection is preserved, now pairing a reappeared identical file to an absent source and relocating it in place rather than re-embedding.
Removal stays index-only and durable (skip-marker keyed on the source hash) and expands folder names and glob patterns; removing a registered root un-registers it.
delete_filesis gone from every surface, so source bytes are never deleted. Discovery and hashing run across a container-aware CPU budget, tunable per run withadd --max-cpus.