Maintenance: verify, compact, vacuum
A versioned database makes an unusual bargain. It never overwrites data, so it accumulates manifests, segments and history. That is the feature. It also means three maintenance questions need honest answers.
- Is the data intact?
verify()walks the manifest checksum chain, andverify(deep=True)re-checksums every stored byte. - Why did queries get slower? Streaming ingestion leaves many small
Parquet segments.
compact()merges them into one, as a new version. - Where did my disk go, and what can I get back?
vacuum()reclaims unreferenced objects only. We test what that means for old versions rather than assume it.
import time
from pathlib import Path
import pandas as pd
import h5i_db
from h5i_db import count_star
import cookbook_utils as cu
db = h5i_db.Database(cu.fresh_db("00_maintenance"), create=True)
def du_mb(path: str) -> float:
"""Directory size in MiB - the du -s of this recipe."""
return sum(f.stat().st_size for f in Path(path).rglob("*") if f.is_file()) / 2**20
def bench(fn, repeat: int = 3) -> float:
"""Min-of-N wall time: the standard cheap timing harness."""
times = []
for _ in range(repeat):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
return min(times)
1. The data#
A week of ticks from cu.make_trades, one row per print. We are going to
mistreat it deliberately, appending it in 150 tiny commits the way an
unbatched feed writer would.
| column | type | meaning |
|---|---|---|
ts |
timestamp[us, tz=UTC] |
trade timestamp, ascending |
symbol |
string |
ticker |
price |
float64 |
trade price |
size |
int64 |
shares traded |
exchange |
string |
reporting venue |
side |
string |
B buyer-initiated, S seller-initiated |
trades = cu.make_trades(symbols=["AAPL", "MSFT", "NVDA"], days=5, trades_per_day=20_000, seed=7)
print(f"{trades.num_rows:,} rows x {trades.num_columns} columns")
trades.to_pandas().head()
313,164 rows x 6 columns
| ts | symbol | price | size | exchange | side | |
|---|---|---|---|---|---|---|
| 0 | 2026-06-01 13:30:00.013859+00:00 | NVDA | 319.29 | 1 | NASDAQ | B |
| 1 | 2026-06-01 13:30:00.101693+00:00 | NVDA | 319.21 | 200 | IEX | S |
| 2 | 2026-06-01 13:30:00.171748+00:00 | MSFT | 362.93 | 1 | NYSE | S |
| 3 | 2026-06-01 13:30:00.199372+00:00 | MSFT | 363.06 | 1 | IEX | B |
| 4 | 2026-06-01 13:30:00.247497+00:00 | MSFT | 362.93 | 100 | BATS | S |
2. Simulate an unbatched writer: 150 tiny commits#
Every commit writes a manifest and at least one segment. 150 appends of about 2,000 rows each leave the table with 150 small Parquet files. Each one is a file open, a footer parse and a merge step for every query that follows.
db.create_table("trades", trades.schema, time_column="ts", sort_key=["ts", "symbol"])
N = 150
n = len(trades)
step = n // N
for i in range(N):
db.append("trades", trades.slice(i * step, step if i < N - 1 else n - (N - 1) * step))
head = db.versions("trades")[-1]
print(f"{head['rows']:,} rows across {head['segments']} segments, "
f"{head['bytes'] / 2**20:.1f} MiB of live data, db dir = {du_mb(db.path):.1f} MiB")
313,164 rows across 150 segments, 2.4 MiB of live data, db dir = 11.3 MiB
3. verify(): the checksum chain#
Shallow verify checks the table's manifest chain. Each manifest commits to
its predecessor and to every segment it references, so a truncated file, a
lost commit or a tampered manifest breaks the chain. It reads metadata only
(bytes_checked: 0), which makes it cheap enough to run on every open.
deep=True additionally re-reads and re-checksums every segment byte. That is
the periodic bit-rot audit, priced accordingly.
print("shallow:", db.verify("trades"))
print("deep :", db.verify("trades", deep=True))
shallow: {'table': 'trades', 'head_sequence': 150, 'manifests_checked': 151, 'segments_checked': 150, 'bytes_checked': 0, 'problems': []}deep : {'table': 'trades', 'head_sequence': 150, 'manifests_checked': 151, 'segments_checked': 11325, 'bytes_checked': 190082365, 'problems': []}An empty problems list from a deep verify is an attestation you can hand to
anyone: the bytes on disk are exactly the bytes every commit signed for.
4. compact(): undo the segment sprawl#
Time a representative aggregation against the 150-segment table, compact, then time it again. Compaction rewrites the same rows into one well-sized segment and commits the result as a new version. History is untouched, so this is safe to run whenever ingestion leaves debris.
QUERY = """
SELECT time_bucket('5m', ts) AS bar, symbol,
vwap(price, size) AS vwap, sum(size) AS volume
FROM trades GROUP BY bar, symbol ORDER BY bar, symbol
"""
t_before = bench(lambda: db.sql(QUERY).to_arrow())
commit = db.compact("trades", note="merge 150 streaming appends")
print({k: commit[k] for k in ("sequence", "op", "segments_total", "segments_added")})
t_after = bench(lambda: db.sql(QUERY).to_arrow())
print(f"\n5m-bar rollup: {t_before * 1e3:.1f} ms on 150 segments "
f"-> {t_after * 1e3:.1f} ms on 1 segment ({t_before / t_after:.1f}x)")
{'sequence': 151, 'op': 'compact', 'segments_total': 1, 'segments_added': 1}
5m-bar rollup: 21.6 ms on 150 segments -> 14.8 ms on 1 segment (1.5x)Two honest observations.
The speedup here is real but modest. Per-segment overhead scales with segment count, so a day of per-tick commits, meaning tens of thousands of segments, hurts far more than our 150.
And the directory grew. The 150 old segments are still referenced by versions 1 to 150, and the compacted table carries one more copy of every row. Version history is a storage bargain, and compaction does not break it:
print(f"db dir after compact: {du_mb(db.path):.1f} MiB")
v75 = db.read("trades", version=75)
print(f"version 75 still readable: {len(v75):,} rows (head has {head['rows']:,})")
db dir after compact: 13.1 MiB version 75 still readable: 156,525 rows (head has 313,164)
5. vacuum(): what it does and does not reclaim#
The natural fear is that vacuum trades history for disk. Test that instead of
assuming it. Below is a dry run (apply=False) with grace_seconds=0, the
most aggressive setting, right after compacting.
db.vacuum("trades", grace_seconds=0, apply=False)
{'scanned_objects': 306,
'candidates': [],
'candidate_bytes': 0,
'deleted': 0,
'dry_run': True}Zero candidates. Every one of those 151 segments is referenced by some version's manifest, and vacuum only ever collects unreferenced objects: staging files from discarded mutation plans, debris from interrupted ingests.
In this build vacuum never prunes version history. Every version stays readable no matter how aggressively you vacuum. Retention is a policy decision it refuses to make implicitly, which is the correct default for anything auditors may ask about.
So let us manufacture the garbage vacuum does exist for. Stage a replace plan, which writes its repaired segment to storage immediately, then discard it. The staged segment is now referenced by nothing.
lo = int(pd.Timestamp("2026-06-01 15:00:00", tz="UTC").value // 1000)
hi = int(pd.Timestamp("2026-06-01 16:00:00", tz="UTC").value // 1000)
window = db.read("trades", time_start=lo, time_end=hi)
plan = db.plan_replace_range("trades", lo, hi, data=window, note="staged then abandoned")
plan.discard()
print("dry run, default grace (1h):", db.vacuum("trades", apply=False))
print("dry run, grace_seconds=0 :", db.vacuum("trades", grace_seconds=0, apply=False))
dry run, default grace (1h): {'scanned_objects': 307, 'candidates': [], 'candidate_bytes': 0, 'deleted': 0, 'dry_run': True}
dry run, grace_seconds=0 : {'scanned_objects': 307, 'candidates': ['tables/a8a1921a-b1e0-4c8e-8d77-0d1d39842168/segments/b47f87e5-af76-42b6-bc45-f3fae558e4b9.parquet'], 'candidate_bytes': 1973203, 'deleted': 0, 'dry_run': True}The default one-hour grace period hides the orphan. Vacuum will not touch young files, because a file that looks unreferenced right now might belong to a commit another process is seconds away from publishing.
Only grace_seconds=0 exposes the discarded plan's segment as a candidate,
and it is safe here because nothing else is writing. Reclaim it for real:
size_before = du_mb(db.path)
result = db.vacuum("trades", grace_seconds=0, apply=True)
print(result)
print(f"db dir: {size_before:.2f} MiB -> {du_mb(db.path):.2f} MiB")
{'scanned_objects': 307, 'candidates': ['tables/a8a1921a-b1e0-4c8e-8d77-0d1d39842168/segments/b47f87e5-af76-42b6-bc45-f3fae558e4b9.parquet'], 'candidate_bytes': 1973203, 'deleted': 1, 'dry_run': False}
db dir: 15.01 MiB -> 13.13 MiB# The load-bearing claim, checked: vacuum deleted the orphan and nothing else.
versions = db.versions("trades")
for v in versions: # every version's manifest still resolves and reads
db.read("trades", version=v["sequence"], limit=5)
assert db.vacuum("trades", grace_seconds=0, apply=False)["candidates"] == []
print(f"all {len(versions)} versions still readable after vacuum; "
f"deep verify clean: {db.verify('trades', deep=True)['problems'] == []}")
all 152 versions still readable after vacuum; deep verify clean: True
6. Snapshots: named, checksummed pins#
Since vacuum never eats history here, snapshots are not a defensive necessity. Their maintenance role is different.
snapshot(name) records the head version of chosen tables plus the manifest
checksum under a durable name. That gives you a read point humans can cite,
such as "the EOD cut", and an integrity anchor you can re-verify later.
Pending mutation plans get the same courtesy: their staged segments are protected from vacuum until they are applied, discarded or expired, on a 7-day TTL.
snap = db.snapshot("eod-2026-06-05", tables=["trades"], note="post-compact EOD cut")
entry = next(iter(snap["entries"].values()))
print(f"snapshot {snap['name']!r} pins {entry['table_name']} @ v{entry['sequence']}")
print(f"manifest checksum: {entry['manifest_checksum'][:16]}…")
db.table("trades", snapshot="eod-2026-06-05").select(count_star().alias("rows")).to_pandas()
snapshot 'eod-2026-06-05' pins trades @ v151 manifest checksum: f0e614c8df0e9ebd…
| rows | |
|---|---|
| 0 | 313164 |
A maintenance cadence that works#
- Every open, or hourly: shallow
verify. Metadata-only, effectively free. - After each ingestion session:
compactthe tables that streamed in as many small commits. Recipe 07's batch-size guidance reduces how often you need this. - Daily, off-hours:
vacuum(apply=False), review the candidate list, thenapply=Truewith the default grace period. Never passgrace_seconds=0while writers may be active. - Weekly, or before attestations:
verify(deep=True)plus a named snapshot of the tables you would have to defend.
Takeaways#
verify()proves the manifest checksum chain cheaply.deep=Trueturns it into a full bit-rot audit, with an emptyproblemslist as the receipt.- Small commits are a query tax, not just a storage tax.
compact()merged 150 segments into 1 as an ordinary new version, with a measurable speedup and zero history lost. vacuum()collects unreferenced objects only: discarded plan staging, interrupted-write debris. We verified that evengrace_seconds=0, apply=Trueleaves every historical version readable. History pruning is simply not something vacuum does in this build.- The grace period is crash-safety for concurrent writers. Zero it only in single-writer maintenance windows.
- Snapshots are named, checksummed read points: the citable artifact for EOD cuts and attestations, not a vacuum workaround.
db.close()