arborist/tests/test_migrate_planner.py
russell@unturf.com 8fe389d45e
#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).
2026-05-26 13:21:18 -04:00

223 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"