Files
kanta/tests/test_revision.py
T
LeoVasanko de2476c59c Add revision numbers: snapshot r field, kanta.rev, CLI rN selectors, k1 meta migration
Revisions are absolute state positions between diffs: the empty initial
state is revision 0 and each change record advances it by one.  Snapshots
persist the revision of the state they contain in a new r field, stamped
with a meta format version k (kanta.structs.META_FORMAT, currently 1);
change records carry no revision of their own.

- kanta.rev exposes the current revision (raises before open)
- replay restores the revision, trusting r on k-marked snapshots and
  counting change records from the start of the file for legacy (k0) ones
- rotation preserves absolute revisions, counted from record positions
- meta migration k1 rewrites a legacy file in full on writable open:
  leading snapshot preserved and re-stamped, all change records kept,
  closing snapshot with the total revision; read-only opens compute the
  same revision without touching the file
- CLI range selectors accept rN bounds: rN is the record that produced
  state N with its state (the change, or the snapshot materializing it);
  single indexes must exist, ranges truncate Python-style
- CLI snapshot indicator lines show the snapshot revision
2026-09-28 00:25:52 +00:00

477 lines
17 KiB
Python

"""Tests for revision numbers: runtime counter, snapshot ``r`` field, CLI rN."""
import asyncio
import json
from datetime import UTC, datetime, timedelta
import pytest
from kanta.exceptions import DataIntegrityError
from kanta.serialization import JsonSerializer
from kanta.structs import META_FORMAT, ChangeRecord, Snapshot
from tests.support import Data, make_kanta, read_last_snapshot
from tests.test_rotation import make_clock, read_all, write_history
T0 = datetime(2026, 1, 1, tzinfo=UTC)
# The CLI reads files with its own JSON Kanta instance unless -k is given.
JSON = ("json", JsonSerializer)
@pytest.mark.asyncio
async def test_revision_requires_open(tmp_path, format_config):
kanta = make_kanta(tmp_path / "data.kantadb", Data, format_config)
with pytest.raises(DataIntegrityError):
kanta.rev
@pytest.mark.asyncio
async def test_revision_counts_change_records(tmp_path, format_config):
path = tmp_path / "data.kantadb"
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
# The bootstrap record is the first diff: state is at revision 1.
assert kanta.rev == 1
with kanta.transaction("inc", log=False) as data:
data.counter += 1
assert kanta.rev == 2
# A no-op transaction queues no record and does not advance the revision.
with kanta.transaction("noop", log=False):
pass
assert kanta.rev == 2
await kanta.close()
@pytest.mark.asyncio
async def test_revision_restored_on_reopen(tmp_path, format_config):
path = tmp_path / "data.kantadb"
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
for _ in range(3):
with kanta.transaction("inc", log=False) as data:
data.counter += 1
await kanta.close()
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(log=False)
assert kanta2.rev == 4
await kanta2.close()
@pytest.mark.asyncio
async def test_snapshot_carries_revision(tmp_path, format_config):
path = tmp_path / "data.kantadb"
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
for _ in range(2):
with kanta.transaction("inc", log=False) as data:
data.counter += 1
await kanta.flush()
kanta.request_snapshot()
kanta._impl.maybe_snapshot()
await kanta.close()
snap = read_last_snapshot(path, format_config)
assert snap is not None
assert snap.r == 3
assert snap.k == META_FORMAT
# Reopening from the snapshot keeps the absolute revision.
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(log=False)
assert kanta2.rev == 3
with kanta2.transaction("inc", log=False) as data:
data.counter += 1
assert kanta2.rev == 4
await kanta2.close()
@pytest.mark.asyncio
async def test_legacy_snapshot_recounted_and_rewritten(tmp_path, format_config):
"""Meta migration k1 rewrites a k0 file in full (see kanta/metamigr.py)."""
path = tmp_path / "data.kantadb"
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
for _ in range(2):
with kanta.transaction("inc", log=False) as data:
data.counter += 1
await kanta.flush()
kanta.request_snapshot()
kanta._impl.maybe_snapshot()
await kanta.close()
# Simulate a pre-revision database: rewrite the last snapshot without k,
# with a wrong r, then append one more change record.
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
records = read_all(path, format_config)
out = bytearray()
for rec in records:
if isinstance(rec, Snapshot):
rec = Snapshot(ts=rec.ts, v=rec.v, state=rec.state, m=rec.m, r=0)
out += framer.frame_snapshot(
serializer.encode(rec), record_offset=len(out)
)
else:
out += framer.frame_change(
serializer.encode(rec), record_offset=len(out)
)
extra = ChangeRecord(ts=T0, a="inc", v=0, diff={"counter": 3})
out += framer.frame_change(serializer.encode(extra), record_offset=len(out))
path.write_bytes(bytes(out))
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(log=False)
# bootstrap + 2 changes + trailing change = revision 4, despite r=0 above.
assert kanta2.rev == 4
assert kanta2.data.counter == 3
await kanta2.close()
# The file was rewritten in full: all change records preserved in order,
# the legacy intermediate snapshot dropped, and a k1 closing snapshot
# carrying the total revision appended.
records = read_all(path, format_config)
changes = [r for r in records if isinstance(r, ChangeRecord)]
assert [c.a for c in changes] == ["bootstrap", "inc", "inc", "inc"]
snapshots = [r for r in records if isinstance(r, Snapshot)]
assert len(snapshots) == 1
assert isinstance(records[-1], Snapshot)
closing = snapshots[0]
assert closing.k == META_FORMAT
assert closing.r == 4
assert closing.state["counter"] == 3
# The next open trusts the stored r (file unchanged this time).
content = path.read_bytes()
kanta3 = make_kanta(path, Data, format_config)
await kanta3.open(log=False)
assert kanta3.rev == 4
await kanta3.close()
assert path.read_bytes() == content
@pytest.mark.asyncio
async def test_legacy_migration_preserves_leading_snapshot(tmp_path, format_config):
"""A legacy file starting with a snapshot keeps it, re-stamped k1."""
path = tmp_path / "data.kantadb"
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
snap_ts = T0 - timedelta(days=40)
snap_m = T0 - timedelta(days=41)
leading = Snapshot(ts=snap_ts, v=3, state={"users": {}, "counter": 7}, m=snap_m)
out = bytearray()
out += framer.frame_snapshot(serializer.encode(leading), record_offset=0)
for i in range(2):
rec = ChangeRecord(ts=T0, a=f"inc{i}", v=3, diff={"counter": 8 + i})
out += framer.frame_change(serializer.encode(rec), record_offset=len(out))
path.write_bytes(bytes(out))
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
# Only the in-file history is countable: 2 change records.
assert kanta.rev == 2
assert kanta.data.counter == 9
await kanta.close()
records = read_all(path, format_config)
assert isinstance(records[0], Snapshot)
assert records[0].k == META_FORMAT
assert records[0].r == 0
assert records[0].ts == snap_ts
assert records[0].m == snap_m
assert records[0].v == 3
assert records[0].state == {"users": {}, "counter": 7}
changes = [r for r in records if isinstance(r, ChangeRecord)]
assert [c.a for c in changes] == ["inc0", "inc1"]
assert isinstance(records[-1], Snapshot)
assert records[-1].k == META_FORMAT
assert records[-1].r == 2
assert records[-1].state["counter"] == 9
@pytest.mark.asyncio
async def test_rotation_of_legacy_file_migrates(tmp_path, format_config):
"""Rotation and meta migration coincide: rotation already produces k1
snapshots with revisions counted from pre-rotation record positions."""
path = tmp_path / "data.kantadb"
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
def change(action, day, counter):
return ChangeRecord(
ts=T0 + day * timedelta(days=1), a=action, v=0, diff={"counter": counter}
)
legacy_snap = Snapshot(
ts=T0 - 40 * timedelta(days=1), v=0, state={"users": {}, "counter": 1}, r=0
)
out = bytearray()
out += framer.frame_change(
serializer.encode(change("bootstrap", -41, 0)), record_offset=len(out)
)
out += framer.frame_change(
serializer.encode(change("day-40", -40, 1)), record_offset=len(out)
)
out += framer.frame_snapshot(
serializer.encode(legacy_snap), record_offset=len(out)
)
out += framer.frame_change(
serializer.encode(change("day-20", -20, 2)), record_offset=len(out)
)
out += framer.frame_change(
serializer.encode(change("day-5", -5, 3)), record_offset=len(out)
)
path.write_bytes(bytes(out))
cell = [T0]
kanta = make_kanta(path, Data, format_config, retention=30 * timedelta(days=1))
kanta.clock(make_clock(cell))
await kanta.open(log=False)
assert kanta.rev == 4
assert kanta.data.counter == 3
await kanta.close()
# The rotation's leading snapshot is k1 with the pre-rotation revision.
records = read_all(path, format_config)
assert isinstance(records[0], Snapshot)
assert records[0].k == META_FORMAT
assert records[0].r == 2
assert [r.a for r in records if isinstance(r, ChangeRecord)] == [
"day-20",
"day-5",
]
# No further rewriting on the next open.
content = path.read_bytes()
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(log=False)
assert kanta2.rev == 4
await kanta2.close()
assert path.read_bytes() == content
@pytest.mark.asyncio
async def test_legacy_snapshot_readonly_recounts_without_writing(
tmp_path, format_config
):
path = tmp_path / "data.kantadb"
kanta = make_kanta(path, Data, format_config)
await kanta.open(log=False)
with kanta.transaction("inc", log=False) as data:
data.counter += 1
await kanta.close()
# Inject a legacy snapshot (no k marker) at the start; the whole file is
# re-framed with a single framer so binary checksums stay valid.
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
legacy = Snapshot(ts=T0, v=0, state={"users": {}, "counter": 0}, r=0)
out = bytearray()
out += framer.frame_snapshot(serializer.encode(legacy), record_offset=0)
for rec in read_all(path, format_config):
out += framer.frame_change(serializer.encode(rec), record_offset=len(out))
path.write_bytes(bytes(out))
content = path.read_bytes()
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(readonly=True, log=False)
assert kanta2.rev == 2
await kanta2.close()
assert path.read_bytes() == content
@pytest.mark.asyncio
async def test_rotation_preserves_revisions(tmp_path, format_config):
path = tmp_path / "data.kantadb"
await write_history(path, format_config, days=[-40, -20, -5])
cell = [T0]
kanta = make_kanta(path, Data, format_config, retention=30 * timedelta(days=1))
kanta.clock(make_clock(cell))
await kanta.open(log=False)
# bootstrap + 3 changes = revision 4 before and after the rotation.
assert kanta.rev == 4
await kanta.close()
records = read_all(path, format_config)
leading = records[0]
assert isinstance(leading, Snapshot)
# One change was dropped (bootstrap + day-40 are before the cutoff
# position... bootstrap is a change record too: 2 records before).
assert leading.r == 2
assert leading.k == META_FORMAT
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open(log=False)
assert kanta2.rev == 4
with kanta2.transaction("inc", log=False) as data:
data.counter += 1
assert kanta2.rev == 5
await kanta2.close()
def test_cli_revision_snapshot_base_file(tmp_path, capsys):
"""File = snapshot {r: 2} + one change; the two producing changes are gone."""
path = tmp_path / "data.kantadb"
serializer = JsonSerializer()
framer = serializer.framer_cls()
snap = Snapshot(
ts=T0, v=0, state={"foo": 1, "bar": 1}, r=2, k=META_FORMAT
)
out = bytearray()
out += framer.frame_snapshot(serializer.encode(snap), record_offset=0)
change = ChangeRecord(ts=T0, a="add_baz", v=0, diff={"baz": 1})
out += framer.frame_change(serializer.encode(change), record_offset=len(out))
path.write_bytes(bytes(out))
capsys.readouterr()
# r1's producing change is gone and no snapshot holds state 1: error.
assert _run_cli(path, "-r", "r1") == 2
assert "r1" in capsys.readouterr().err
# r2 prints the snapshot line (it literally carries r: 2) and its state.
assert _run_cli(path, "-r", "r2", "-o", "-") == 0
out = capsys.readouterr()
assert "snapshot s0" in out.err
assert "r2" in out.err
assert json.loads(out.out) == {"foo": 1, "bar": 1}
# r3 is the change, replayed on the snapshot base; state includes baz.
assert _run_cli(path, "-r", "r3", "-o", "-") == 0
out = capsys.readouterr()
assert "add_baz" in out.err
assert json.loads(out.out) == {"foo": 1, "bar": 1, "baz": 1}
# Single index and half-open range agree.
assert _run_cli(path, "-r", "r2") == 0
single = capsys.readouterr().err
assert _run_cli(path, "-r", "r2:r3") == 0
assert capsys.readouterr().err == single
def _run_cli(path, *args):
from kanta.__main__ import main
return main([str(path), *args])
def test_cli_revision_ranges(tmp_path, capsys):
path = tmp_path / "data.kantadb"
async def build():
kanta = make_kanta(path, Data, JSON)
await kanta.open(log=False)
for i in range(3):
with kanta.transaction(f"inc{i}", log=False) as data:
data.counter += 1
await kanta.close()
asyncio.run(build())
capsys.readouterr()
# A revision selects the record that produced that state, with the
# state: r1 and r1:r2 both show only the bootstrap and output state 1.
assert _run_cli(path, "-r", "r1") == 0
single = capsys.readouterr().err
assert "bootstrap" in single
assert "inc0" not in single
assert _run_cli(path, "-r", "r1:r2") == 0
assert capsys.readouterr().err == single
assert _run_cli(path, "-r", "r1", "-o", "-") == 0
assert json.loads(capsys.readouterr().out) == {"users": {}, "counter": 0}
# r1..r2 (inclusive end) adds the next change and outputs state 2.
assert _run_cli(path, "-r", "r1..r2", "-o", "-") == 0
out = capsys.readouterr()
assert "inc0" in out.err
assert json.loads(out.out) == {"users": {}, "counter": 1}
# r0 is not meaningful: no record produces the empty initial state.
assert _run_cli(path, "-r", "r0") == 2
assert "r0" in capsys.readouterr().err
# Range by revision.
assert _run_cli(path, "-r", "r2:r4") == 0
err = capsys.readouterr().err
assert "inc0" in err
assert "inc1" in err
assert "inc2" not in err
# Negative revisions are rejected.
assert _run_cli(path, "-r", "r-1") == 1
assert "negative" in capsys.readouterr().err
# Out-of-range single revision is a range error (exit 2).
assert _run_cli(path, "-r", "r99") == 2
assert "r99" in capsys.readouterr().err
# The same bounds as a range truncate Python-style instead: an empty
# display and the closest known state, not an error.
assert _run_cli(path, "-r", "r99:r100", "-o", "-") == 0
out = capsys.readouterr()
assert "bootstrap" not in out.err
assert "inc" not in out.err
assert json.loads(out.out) == {"users": {}, "counter": 3}
# Only the end beyond the last revision: truncated to end of file.
assert _run_cli(path, "-r", "r3:r99", "-o", "-") == 0
out = capsys.readouterr()
assert "inc1" in out.err
assert "inc2" in out.err
assert json.loads(out.out) == {"users": {}, "counter": 3}
def test_cli_revision_after_rotation(tmp_path, capsys):
path = tmp_path / "data.kantadb"
async def build():
await write_history(path, JSON, days=[-40, -20, -5])
cell = [T0]
kanta = make_kanta(
path, Data, JSON, retention=30 * timedelta(days=1)
)
kanta.clock(make_clock(cell))
await kanta.open(log=False)
await kanta.close()
asyncio.run(build())
capsys.readouterr()
# The leading snapshot holds revision 2 (bootstrap + day-40). Since the
# change that produced state 2 is gone, r2 shows the snapshot itself and
# outputs the snapshot state.
assert _run_cli(path, "-r", "r2", "-o", "-") == 0
out = capsys.readouterr()
assert "snapshot s0" in out.err
assert "r2" in out.err
assert "day-20" not in out.err
assert json.loads(out.out) == {"users": {}, "counter": 1}
# r1 has no record in this file: a single index does not truncate.
assert _run_cli(path, "-r", "r1") == 2
assert "r1" in capsys.readouterr().err
# A range truncates like Python slicing instead: r1:r3 starts at the
# beginning of the file and stops before the r3 record, showing just the
# leading snapshot.
assert _run_cli(path, "-r", "r1:r3") == 0
err = capsys.readouterr().err
assert "snapshot s0" in err
assert "r2" in err
assert "day-20" not in err
# Absolute revisions reach the retained change records.
assert _run_cli(path, "-r", "r3:r4") == 0
err = capsys.readouterr().err
assert "day-20" in err
assert "snapshot" not in err
assert "day-5" not in err