Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_retain_memory_budget.py
T
Nicolò Boschi e65973b1d9 fix(retain): bound retain's memory by a budget instead of by the document (#3756) (#3763)
* fix(retain): bound retain's memory by a budget instead of by the document (#3756)

Retaining one large document held state proportional to the document rather
than to a working set. The reported peak was blamed on embeddings held as
list[float]; measured, that was not where it went. Two whole-document
operations dominated, both running before a single fact or embedding existed.

Peak Python bytes allocated, 45 MB body (tracemalloc, order-independent):

                          before     after
  sizing   count_tokens   384.7 MB     9.6 MB   (windowed; flat at any size)
  chunking split_text     200.8 MB     0.0 MB   (streamed; flat at any size)

The facts themselves were never the problem: the streaming pipeline already
bounds them to retain_chunk_batch_size chunks, ~1700 facts, ~21 MB.

Four changes, and a bound that is now explicit:

1. count_tokens_windowed() sizes a body a megabyte at a time instead of
   building one boxed int per token. Every caller compares against a batch
   budget or logs the number, so the one-token-per-window boundary error
   (45 tokens in 11.6M) is unobservable.

2. iter_chunks() streams chunks instead of materialising them, including a
   lazy re-implementation of the RecursiveCharacterTextSplitter configuration
   retain used. langchain leaves the runtime dependencies with it, kept only
   as the reference the differential test diffs against.

   Boundaries are content hashes for delta retain and chunk_ids by index, so
   test_chunking_streams.py pins the output against langchain directly rather
   than trusting the rewrite.

3. An oversized item's body is Memory Defense screened AND content-hashed
   once, not once per slice. That hash was the last piece of work scaling
   with (sub-batches x document size): ~0.9s repeated ~1,200 times for a
   45 MB body, about 18 minutes spent re-deriving one value.

4. ProcessedFact.embedding is array("f") rather than list[float] - 1,616
   bytes against 12,344 for 384 dims. float32 is what pgvector stores, so
   the rounding just happens one step earlier and the stored bytes match.

RetainMemoryBudget then turns the pipeline's bound from a count of chunks
into a ceiling in bytes (HINDSIGHT_API_RETAIN_MEMORY_BUDGET_MB, default 128).
A count is only a memory bound if chunks cost a predictable amount, and they
do not - a chunk carries however many facts the extractor found in it. The
producer reserves a chunk's estimated cost before queueing and the consumer
releases it once written, so over budget extraction waits for the write path
instead of growing.

What still costs a copy of the document is the document: the submitted string
and the sub-batch slices cut from it (45.7 MB for a 45 MB body). Nothing in
the front half is superlinear any more, and nothing but those two scales at
all.

Speed is unchanged or better - sizing 2.42s -> 2.46s, chunking 0.29s -> 0.16s
on a 45 MB body.

Measured by hindsight-dev/benchmarks/perf/retain_memory.py, which reports
tracemalloc rather than RSS: RSS cannot attribute an allocation to the code
that made it (arenas are mapped on first touch and reused silently), and
reading it that way is what made this issue's original diagnosis wrong twice.

