Throttle background auto-vacuum to at most once per 5 minutes
This commit is contained in:
parent
df8af54298
commit
5d5d87d44c
4 changed files with 92 additions and 13 deletions
|
|
@ -4,6 +4,7 @@
|
|||
### Changed
|
||||
|
||||
- Mutable document attributes (`uri`, `title`, `metadata`, `created_at`, `updated_at`) moved from the `documents` table into a new `document_meta` table (1:1 on `document_id`); metadata/title/`source_revision` updates no longer rewrite the docling blobs. Migration `v0_58_0` relocates existing data and runs a one-time `vacuum` to reclaim prior bloat.
|
||||
- Background auto-vacuum is throttled to at most once per 5 minutes; a final vacuum on close collapses any throttled writes. Sustained ingestion no longer triggers back-to-back compaction of the `documents` table.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from datetime import datetime
|
|||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import TYPE_CHECKING, overload
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -40,6 +41,12 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Throttle for the background auto-vacuum: under sustained ingestion, scheduling
|
||||
# a compaction on every write degenerates into back-to-back optimize() passes
|
||||
# that churn the blob-bearing documents table. Fire at most one per interval; a
|
||||
# final vacuum on close collapses anything throttled here.
|
||||
_VACUUM_MIN_INTERVAL_S = 300.0
|
||||
|
||||
|
||||
class RebuildMode(Enum):
|
||||
"""Mode for rebuilding the database."""
|
||||
|
|
@ -87,6 +94,8 @@ class HaikuRAG:
|
|||
self._read_only = read_only
|
||||
self._before = before
|
||||
self._vacuum_tasks: set[asyncio.Task] = set()
|
||||
self._last_vacuum_at: float | None = None
|
||||
self._vacuum_dirty = False
|
||||
|
||||
@property
|
||||
def is_read_only(self) -> bool:
|
||||
|
|
@ -137,17 +146,20 @@ class HaikuRAG:
|
|||
return False
|
||||
|
||||
async def _await_vacuum_tasks(self) -> None:
|
||||
"""Drain background vacuum work before tearing down the connection.
|
||||
"""Drain background vacuum work and run a final collapse before teardown.
|
||||
|
||||
Each create_document / update_document can schedule its own vacuum task;
|
||||
all must be awaited, not just the most recently scheduled one. Vacuum
|
||||
skips when another is already running, so the cleanup for the final
|
||||
writes may have been a no-op. Run one more pass once the in-flight tasks
|
||||
are done to collapse versions created after the last vacuum took the lock.
|
||||
Writes schedule a throttled background vacuum; many are debounced or skip
|
||||
because another vacuum holds the lock. The final pass collapses the
|
||||
versions those left behind. It runs whenever writes happened
|
||||
(``_vacuum_dirty``) — not gated on in-flight tasks remaining, since a
|
||||
debounced run may have scheduled none — but never when nothing was
|
||||
written (so opening + closing a store still never writes).
|
||||
"""
|
||||
if not self._vacuum_tasks:
|
||||
if self._vacuum_tasks:
|
||||
await asyncio.gather(*self._vacuum_tasks, return_exceptions=True)
|
||||
if not self._vacuum_dirty:
|
||||
return
|
||||
await asyncio.gather(*self._vacuum_tasks, return_exceptions=True)
|
||||
self._vacuum_dirty = False
|
||||
# __aexit__ runs during exception unwinding; a raising vacuum here would
|
||||
# mask the original exception, so the drain stays best-effort.
|
||||
try:
|
||||
|
|
@ -156,7 +168,19 @@ class HaikuRAG:
|
|||
logger.debug("Final vacuum on close failed", exc_info=True)
|
||||
|
||||
def _schedule_vacuum(self) -> None:
|
||||
"""Schedule a background vacuum and track the task for later awaiting."""
|
||||
"""Schedule a background vacuum, throttled to at most one per
|
||||
``_VACUUM_MIN_INTERVAL_S``. Sustained writes would otherwise trigger
|
||||
back-to-back compaction of the blob-bearing documents table. The throttle
|
||||
only skips the background task — ``_vacuum_dirty`` still marks that a
|
||||
final vacuum on close is owed."""
|
||||
self._vacuum_dirty = True
|
||||
now = monotonic()
|
||||
if (
|
||||
self._last_vacuum_at is not None
|
||||
and now - self._last_vacuum_at < _VACUUM_MIN_INTERVAL_S
|
||||
):
|
||||
return
|
||||
self._last_vacuum_at = now
|
||||
task = asyncio.create_task(self.store.vacuum())
|
||||
self._vacuum_tasks.add(task)
|
||||
task.add_done_callback(self._vacuum_tasks.discard)
|
||||
|
|
|
|||
56
tests/test_vacuum_debounce.py
Normal file
56
tests/test_vacuum_debounce.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import haiku.rag.client as client_mod
|
||||
from haiku.rag.client import HaikuRAG
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_vacuum_is_debounced(temp_db_path, monkeypatch):
|
||||
"""Rapid writes within the throttle window schedule only one background
|
||||
vacuum; once the interval elapses, a new one is scheduled."""
|
||||
t = {"now": 1000.0}
|
||||
monkeypatch.setattr(client_mod, "monotonic", lambda: t["now"])
|
||||
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
calls: list[int] = []
|
||||
|
||||
async def fake_vacuum(*_a, **_k):
|
||||
calls.append(1)
|
||||
|
||||
monkeypatch.setattr(client.store, "vacuum", fake_vacuum)
|
||||
|
||||
for _ in range(3):
|
||||
client._schedule_vacuum()
|
||||
await asyncio.gather(*client._vacuum_tasks)
|
||||
assert len(calls) == 1 # debounced within the interval
|
||||
|
||||
t["now"] += client_mod._VACUUM_MIN_INTERVAL_S + 1
|
||||
client._schedule_vacuum()
|
||||
await asyncio.gather(*client._vacuum_tasks)
|
||||
assert len(calls) == 2 # interval elapsed -> a new vacuum scheduled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debounced_writes_still_collapse_on_close(temp_db_path, monkeypatch):
|
||||
"""Even when scheduled vacuums after the first are debounced, the writes are
|
||||
marked dirty so the close-time drain runs a final collapse."""
|
||||
t = {"now": 1000.0}
|
||||
monkeypatch.setattr(client_mod, "monotonic", lambda: t["now"])
|
||||
calls: list[int] = []
|
||||
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
|
||||
async def fake_vacuum(*_a, **_k):
|
||||
calls.append(1)
|
||||
|
||||
monkeypatch.setattr(client.store, "vacuum", fake_vacuum)
|
||||
|
||||
client._schedule_vacuum() # schedules the first background pass
|
||||
client._schedule_vacuum() # debounced (no task)
|
||||
|
||||
await client._await_vacuum_tasks()
|
||||
# one scheduled background pass + one final collapse on drain
|
||||
assert len(calls) == 2
|
||||
assert client._vacuum_dirty is False
|
||||
|
|
@ -334,10 +334,8 @@ async def test_close_suppresses_failing_drain_vacuum(temp_db_path, monkeypatch):
|
|||
calls.append(1)
|
||||
raise RuntimeError("vacuum boom")
|
||||
|
||||
# A finished task in the set forces the drain branch to run.
|
||||
task = asyncio.create_task(asyncio.sleep(0))
|
||||
await task
|
||||
client._vacuum_tasks.add(task)
|
||||
# Writes happened, so close owes a final vacuum — force that drain branch.
|
||||
client._vacuum_dirty = True
|
||||
monkeypatch.setattr(client.store, "vacuum", boom)
|
||||
|
||||
# Must not raise despite the drain vacuum erroring.
|
||||
|
|
|
|||
Loading…
Reference in a new issue