fix(indexer): cap chunk length so an unsplittable line cannot OOM the daemon - #273
Open
m-bo-one wants to merge 1 commit into
Open
fix(indexer): cap chunk length so an unsplittable line cannot OOM the daemon#273m-bo-one wants to merge 1 commit into
m-bo-one wants to merge 1 commit into
Conversation
… daemon RecursiveSplitter treats chunk_size as a target, not a bound: a line holding no separator it recognises comes back whole. A Godot .tscn stores a packed array as one 60,942-character line, and that chunk reached the embedder intact. Attention is quadratic in sequence length, so indexing that one file took the daemon from 0.8 GB to 106 GB of private commit in three seconds and killed it, leaving an empty log — the "it hangs and eats all the RAM" report. cap_chunk_size() cuts every oversized chunk down to the ceiling, carrying line, column and byte offsets forward so the pieces still report where they came from. It runs on custom chunkers too, since returning a whole file as one chunk is a documented use and must not be able to kill the process. Pieces are evened out rather than cut at the limit with a remainder: a tail of a few characters would be an embedding of noise, and nothing else in the pipeline emits a chunk below MIN_CHUNK_SIZE. The ceiling defaults to CHUNK_SIZE rather than a multiple of it, for the same quadratic reason: at 4x the same file still peaked at 32 GB, because the embedder pads a batch of up to 64 to its longest member. Every other chunk measured across two real repositories was at most 1000 characters, so this only fires on the pathological line it exists for. Tests cover three levels: cap_chunk_size on its own, the real RecursiveSplitter (one test records that it does exceed its own chunk_size on such a line, so a future fix upstream is noticed rather than silently making this dead code), and an end-to-end index run asserting no row in the index exceeds CHUNK_SIZE. The last one was checked by disabling the call: it fails with 57929 <= 1000. Measured after the fix: that file indexes in 16 s at an 11 GB peak, and the repository it came from (413 files, 9811 chunks) in 22 s, where it previously reached 76 GB of commit and died mid-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
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
RecursiveSplittertreatschunk_sizeas a target, not a bound. A line containing no separator itrecognises comes back as one chunk, however long the line is, and
process_filehands that chunkstraight to
embedder.embed().That is fatal rather than wasteful.
SentenceTransformerEmbedder._embedbatches up to 64 texts andpads the batch to its longest member, and attention costs O(n²) in that length. A 60,000-character
chunk is not 60x a normal one, it is thousands of times the memory.
Where it showed up: a Godot project.
.tscnscene files store packed arrays — vertices, polygons,navigation meshes — as a single line, and a mid-sized scene reaches 60,000 characters on one line
easily. Nothing about the bug is Godot-specific, though: minified JS or CSS, a long JSON literal, a
base64 blob or a generated lookup table all take the same path.
How it surfaced
The report was "indexing hangs, then eats all the RAM", and
daemon.logended mid-session with notraceback, because the process is killed before anything is written. What made it findable:
stays modest because most of the growth never becomes resident before the kill.
trigger. Indexing files individually put the whole failure on one 472 KB scene file: 0.8 GB → 106
GB of commit in three seconds, dead process.
characters from a splitter asked for 1,000. Every other chunk in that repository and in a second,
unrelated one measured at most 1,000, so this was the single input out of contract.
Reproduction, deterministic and instant:
Write that string into a file inside an indexed project and
ccc indexreproduces the crash end toend.
Fix
cap_chunk_size()cuts every chunk down tolimit, defaulting toCHUNK_SIZE, and carries line,column, character and byte offsets forward so the pieces still report where they came from. When
nothing is oversized it returns the input list unchanged, so the ordinary path allocates nothing.
use of
CHUNKER_REGISTRY, and that should not be able to kill the daemon either.of 669, not
1000, 1000, 7. A seven-character tail would be an embedding of noise, and nothingelse in the pipeline emits a chunk below
MIN_CHUNK_SIZE.CHUNK_SIZEand not a multiple of it: atCHUNK_SIZE * 4the same file stillpeaked at 32 GB, since with
max_batch_size=64four times the sequence length is sixteen times theattention matrix.
Validation
Windows 11, RTX 5090,
nomic-ai/CodeRankEmbedoncuda:0, 61.6 GB RAM. Peak is private commit.The third row is the control: with nothing over the ceiling the run is identical, which is what the
early return guarantees.
Tests are at three levels, since a unit test on
cap_chunk_sizealone would still pass if the callwere dropped from
process_file:tests/test_chunk_cap.py, 7 unit cases: pass-through, the even cut, the worst case for evening out(one character over the ceiling), position carry-over across a newline, byte offsets on multi-byte
text, a caller-supplied limit, and the real 60,942-character shape.
RecursiveSplitter. One asserts that it does exceed its ownchunk_sizeon such a line, so if that is fixed upstream this test fails and reports the cap asdead code instead of leaving it in place forever.
tests/test_e2e.py::test_session_caps_oversized_chunks: a fullinit+indexrun, asserting norow in the index exceeds
CHUNK_SIZE. Checked by disabling the call, where it fails withassert 57929 <= 1000. Adds about 8 s to the suite.uv run pytest— 326 passed, 5 skipped, 8 deselected.uv run prek run --all-files— clean.(
test_file_walk.py::test_max_file_size_keeps_unstattable_filesfails on my machine onmainaswell: it creates a symlink and Windows refuses without the privilege,
WinError 1314.)Compatibility
No configuration, no migration, no API change. Chunk IDs change only for files that previously
produced an oversized chunk, since those become several rows — such files reindex once and become
searchable at a useful granularity, which they were not before.
Notes for reviewers
RecursiveSplittercould fall back to a hard character cut whenit runs out of separators, and then no consumer would need this. I put the fix here because this is
where the ceiling is known and where the crash lands, but I am happy to move it into the engine
instead, or to open the issue there — say which you prefer.
CHUNK_SIZE, already the pipeline's chunkcontract, and a separate knob would invite a value that reintroduces the crash.
limitstays aparameter only so the tests can drive it.
to fold it into one line per file if you prefer.