* test(retain): pin the new chunker against the one it replaced, over a corpus (#3756)

The streaming chunker was diffed against langchain only for the plain-text
splitter, on hand-written strings. That leaves the paths retain actually takes
on real input — JSON conversations, JSONL logs, the structured-limit branch —
covered by nothing but the assumption that they were untouched.

This runs the whole pre-#3756 `chunk_text`, copied verbatim and still calling
langchain, against the live one over 21 document shapes x 7 chunk sizes, the
same again across three structured-chunk-size settings, and 200 seeded random
documents. It also asserts `iter_chunks` and `chunk_text` agree everywhere and
that every emitted chunk re-chunks to itself (#2301's invariant).

Why the copy is verbatim and must stay that way: the moment the reference
shares a code path with the live implementation the comparison proves nothing.
`test_the_legacy_reference_really_is_the_old_implementation` pins the reference
to langchain's own output so that cannot happen quietly.

Verified non-vacuous by mutation: shifting the packer's budget by one character
fails 41 of the 126 tests. (An exactly-budget piece re-packs to itself whichever
branch it takes, so `<` vs `<=` is an equivalent mutation rather than an
uncaught one — noted in the corpus helper so nobody re-derives it.)

* fix(retain): four regressions CI caught in the memory-budget work (#3756)

1. estimate_chunk_bytes() called len() on a null context and failed the retain
   it was sizing. ProcessedFact.context is annotated str, but a converted file
   upload retains without one and puts None there — every file-retain test
   returned HTTP 500. A budget heuristic must never be able to break the
   operation, so every string now goes through a None-tolerant helper.

2. RetainMemoryBudget accepted a non-int limit and hung. A MagicMock config
   makes every comparison inside it truthy, so the producer waited for room
   that could never be reported — test_consumer_failure_cancels_in_flight_
   extractions timed out at 300s instead of failing. It now rejects a limit
   that is not an int, so a mis-mocked config is a clear TypeError rather than
   a wedged retain, and the test models the field it needs.

3. Two async-batch tests stubbed chunk_text, which retain no longer calls;
   they patch iter_chunks now, which covers both forms.

4. One mapping test compared ProcessedFact.embedding against a float list.
   It is packed since this branch — compare the unpacked values, since what
   the assertion is about is which facts survived, not the container.
2026-08-24 15:03:07 +02:00

232 lines
8.1 KiB
Python

"""The retain pipeline is bounded in bytes, not just in chunks (issue #3756).
``retain_chunk_batch_size`` bounds how many chunks the streaming pipeline holds, which is
only a memory bound if chunks cost a predictable amount — and they do not, because a chunk
carries however many facts the extractor found in it. ``RetainMemoryBudget`` puts a ceiling
on what those chunks weigh, so a worker can be sized against a number that means something.
The properties that have to hold: the budget must actually block a producer that has run
ahead, it must never block one that would otherwise make no progress, and it must give back
exactly what was taken.
"""
import asyncio
from array import array
import pytest
from hindsight_api.engine.retain.memory_budget import (
RetainMemoryBudget,
estimate_chunk_bytes,
)
from hindsight_api.engine.retain.types import ChunkMetadata, ExtractedFact, ProcessedFact
_MB = 1024 * 1024
def _fact(text: str = "Ada shipped the parser", context: str = "standup notes", dims: int = 384) -> ProcessedFact:
return ProcessedFact(
fact_text=text,
fact_type="world",
embedding=array("f", [0.01] * dims),
occurred_start=None,
occurred_end=None,
mentioned_at=None,
context=context,
metadata={},
)
def _extracted(text: str = "Ada shipped the parser") -> ExtractedFact:
return ExtractedFact(fact_text=text, fact_type="world", context="standup notes")
def _meta(chunk_text: str = "x" * 1500) -> ChunkMetadata:
return ChunkMetadata(chunk_text=chunk_text, fact_count=1, content_index=0, chunk_index=0)
# ---------------------------------------------------------------------------
# estimate_chunk_bytes
# ---------------------------------------------------------------------------
def test_estimate_counts_the_things_that_actually_scale():
"""Text, context, embedding and chunk text — the parts that grow with extraction."""
small = estimate_chunk_bytes([_fact()], [_extracted()], [_meta()])
more_facts = estimate_chunk_bytes([_fact()] * 10, [_extracted()] * 10, [_meta()])
bigger_vectors = estimate_chunk_bytes([_fact(dims=1536)], [_extracted()], [_meta()])
longer_text = estimate_chunk_bytes([_fact(text="y" * 5000)], [_extracted()], [_meta()])
assert more_facts > small * 5
assert bigger_vectors > small
assert longer_text > small
def test_estimate_of_nothing_is_nothing():
"""A chunk that yielded no facts reserves nothing."""
assert estimate_chunk_bytes([], [], []) == 0
def test_estimate_tolerates_a_null_context():
"""A retain with no context must not be failed by its own memory estimate.
``ProcessedFact.context`` is annotated ``str``, but a converted file upload retains
without one and puts ``None`` there. The first version of this estimator called
``len()`` on it and turned every file retain into an HTTP 500 — a heuristic breaking
the operation it exists to protect.
"""
fact = _fact()
fact.context = None # type: ignore[assignment]
raw = _extracted()
raw.context = None # type: ignore[assignment]
assert estimate_chunk_bytes([fact], [raw], [_meta()]) > 0
def test_estimate_tracks_embedding_width():
"""A 1536-dim model costs 4x a 384-dim one per fact, and the estimate says so."""
narrow = estimate_chunk_bytes([_fact(dims=384)], [], [])
wide = estimate_chunk_bytes([_fact(dims=1536)], [], [])
assert wide - narrow == (1536 - 384) * 4
# ---------------------------------------------------------------------------
# RetainMemoryBudget
# ---------------------------------------------------------------------------
async def test_reservations_under_the_budget_never_wait():
budget = RetainMemoryBudget(limit_bytes=_MB)
for _ in range(4):
await asyncio.wait_for(budget.reserve(100_000), timeout=1)
assert budget.held_bytes == 400_000
async def test_a_producer_over_the_budget_waits_for_the_consumer():
"""The point of the whole thing: extraction throttles instead of the worker dying."""
budget = RetainMemoryBudget(limit_bytes=1000)
await budget.reserve(900)
blocked = asyncio.create_task(budget.reserve(500))
await asyncio.sleep(0)
assert not blocked.done(), "a reservation past the budget should not have been admitted"
budget.release(900)
await asyncio.wait_for(blocked, timeout=1)
assert budget.held_bytes == 500
async def test_an_oversized_chunk_is_admitted_rather_than_deadlocking():
"""Nothing else is holding memory, so nobody can free any — admit it and move on.
A single chunk whose facts exceed the entire budget is possible (a dense chunk, a wide
embedding model, a small budget). Blocking it would wait forever for room that only it
could release.
"""
budget = RetainMemoryBudget(limit_bytes=1000)
await asyncio.wait_for(budget.reserve(50_000), timeout=1)
assert budget.held_bytes == 50_000
async def test_an_oversized_chunk_still_waits_its_turn():
"""It is admitted when the pipeline is empty — not while someone else holds memory."""
budget = RetainMemoryBudget(limit_bytes=1000)
await budget.reserve(600)
blocked = asyncio.create_task(budget.reserve(50_000))
await asyncio.sleep(0)
assert not blocked.done()
budget.release(600)
await asyncio.wait_for(blocked, timeout=1)
async def test_release_gives_back_exactly_what_was_taken():
budget = RetainMemoryBudget(limit_bytes=_MB)
await budget.reserve(300)
await budget.reserve(700)
budget.release(300)
assert budget.held_bytes == 700
budget.release(700)
assert budget.held_bytes == 0
async def test_over_release_cannot_drive_the_budget_negative():
"""A double release must not manufacture headroom that does not exist."""
budget = RetainMemoryBudget(limit_bytes=_MB)
await budget.reserve(100)
budget.release(100)
budget.release(100)
assert budget.held_bytes == 0
async def test_disabling_the_budget_restores_the_count_only_bound():
"""``0`` means "I have tuned the chunk count myself" — nothing waits, nothing flushes."""
budget = RetainMemoryBudget(limit_bytes=0)
await asyncio.wait_for(budget.reserve(10 * _MB), timeout=1)
assert not budget.enabled
assert budget.held_bytes == 0
assert budget.should_flush(10 * _MB) is False
def test_should_flush_at_half_the_budget():
"""Half for the open batch, half for the producer to keep extracting into."""
budget = RetainMemoryBudget(limit_bytes=1000)
assert budget.should_flush(499) is False
assert budget.should_flush(500) is True
async def test_many_producers_against_one_consumer_stay_within_the_budget():
"""Whatever the interleaving, the pipeline never holds more than it reserved for.
Models the real shape: many extraction tasks handing work to a single writer. The
invariant is that held bytes never exceed the budget while more than one chunk is in
flight — the oversized-chunk escape hatch only applies to an empty pipeline.
"""
budget = RetainMemoryBudget(limit_bytes=10_000)
peak = 0
committed = 0
async def produce(cost: int) -> None:
nonlocal peak
await budget.reserve(cost)
peak = max(peak, budget.held_bytes)
await asyncio.sleep(0)
async def consume(costs: list[int]) -> None:
nonlocal committed
for cost in costs:
await asyncio.sleep(0)
budget.release(cost)
committed += cost
costs = [1500] * 40
producers = asyncio.gather(*(produce(cost) for cost in costs))
consumer = asyncio.create_task(consume(costs))
await asyncio.wait_for(asyncio.gather(producers, consumer), timeout=5)
assert committed == sum(costs)
assert budget.held_bytes == 0
# 10,000 budget with 1,500-byte chunks admits 6 before blocking the 7th.
assert peak <= 10_000
@pytest.mark.parametrize("limit_mb", [1, 16, 128])
def test_configured_budget_converts_to_bytes(limit_mb: int):
"""The config is in MB because that is the unit a worker limit is written in."""
budget = RetainMemoryBudget(limit_bytes=limit_mb * _MB)
assert budget.enabled
assert budget.limit_bytes == limit_mb * 1024 * 1024