haiku.rag/tests/store/test_vacuum_bounded.py
Yiorgis Gozadinos 581ddf4f41
Bound vacuum compaction memory on the tables holding docling blobs
Vacuum compacts documents and document_items through lance directly
with a fragment target derived from storage.compaction_target_bytes
(default 2 GiB), instead of AsyncTable.optimize, whose default
1,048,576-row fragment target re-merges the whole table on every pass
and peaks at 5-6x the table's bytes. The target is sized from the
widest fragment's bytes per row, so peak memory is bounded by the
target instead of the corpus. lancedb's async API exposes no
compaction options (lancedb/lancedb#2325), hence the pylance
dependency.
2026-09-04 10:35:26 +03:00

413 lines
14 KiB
Python

import asyncio
import os
import subprocess
import sys
import textwrap
import threading
from typing import cast
import lance
import pyarrow as pa
import pytest
from pydantic import ValidationError
from haiku.rag.config.models import AppConfig, StorageConfig
from haiku.rag.store.engine import Store, compaction_target_rows
MB = 1024**2
def _write(path, rows, payload_bytes, per_fragment=10):
schema = pa.schema(
[pa.field("id", pa.string()), pa.field("blob", pa.large_binary())]
)
dataset = lance.dataset(path) if os.path.exists(path) else None
for start in range(0, rows, per_fragment):
batch = pa.Table.from_arrays(
[
pa.array(
[f"d{i}" for i in range(start, start + per_fragment)], pa.string()
),
pa.array(
[os.urandom(payload_bytes) for _ in range(per_fragment)],
pa.large_binary(),
),
],
schema=schema,
)
dataset = lance.write_dataset(
batch, path, mode="append" if dataset else "create"
)
return dataset
def test_target_scales_inversely_with_row_size(tmp_path):
dataset = _write(tmp_path / "wide.lance", 20, 1 * MB)
assert compaction_target_rows(dataset, 8 * MB) == pytest.approx(8, abs=1)
# halving the budget halves the rows a task may take
assert compaction_target_rows(dataset, 4 * MB) == pytest.approx(4, abs=1)
def test_target_uses_widest_fragment_not_the_average(tmp_path):
"""A few outsized rows must shrink the target for the whole table."""
path = tmp_path / "skewed.lance"
_write(path, 10, 4 * MB)
_write(path, 10, 64 * 1024) # a second, much narrower fragment
dataset = lance.dataset(path)
target = compaction_target_rows(dataset, 16 * MB)
# the average row is ~2 MB, which would allow ~8 rows; the widest is 4 MB
assert target is not None and target <= 5
def test_target_floors_at_one_row(tmp_path):
dataset = _write(tmp_path / "huge.lance", 10, 2 * MB)
assert compaction_target_rows(dataset, 1024) == 1
def test_unmeasurable_table_is_not_compacted(tmp_path):
"""No size information must mean no compaction, not lance's default.
Falling back to the default row target is the unbounded behaviour this
exists to avoid.
"""
schema = pa.schema(
[pa.field("id", pa.string()), pa.field("blob", pa.large_binary())]
)
dataset = lance.write_dataset(schema.empty_table(), tmp_path / "empty.lance")
assert compaction_target_rows(dataset, 1024) is None
class _FakeDataFile:
def __init__(self, size):
self.file_size_bytes = size
class _FakeFragment:
def __init__(self, rows, sizes):
self.physical_rows = rows
self._files = [_FakeDataFile(s) for s in sizes]
def data_files(self):
return self._files
class _FakeDataset:
"""Stands in for a manifest lance 10 will not produce.
Every file lance writes today records its size, so a mixed manifest can only
come from a database written by a much older version.
"""
def __init__(self, fragments):
self._fragments = fragments
def get_fragments(self):
return self._fragments
def test_one_unmeasurable_fragment_disables_the_whole_table():
"""Sizing from the measurable fragments would still hand the other one over
to compaction whole."""
dataset = _FakeDataset(
[
_FakeFragment(rows=10, sizes=[1 * MB]), # measurable and narrow
_FakeFragment(rows=10, sizes=[None]), # size missing
]
)
assert compaction_target_rows(cast("lance.LanceDataset", dataset), 8 * MB) is None
def test_empty_fragments_do_not_block_sizing():
dataset = _FakeDataset(
[
_FakeFragment(rows=0, sizes=[]),
_FakeFragment(rows=10, sizes=[10 * MB]),
]
)
assert compaction_target_rows(cast("lance.LanceDataset", dataset), 8 * MB) == 8
def test_budget_must_be_positive():
with pytest.raises(ValidationError):
StorageConfig(compaction_target_bytes=0)
@pytest.mark.asyncio
async def test_vacuum_leaves_the_handle_usable(temp_db_path):
"""Reads and writes must work through the same handle after a vacuum.
The bounded path mutates the dataset behind the open AsyncTable, so a
missing checkout_latest leaves the handle on a stale version.
"""
config = AppConfig()
config.storage.auto_vacuum = False
async with Store(temp_db_path, config=config, create=True) as store:
await store.documents_table.add(
[{"id": "a", "content": "x", "docling_document": b"0" * MB}]
)
await store.vacuum(retention_seconds=0)
assert await store.documents_table.count_rows() == 1
await store.documents_table.add(
[{"id": "b", "content": "y", "docling_document": b"1" * MB}]
)
assert await store.documents_table.count_rows() == 2
@pytest.mark.asyncio
async def test_handle_is_refreshed_when_a_later_step_fails(temp_db_path, monkeypatch):
"""A committed compaction followed by a failure must not strand the handle."""
config = AppConfig()
config.storage.auto_vacuum = False
async with Store(temp_db_path, config=config, create=True) as store:
await store.documents_table.add(
[{"id": "a", "content": "x", "docling_document": b"0" * MB}]
)
await store.documents_table.add(
[{"id": "b", "content": "y", "docling_document": b"1" * MB}]
)
def explode(dataset):
raise RuntimeError("index optimize failed")
real = Store._run_lance_maintenance
calls = {"n": 0}
async def fail_on_second_step(self, table, step):
calls["n"] += 1
# 1 compact, 2 optimize_indices, 3 prune -- fail after the commit
return await real(self, table, explode if calls["n"] == 2 else step)
monkeypatch.setattr(Store, "_run_lance_maintenance", fail_on_second_step)
with pytest.raises(RuntimeError, match="index optimize failed"):
await store.vacuum(retention_seconds=0)
# the handle must still serve reads and writes despite the failure
assert await store.documents_table.count_rows() == 2
await store.documents_table.add(
[{"id": "c", "content": "z", "docling_document": b"2" * MB}]
)
assert await store.documents_table.count_rows() == 3
# 1 compact, 2 optimize_indices, 3 prune -- all three on the first payload table
@pytest.mark.parametrize("cancel_at", [1, 2, 3])
@pytest.mark.asyncio
async def test_cancelling_vacuum_waits_for_the_running_step(
temp_db_path, cancel_at, monkeypatch
):
"""A worker thread cannot be cancelled, so the step must be awaited.
Releasing the write lock while lance is still mutating the dataset would let
the next write race a commit that is still in flight, so a cancellation has
to wait for the thread rather than abandon it.
"""
config = AppConfig()
config.storage.auto_vacuum = False
async with Store(temp_db_path, config=config, create=True) as store:
await store.documents_table.add(
[{"id": "a", "content": "x", "docling_document": b"0" * MB}]
)
in_thread = threading.Event()
release = threading.Event()
completed: list[int] = []
real = Store._run_lance_maintenance
calls = {"n": 0}
def blocking(dataset):
in_thread.set()
release.wait(30)
completed.append(1)
async def block_on_nth(self, table, step):
calls["n"] += 1
return await real(
self, table, blocking if calls["n"] == cancel_at else step
)
monkeypatch.setattr(Store, "_run_lance_maintenance", block_on_nth)
try:
task = asyncio.create_task(store.vacuum(retention_seconds=0))
while not in_thread.is_set():
await asyncio.sleep(0.01)
task.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
# the step ran to completion despite the cancellation
assert completed == [1]
assert not store._write_lock.locked()
assert await store.documents_table.count_rows() == 1
@pytest.mark.asyncio
async def test_thin_tables_keep_using_optimize(temp_db_path, monkeypatch):
"""Only payload-bearing tables take the sized path."""
config = AppConfig()
config.storage.auto_vacuum = False
optimized: list[str] = []
compacted: list[str] = []
async with Store(temp_db_path, config=config, create=True) as store:
real_optimize = type(store.chunks_table).optimize
async def record_optimize(self, **kwargs):
optimized.append(self.name)
return await real_optimize(self, **kwargs)
async def record_compact(self, table, cutoff):
compacted.append(table.name)
monkeypatch.setattr(type(store.chunks_table), "optimize", record_optimize)
monkeypatch.setattr(Store, "_compact_to_target", record_compact)
await store.vacuum(retention_seconds=0)
assert sorted(compacted) == ["document_items", "documents"]
assert sorted(optimized) == ["chunks", "document_meta", "settings"]
@pytest.mark.asyncio
async def test_unmeasurable_table_still_prunes(temp_db_path, monkeypatch, caplog):
config = AppConfig()
config.storage.auto_vacuum = False
pruned: list[str] = []
async with Store(temp_db_path, config=config, create=True) as store:
await store.documents_table.add(
[{"id": "a", "content": "x", "docling_document": b"0" * MB}]
)
monkeypatch.setattr(
"haiku.rag.store.engine.compaction_target_rows", lambda *_: None
)
real = Store._run_lance_maintenance
async def record(self, table, step):
pruned.append(getattr(step, "__qualname__", "?"))
return await real(self, table, step)
monkeypatch.setattr(Store, "_run_lance_maintenance", record)
with caplog.at_level("WARNING"):
await store.vacuum(retention_seconds=0)
# compaction skipped, pruning still ran, and the skip is visible
assert len(pruned) == 2 # documents and document_items, prune only
assert "sizes are absent" in caplog.text
# The failure is peak RSS while rewriting, and a bounded and an unbounded run
# produce identical files, so only a memory measurement can tell them apart.
_RSS_PROBE = textwrap.dedent(
"""
import asyncio, os, sys, threading, time
from pathlib import Path
import psutil
db, budget, repo = sys.argv[1], int(sys.argv[2]), sys.argv[3]
sys.path.insert(0, repo)
from haiku.rag.config.models import AppConfig
from haiku.rag.store.engine import Store
proc = psutil.Process()
peak = {"v": 0}
def watch():
while True:
peak["v"] = max(peak["v"], proc.memory_info().rss)
time.sleep(0.01)
threading.Thread(target=watch, daemon=True).start()
async def main():
config = AppConfig()
config.storage.auto_vacuum = False
if budget:
config.storage.compaction_target_bytes = budget
peaks = []
async with Store(Path(db), config=config, create=True) as store:
row = 0
for round_no in range(4):
await store.documents_table.add([
{"id": f"d{row + i}", "content": "x",
"docling_document": os.urandom(4 * 1024 * 1024)}
for i in range(8)])
row += 8
if round_no:
# churn: rewrite an old row so deletions accumulate
await store.documents_table.delete(f"id = 'd{round_no}'")
base = peak["v"] = proc.memory_info().rss
await store.vacuum(retention_seconds=0)
peaks.append((peak["v"] - base) / (1024 * 1024))
print(",".join(f"{p:.1f}" for p in peaks))
asyncio.run(main())
"""
)
def _probe(tmp_path, name, budget):
"""Peak RSS in MB per append+churn+vacuum round, in a fresh process."""
out = subprocess.run(
[
sys.executable,
"-c",
_RSS_PROBE,
str(tmp_path / name),
str(budget),
os.getcwd(),
],
capture_output=True,
text=True,
check=True,
)
return [float(p) for p in out.stdout.strip().splitlines()[-1].split(",")]
@pytest.mark.integration
def test_sized_vacuum_does_not_grow_with_the_table(tmp_path):
"""Drives Store.vacuum end to end, with deletions, not lance directly."""
unbounded = _probe(tmp_path, "unbounded.lancedb", 8 * 1024**3)
bounded = _probe(tmp_path, "bounded.lancedb", 24 * MB)
# the unsized pass re-reads everything written so far, so its cost rises
assert unbounded[-1] > unbounded[1]
assert max(bounded) < max(unbounded)
# and the sized one does not trend upward as the table grows
assert max(bounded[2:]) <= max(bounded[1], 1.0) * 1.5
@pytest.mark.integration
def test_a_fragment_larger_than_the_target_is_rewritten_whole(tmp_path):
"""The target sizes what compaction writes, not what it may read.
A fragment written larger than the target -- by a big ingest batch, or by an
older unsized vacuum -- is rewritten in one piece the first time deletions
make it a candidate, costing roughly its own size regardless of the target.
Recorded because it is the ceiling the config knob cannot lower.
"""
path = tmp_path / "batched.lance"
_write(path, 40, 2 * MB, per_fragment=40) # one 80 MB fragment
dataset = lance.dataset(path)
target = compaction_target_rows(dataset, 8 * MB)
assert target is not None and target <= 4
# deletions must pass lance's 10% threshold for the fragment to be a candidate
dataset.delete("id in ('d0','d1','d2','d3','d4','d5')")
dataset = lance.dataset(path)
metrics = dataset.optimize.compact_files(target_rows_per_fragment=target)
# the oversized fragment was read whole and split into target-sized pieces
assert metrics.fragments_removed == 1
assert metrics.fragments_added > 1