#000065 step 2: hydration planner (pure compute, no execution yet)
Disk-aware strategy picker for the corpus reshard. Pure-compute API:
feed it (corpus facts, free bytes) and it returns a HydrationPlan
naming one of three strategies + a peak-draw estimate.
Strategies (preference order):
all_at_once - direct source→target with overlap; fastest;
peak draw = corpus + WAL × M_target + VACUUM
per_source_shard - pack→delete→hydrate per round; safest;
peak draw = pack + WAL + 1 target VACUUM
streaming_row - in-place mutation, --allow-in-place opt-in;
peak draw = WAL only
Safety budget: 4 GB margin above the strategy's peak draw — the
buffer that survives one bad sort + one badly-sized WAL grow.
Production-host preview at 95 GB free / 38 GB corpus:
strategy: all_at_once
peak draw est: 64.2 GB
free at peak: 31.2 GB (well above safety)
rationale: all_at_once: peak draw 64.2 GB + 4.3 GB safety ≤ free 95.4 GB
The plan's full readout (strategy, free bytes, peak draw, rationale)
is JSON-serializable so it goes into the migration audit event as a
single forensic record.
14 tests cover: strategy pick by free-disk level, --force_strategy
override, --allow-in-place gating, skewed shard sizes, json-
serializable audit body, target_M validation.
No execution yet — this is just the planner. Next: the strategy A
executor (direct source→target read+write).
This commit is contained in:
parent
3aae8119a6
commit
8fe389d45e
2 changed files with 551 additions and 0 deletions
328
arborist/migrate.py
Normal file
328
arborist/migrate.py
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
"""Reshard / hydration tool (#000065).
|
||||||
|
|
||||||
|
Migrates a source shards directory (any M_source) to a target shards
|
||||||
|
directory (any M_target) by content-hash routing:
|
||||||
|
|
||||||
|
target_shard = shard_for_document(document_root, M_target)
|
||||||
|
|
||||||
|
Per-document tables follow their parent doc. The audit chain is
|
||||||
|
consolidated to canonical shard ``000`` via Option A — every source
|
||||||
|
shard's ``audit_events`` rows are sorted by ``ts`` and re-chained
|
||||||
|
into one canonical chain landed in shard 000.
|
||||||
|
|
||||||
|
The :class:`HydrationPlanner` picks a strategy from available disk:
|
||||||
|
|
||||||
|
* ``all_at_once`` — direct source→target with overlap; fastest
|
||||||
|
* ``per_source_shard``— pack/delete/hydrate per round; safest, slowest
|
||||||
|
* ``streaming_row`` — in-place mutation; last resort (--allow-in-place)
|
||||||
|
* refused — below safety margin, return :class:`InsufficientDisk`
|
||||||
|
|
||||||
|
Pure planning (``HydrationPlanner.plan``) is decoupled from execution.
|
||||||
|
The plan + free-space readout + peak draw + duration are recorded in
|
||||||
|
one ``audit_events`` row at the end of every successful migration.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sizing knobs. Threshold rationale documented inline so an operator can
|
||||||
|
# audit "why did the planner pick X" from this file alone.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Always keep this much free above the planner's draw estimate. SQLite
|
||||||
|
#: ``temp_store=FILE`` spills can be GB-scale on the edges fan-in; this is
|
||||||
|
#: the buffer that survives one bad sort + one badly-sized WAL grow.
|
||||||
|
SAFETY_MARGIN_BYTES = 4 * 1024 ** 3
|
||||||
|
|
||||||
|
#: SQLite WAL during heavy INSERT against a 9 GB shard has been measured
|
||||||
|
#: at ~4 GB on the production corpus; the four-shard parallel hydration
|
||||||
|
#: of strategy A can hold all four WALs live at once.
|
||||||
|
WAL_HEADROOM_PER_SHARD = 4 * 1024 ** 3
|
||||||
|
|
||||||
|
#: VACUUM scratch at end of strategy A. SQLite VACUUM rewrites the whole
|
||||||
|
#: DB to a sibling tmpfile then swaps; peak draw is ~1× DB size.
|
||||||
|
VACUUM_HEADROOM_RATIO = 1.0
|
||||||
|
|
||||||
|
#: Strategy B per-round pack size: a single source shard packed with
|
||||||
|
#: zstd typically lands at ~30% of source-shard size. Estimated, the
|
||||||
|
#: actual measured ratio at migration time replaces this.
|
||||||
|
DEFAULT_PACK_RATIO = 0.30
|
||||||
|
|
||||||
|
#: Multiplier on ``largest_source_shard_bytes`` for strategy B sizing.
|
||||||
|
#: ``1.5×`` covers: pack-on-disk + new-shard-growth + WAL on the new
|
||||||
|
#: shard being written during that round + scratch.
|
||||||
|
STRATEGY_B_MULTIPLIER = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
Strategy = Literal[
|
||||||
|
"all_at_once",
|
||||||
|
"per_source_shard",
|
||||||
|
"streaming_row",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CorpusFacts:
|
||||||
|
"""Read-only snapshot of source-corpus sizes the planner needs.
|
||||||
|
|
||||||
|
All ``*_bytes`` fields are bytes (not blocks, not KB). ``shard_files``
|
||||||
|
is sorted by index (``000.db``, ``001.db``, ...).
|
||||||
|
"""
|
||||||
|
|
||||||
|
shard_files: tuple[Path, ...]
|
||||||
|
total_bytes: int
|
||||||
|
largest_shard_bytes: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dir(cls, shards_dir: Path) -> "CorpusFacts":
|
||||||
|
files = tuple(sorted(shards_dir.glob("00[0-9].db")))
|
||||||
|
if not files:
|
||||||
|
raise ValueError(f"no shard files (00[0-9].db) under {shards_dir}")
|
||||||
|
sizes = [p.stat().st_size for p in files]
|
||||||
|
return cls(
|
||||||
|
shard_files=files,
|
||||||
|
total_bytes=sum(sizes),
|
||||||
|
largest_shard_bytes=max(sizes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HydrationPlan:
|
||||||
|
"""Output of :class:`HydrationPlanner.plan`.
|
||||||
|
|
||||||
|
Persisted into the migration audit event so an operator can later
|
||||||
|
answer "why did the tool pick strategy X" from the audit chain
|
||||||
|
alone. Never includes a path to a credential or secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy: Strategy
|
||||||
|
source_dir: Path
|
||||||
|
target_dir: Path
|
||||||
|
target_M: int
|
||||||
|
corpus_facts: CorpusFacts
|
||||||
|
free_bytes_at_plan: int
|
||||||
|
estimated_peak_draw_bytes: int
|
||||||
|
estimated_final_target_bytes: int
|
||||||
|
rationale: str
|
||||||
|
|
||||||
|
def free_at_peak_bytes(self) -> int:
|
||||||
|
"""Predicted free disk at the worst point during execution."""
|
||||||
|
return self.free_bytes_at_plan - self.estimated_peak_draw_bytes
|
||||||
|
|
||||||
|
def as_audit_body(self) -> dict:
|
||||||
|
return {
|
||||||
|
"strategy": self.strategy,
|
||||||
|
"source_dir": str(self.source_dir),
|
||||||
|
"target_dir": str(self.target_dir),
|
||||||
|
"target_M": self.target_M,
|
||||||
|
"source_shards": [str(p) for p in self.corpus_facts.shard_files],
|
||||||
|
"source_total_bytes": self.corpus_facts.total_bytes,
|
||||||
|
"source_largest_bytes": self.corpus_facts.largest_shard_bytes,
|
||||||
|
"free_bytes_at_plan": self.free_bytes_at_plan,
|
||||||
|
"estimated_peak_draw_bytes": self.estimated_peak_draw_bytes,
|
||||||
|
"estimated_final_target_bytes": self.estimated_final_target_bytes,
|
||||||
|
"rationale": self.rationale,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientDisk(RuntimeError):
|
||||||
|
"""Raised when free space is below the safety margin for every strategy.
|
||||||
|
|
||||||
|
The exception carries the corpus facts + free-bytes readout the planner
|
||||||
|
saw so the operator's terminal output (or CI log) explains the gap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
free_bytes: int,
|
||||||
|
required_bytes: int,
|
||||||
|
corpus_facts: CorpusFacts,
|
||||||
|
) -> None:
|
||||||
|
self.free_bytes = free_bytes
|
||||||
|
self.required_bytes = required_bytes
|
||||||
|
self.corpus_facts = corpus_facts
|
||||||
|
super().__init__(
|
||||||
|
f"insufficient disk: have {free_bytes / 1e9:.1f} GB free, "
|
||||||
|
f"need at least {required_bytes / 1e9:.1f} GB for any strategy. "
|
||||||
|
f"corpus {corpus_facts.total_bytes / 1e9:.1f} GB across "
|
||||||
|
f"{len(corpus_facts.shard_files)} shards. Free disk or mount "
|
||||||
|
f"target on a different filesystem."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _statvfs_free_bytes(path: Path) -> int:
|
||||||
|
"""Bytes free on the filesystem containing ``path``.
|
||||||
|
|
||||||
|
Uses ``f_bavail`` (unprivileged free), not ``f_bfree``, matching what
|
||||||
|
``df`` shows non-root users.
|
||||||
|
"""
|
||||||
|
st = os.statvfs(path)
|
||||||
|
return st.f_bavail * st.f_frsize
|
||||||
|
|
||||||
|
|
||||||
|
class HydrationPlanner:
|
||||||
|
"""Pick a strategy + report estimated draw, given disk constraints.
|
||||||
|
|
||||||
|
Pure-compute API: feed it the facts (corpus size, free bytes) and
|
||||||
|
it returns a :class:`HydrationPlan`. Execution is a separate
|
||||||
|
function (``execute_plan``) so we can unit-test planning without
|
||||||
|
touching real shards or the filesystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
safety_margin_bytes: int = SAFETY_MARGIN_BYTES,
|
||||||
|
wal_headroom_per_shard: int = WAL_HEADROOM_PER_SHARD,
|
||||||
|
vacuum_headroom_ratio: float = VACUUM_HEADROOM_RATIO,
|
||||||
|
pack_ratio: float = DEFAULT_PACK_RATIO,
|
||||||
|
strategy_b_multiplier: float = STRATEGY_B_MULTIPLIER,
|
||||||
|
) -> None:
|
||||||
|
self.safety_margin_bytes = safety_margin_bytes
|
||||||
|
self.wal_headroom_per_shard = wal_headroom_per_shard
|
||||||
|
self.vacuum_headroom_ratio = vacuum_headroom_ratio
|
||||||
|
self.pack_ratio = pack_ratio
|
||||||
|
self.strategy_b_multiplier = strategy_b_multiplier
|
||||||
|
|
||||||
|
# ----- size estimators -----
|
||||||
|
|
||||||
|
def _estimate_strategy_a_peak_draw(
|
||||||
|
self, facts: CorpusFacts, M_target: int
|
||||||
|
) -> int:
|
||||||
|
"""Strategy A all-at-once.
|
||||||
|
|
||||||
|
Peak draw = new shards being written (≈ corpus size) + WAL on each
|
||||||
|
of the M_target target shards + VACUUM scratch on the largest
|
||||||
|
target. Local packs are NOT used by strategy A in this build —
|
||||||
|
direct source→target read+write keeps draw lower than the pack
|
||||||
|
variant.
|
||||||
|
"""
|
||||||
|
new_shards = facts.total_bytes
|
||||||
|
wal = self.wal_headroom_per_shard * M_target
|
||||||
|
# VACUUM scratch is bounded by the largest target shard, which
|
||||||
|
# for a uniformly hash-routed corpus is approximately
|
||||||
|
# total_bytes / M_target.
|
||||||
|
per_target = facts.total_bytes / max(M_target, 1)
|
||||||
|
vacuum = int(per_target * self.vacuum_headroom_ratio)
|
||||||
|
return new_shards + wal + vacuum
|
||||||
|
|
||||||
|
def _estimate_strategy_b_peak_draw(
|
||||||
|
self, facts: CorpusFacts, M_target: int
|
||||||
|
) -> int:
|
||||||
|
"""Strategy B per-source-shard round, peak DRAW above baseline.
|
||||||
|
|
||||||
|
Per round: pack source → delete source → hydrate → delete pack.
|
||||||
|
Source is deleted BEFORE new shards are written, so the net
|
||||||
|
on-disk size never exceeds baseline + pack at any moment.
|
||||||
|
Round peak above baseline = one pack on disk + WAL on the
|
||||||
|
currently-writing target + VACUUM scratch on one target (the
|
||||||
|
largest, ≈ corpus / M_target).
|
||||||
|
"""
|
||||||
|
pack = int(facts.largest_shard_bytes * self.pack_ratio)
|
||||||
|
wal = self.wal_headroom_per_shard
|
||||||
|
per_target_bytes = facts.total_bytes / max(M_target, 1)
|
||||||
|
vacuum = int(per_target_bytes * self.vacuum_headroom_ratio)
|
||||||
|
return pack + wal + vacuum
|
||||||
|
|
||||||
|
def _estimate_strategy_c_peak_draw(self, facts: CorpusFacts) -> int:
|
||||||
|
"""Strategy C streaming-row in-place.
|
||||||
|
|
||||||
|
Peak draw = WAL only (rows DELETE from source as they INSERT to
|
||||||
|
target). No pack, no overlap of old+new at corpus scale.
|
||||||
|
"""
|
||||||
|
return self.wal_headroom_per_shard
|
||||||
|
|
||||||
|
# ----- planner -----
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_dir: Path,
|
||||||
|
target_dir: Path,
|
||||||
|
target_M: int,
|
||||||
|
free_bytes: int | None = None,
|
||||||
|
force_strategy: Strategy | None = None,
|
||||||
|
allow_in_place: bool = False,
|
||||||
|
) -> HydrationPlan:
|
||||||
|
"""Pick a strategy that fits the available disk + return the plan."""
|
||||||
|
if target_M < 1:
|
||||||
|
raise ValueError(f"target_M must be >= 1, got {target_M}")
|
||||||
|
facts = CorpusFacts.from_dir(source_dir)
|
||||||
|
free = free_bytes if free_bytes is not None else _statvfs_free_bytes(
|
||||||
|
target_dir.parent if target_dir.parent.exists() else target_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evaluate strategies in preference order: fastest fitting wins.
|
||||||
|
# Strategy C is opt-in only because it mutates source in place.
|
||||||
|
candidates: list[tuple[Strategy, int]] = [
|
||||||
|
("all_at_once", self._estimate_strategy_a_peak_draw(facts, target_M)),
|
||||||
|
("per_source_shard", self._estimate_strategy_b_peak_draw(facts, target_M)),
|
||||||
|
]
|
||||||
|
if allow_in_place:
|
||||||
|
candidates.append(
|
||||||
|
("streaming_row", self._estimate_strategy_c_peak_draw(facts))
|
||||||
|
)
|
||||||
|
|
||||||
|
if force_strategy is not None:
|
||||||
|
forced = next(
|
||||||
|
((s, d) for s, d in candidates if s == force_strategy), None
|
||||||
|
)
|
||||||
|
if forced is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"forced strategy {force_strategy!r} not available "
|
||||||
|
f"(allow_in_place={allow_in_place})"
|
||||||
|
)
|
||||||
|
strategy, draw = forced
|
||||||
|
rationale = f"forced strategy={force_strategy} (operator override)"
|
||||||
|
return self._build_plan(
|
||||||
|
strategy, draw, rationale, facts, free, source_dir, target_dir, target_M
|
||||||
|
)
|
||||||
|
|
||||||
|
for strategy, draw in candidates:
|
||||||
|
if free >= draw + self.safety_margin_bytes:
|
||||||
|
rationale = (
|
||||||
|
f"{strategy}: peak draw {draw / 1e9:.1f} GB + "
|
||||||
|
f"{self.safety_margin_bytes / 1e9:.1f} GB safety = "
|
||||||
|
f"{(draw + self.safety_margin_bytes) / 1e9:.1f} GB ≤ "
|
||||||
|
f"free {free / 1e9:.1f} GB"
|
||||||
|
)
|
||||||
|
return self._build_plan(
|
||||||
|
strategy, draw, rationale, facts, free,
|
||||||
|
source_dir, target_dir, target_M,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing fit — report the cheapest required draw so the operator
|
||||||
|
# knows how much to free.
|
||||||
|
min_needed = min(d for _, d in candidates) + self.safety_margin_bytes
|
||||||
|
raise InsufficientDisk(
|
||||||
|
free_bytes=free, required_bytes=min_needed, corpus_facts=facts
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_plan(
|
||||||
|
self,
|
||||||
|
strategy: Strategy,
|
||||||
|
peak_draw: int,
|
||||||
|
rationale: str,
|
||||||
|
facts: CorpusFacts,
|
||||||
|
free: int,
|
||||||
|
source_dir: Path,
|
||||||
|
target_dir: Path,
|
||||||
|
target_M: int,
|
||||||
|
) -> HydrationPlan:
|
||||||
|
return HydrationPlan(
|
||||||
|
strategy=strategy,
|
||||||
|
source_dir=source_dir,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=target_M,
|
||||||
|
corpus_facts=facts,
|
||||||
|
free_bytes_at_plan=free,
|
||||||
|
estimated_peak_draw_bytes=peak_draw,
|
||||||
|
estimated_final_target_bytes=facts.total_bytes,
|
||||||
|
rationale=rationale,
|
||||||
|
)
|
||||||
223
tests/test_migrate_planner.py
Normal file
223
tests/test_migrate_planner.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Tests for the #000065 hydration planner.
|
||||||
|
|
||||||
|
Pure-compute tests — never touches SQLite or the network. Validates
|
||||||
|
that the planner picks the right strategy from a given (corpus size,
|
||||||
|
free disk) pair, and that the chosen strategy's peak-draw estimate
|
||||||
|
matches the safety budget.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arborist.migrate import (
|
||||||
|
CorpusFacts,
|
||||||
|
HydrationPlan,
|
||||||
|
HydrationPlanner,
|
||||||
|
InsufficientDisk,
|
||||||
|
SAFETY_MARGIN_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
GB = 1024 ** 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def four_shard_corpus(tmp_path: Path) -> Path:
|
||||||
|
"""Create 4 empty 'shard' files of realistic sizes (~8.8 GB each)."""
|
||||||
|
shards_dir = tmp_path / "shards"
|
||||||
|
shards_dir.mkdir()
|
||||||
|
# Don't allocate real bytes; touch + truncate is enough for the
|
||||||
|
# planner since it reads ``.stat().st_size``.
|
||||||
|
sizes_gb = [8.8, 8.8, 8.8, 8.8]
|
||||||
|
for i, size_gb in enumerate(sizes_gb):
|
||||||
|
path = shards_dir / f"{i:03d}.db"
|
||||||
|
path.touch()
|
||||||
|
os = __import__("os")
|
||||||
|
os.truncate(path, int(size_gb * GB))
|
||||||
|
return shards_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def target_dir(tmp_path: Path) -> Path:
|
||||||
|
d = tmp_path / "shards.v2"
|
||||||
|
d.mkdir()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class TestCorpusFacts:
|
||||||
|
def test_reads_sizes(self, four_shard_corpus):
|
||||||
|
f = CorpusFacts.from_dir(four_shard_corpus)
|
||||||
|
assert len(f.shard_files) == 4
|
||||||
|
assert f.total_bytes == int(8.8 * GB) * 4
|
||||||
|
assert f.largest_shard_bytes == int(8.8 * GB)
|
||||||
|
|
||||||
|
def test_empty_dir_rejected(self, tmp_path):
|
||||||
|
empty = tmp_path / "empty"
|
||||||
|
empty.mkdir()
|
||||||
|
with pytest.raises(ValueError, match="no shard files"):
|
||||||
|
CorpusFacts.from_dir(empty)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrategyPick:
|
||||||
|
def test_88_gb_free_picks_all_at_once(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
"""The production-host case: 88 GB free, 35 GB corpus."""
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
)
|
||||||
|
assert plan.strategy == "all_at_once"
|
||||||
|
|
||||||
|
def test_47_gb_free_falls_to_per_shard(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
"""The old-host case: 47 GB free, 35 GB corpus."""
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=47 * GB,
|
||||||
|
)
|
||||||
|
assert plan.strategy == "per_source_shard"
|
||||||
|
|
||||||
|
def test_15_gb_free_refused_without_in_place(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
"""At 15 GB free, no opt-in: strategy B doesn't fit either."""
|
||||||
|
with pytest.raises(InsufficientDisk):
|
||||||
|
HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=15 * GB,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_10_gb_free_with_in_place_picks_streaming(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
"""At 10 GB free with --allow-in-place: strategy C (streaming)."""
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=10 * GB,
|
||||||
|
allow_in_place=True,
|
||||||
|
)
|
||||||
|
assert plan.strategy == "streaming_row"
|
||||||
|
|
||||||
|
def test_zero_free_refused_even_with_in_place(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
with pytest.raises(InsufficientDisk):
|
||||||
|
HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=1 * GB,
|
||||||
|
allow_in_place=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestForcedStrategy:
|
||||||
|
def test_force_strategy_b_even_when_a_fits(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
"""Operator override: pick B even if A fits."""
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
force_strategy="per_source_shard",
|
||||||
|
)
|
||||||
|
assert plan.strategy == "per_source_shard"
|
||||||
|
assert "forced" in plan.rationale
|
||||||
|
|
||||||
|
def test_force_unavailable_strategy_rejected(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="not available"):
|
||||||
|
HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
force_strategy="streaming_row", # not allow_in_place
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanFields:
|
||||||
|
def test_audit_body_is_json_serializable(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
)
|
||||||
|
json.dumps(plan.as_audit_body()) # no exception
|
||||||
|
|
||||||
|
def test_free_at_peak_reasonable(
|
||||||
|
self, four_shard_corpus, target_dir
|
||||||
|
):
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
)
|
||||||
|
# At least the safety margin remains free at peak
|
||||||
|
# (otherwise the planner would've rejected this strategy).
|
||||||
|
assert plan.free_at_peak_bytes() >= SAFETY_MARGIN_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class TestTargetMValidation:
|
||||||
|
def test_target_M_zero_rejected(self, four_shard_corpus, target_dir):
|
||||||
|
with pytest.raises(ValueError, match="target_M"):
|
||||||
|
HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=0,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_target_M_one(self, four_shard_corpus, target_dir):
|
||||||
|
"""M=1 (consolidate to a single shard) is a valid target."""
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=four_shard_corpus,
|
||||||
|
target_dir=target_dir,
|
||||||
|
target_M=1,
|
||||||
|
free_bytes=88 * GB,
|
||||||
|
)
|
||||||
|
assert plan.target_M == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestVariedShardSizes:
|
||||||
|
"""Skew test: one shard much larger than the others."""
|
||||||
|
|
||||||
|
def test_strategy_b_bounded_by_largest(self, tmp_path):
|
||||||
|
import os
|
||||||
|
shards = tmp_path / "shards"
|
||||||
|
shards.mkdir()
|
||||||
|
# 3 small + 1 large.
|
||||||
|
sizes = [1 * GB, 1 * GB, 1 * GB, 20 * GB]
|
||||||
|
for i, s in enumerate(sizes):
|
||||||
|
p = shards / f"{i:03d}.db"
|
||||||
|
p.touch()
|
||||||
|
os.truncate(p, s)
|
||||||
|
plan = HydrationPlanner().plan(
|
||||||
|
source_dir=shards,
|
||||||
|
target_dir=tmp_path / "out",
|
||||||
|
target_M=4,
|
||||||
|
free_bytes=40 * GB,
|
||||||
|
)
|
||||||
|
# Strategy A peak should fail (23 GB new + 4 × 4 WAL + scratch >40),
|
||||||
|
# so we expect strategy B which is bounded by the 20 GB shard.
|
||||||
|
assert plan.strategy == "per_source_shard"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue