Files
interactive-story/backend/tests/test_snapshot_compression.py
T
parththakkar106andClaude Opus 5 d3756abdaa Give every action a branch and a depth
Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a
`branches` table, `branch_id`/`depth` on actions and memories, a head pointer
on adventures, migrations 46-52, and a server-side backfill that re-reads every
existing adventure as a tree with one branch. `depth` holds the number `index`
already held, gaps included, so no story changes — a linear story *is* a tree
with one branch, which is what makes the SP0 baseline passing unmodified the
pass condition rather than a hope.

The writer had to come with it. No migration will ever visit a row written
after it ran, so columns backfilled today and populated next subphase would
leave a hole exactly the width of one deploy, and from SP2 on a row without a
branch is a row no read can see. `app/tree.py` owns that: one module, because a
node written without a branch fails by disappearing rather than by raising.

Three things the schema itself insisted on:

- `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing
  both ways makes the two tables a cycle create_all cannot order, and its
  escape hatch needs an ALTER SQLite does not have. It is a cache, and a head
  naming a branch that is gone recovers onto the root.
- `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a
  second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`,
  because Postgres `json` has no equality operator.
- SQLite will not drop a column a foreign key names, which is how two existing
  tests broke: they simulated an old database by rewinding the stamp while
  leaving the new columns in place. Every ADD COLUMN migration is now
  idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by
  rebuilding three tables from frozen DDL so the real ALTERs run.

297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn;
page load and index are byte-identical to the recorded figures.

The deploy that ships this needs one `VACUUM FULL actions;` on the direct
endpoint afterwards — it rewrites every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30

264 lines
8.4 KiB
Python

"""context_snapshot, stored compressed.
The column is 89% of the database and the free tier allows 512 MB. Reads were
solved by deferring it; this is about the storage ceiling. Postgres already
TOASTs it and only gets 1.7x, because pglz is tuned for fast decompression of
data a query might filter on — and nothing ever filters on an assembled
prompt.
Three things have to hold, and only the first is obvious:
* what goes in comes back out, exactly, including a snapshot written before
the conversion and one that is NULL;
* the model still hands callers a dict, so no call site changes;
* migration 44 drops the original column, so the backfill is the one
destructive step in this file — it must convert every row or abort.
python -m pytest tests/test_snapshot_compression.py -v
"""
import json
import os
import random
import tempfile
import zlib
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from sqlalchemy import text
from app import compression, migrations, models
from app.database import Base, SessionLocal, engine
from tests import schema_rewind
from tools.fakeprose import prose
def snapshot(seed: int, nbytes: int = 60_000) -> dict:
rng = random.Random(seed)
return {
"system": prose(rng, nbytes // 3),
"story": prose(rng, nbytes - nbytes // 3),
"world_state": {"delta": {"player.hp": -3}},
}
@pytest.fixture()
def db():
Base.metadata.create_all(bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def adventure(db):
user = models.User(is_guest=False, email="snap@example.com")
db.add(user)
db.flush()
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
db.add(adv)
db.commit()
return adv
# ------------------------------------------------------------------- packing
def test_pack_round_trips_exactly():
value = snapshot(1)
assert compression.unpack(compression.pack(value)) == value
def test_pack_handles_the_awkward_values():
for value in ({}, {"a": None}, {"nested": {"deep": [1, 2, {"x": "é"}]}}):
assert compression.unpack(compression.pack(value)) == value
def test_pack_actually_shrinks_prose():
"""The whole justification. If this ratio collapses, the migration is
spending CPU for nothing."""
value = snapshot(2, 200_000)
raw = json.dumps(value, separators=(",", ":")).encode()
packed = compression.pack(value)
assert len(packed) < len(raw) / 3, (
f"{len(raw):,} B compressed to {len(packed):,} B — under 3x"
)
def test_unpack_rejects_nothing_it_wrote():
packed = compression.pack({"a": "b"})
assert compression.unpack(bytearray(packed)) == {"a": "b"}
assert compression.unpack(memoryview(bytes(packed))) == {"a": "b"}
# --------------------------------------------------------------- the column
def test_the_column_stores_bytes_and_returns_a_dict(db, adventure):
value = snapshot(3)
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
context_snapshot=value,
)
db.add(action)
db.commit()
db.expire_all()
assert action.context_snapshot == value
stored = db.execute(
text("SELECT context_snapshot FROM actions WHERE id = :id"),
{"id": action.id},
).scalar()
assert isinstance(stored, (bytes, bytearray, memoryview))
assert json.loads(zlib.decompress(bytes(stored))) == value
def test_the_column_is_smaller_than_the_json_it_holds(db, adventure):
value = snapshot(4, 200_000)
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
context_snapshot=value,
)
db.add(action)
db.commit()
stored = db.execute(
text("SELECT length(context_snapshot) FROM actions WHERE id = :id"),
{"id": action.id},
).scalar()
assert stored < len(json.dumps(value)) / 3
def test_null_stays_null(db, adventure):
action = models.Action(
adventure_id=adventure.id, index=0, type="do", text="t",
context_snapshot=None,
)
db.add(action)
db.commit()
db.expire_all()
assert action.context_snapshot is None
def test_an_unreadable_snapshot_reads_as_none_rather_than_raising(db, adventure):
"""One corrupt row must not 500 the turn that happens to load it. The
snapshot is a debugging view; the story is the thing that matters."""
action = models.Action(
adventure_id=adventure.id, index=0, type="ai", text="t",
context_snapshot={"a": "b"},
)
db.add(action)
db.commit()
db.execute(
text("UPDATE actions SET context_snapshot = :junk WHERE id = :id"),
{"junk": b"not zlib at all", "id": action.id},
)
db.commit()
db.expire_all()
assert db.get(models.Action, action.id).context_snapshot is None
# ------------------------------------------------------- the upgrade in full
def as_json_column(db, rows: dict[int, dict]) -> None:
"""The pre-43 schema: context_snapshot as a JSON column, populated."""
db.execute(text("ALTER TABLE actions DROP COLUMN context_snapshot"))
db.execute(text("ALTER TABLE actions ADD COLUMN context_snapshot JSON"))
for action_id, value in rows.items():
db.execute(
text("UPDATE actions SET context_snapshot = :v WHERE id = :id"),
{"v": json.dumps(value), "id": action_id},
)
db.commit()
def seed_pre_43(db, adventure, count: int = 4) -> dict[int, dict]:
ids = []
for i in range(count):
action = models.Action(
adventure_id=adventure.id, index=i, type="ai", text=f"t{i}"
)
db.add(action)
db.flush()
ids.append(action.id)
# One action with no snapshot at all, which must survive as NULL.
plain = models.Action(
adventure_id=adventure.id, index=count, type="do", text="look"
)
db.add(plain)
db.commit()
expected = {action_id: snapshot(action_id) for action_id in ids}
as_json_column(db, expected)
db.commit()
# Take the later migrations' columns back off too, not just the stamp:
# replaying 43-45 also replays everything appended after them.
schema_rewind.rewind_to(engine, migrations.SNAPSHOT_COMPRESS_VERSION - 1)
return expected
def test_bootstrap_converts_every_snapshot(db, adventure):
expected = seed_pre_43(db, adventure)
db.close()
migrations.bootstrap(engine)
check = SessionLocal()
try:
for action_id, value in expected.items():
assert check.get(models.Action, action_id).context_snapshot == value
assert check.execute(
text("SELECT count(*) FROM actions WHERE context_snapshot IS NULL")
).scalar() == 1
finally:
check.close()
def test_bootstrap_leaves_the_column_named_context_snapshot(db, adventure):
seed_pre_43(db, adventure)
db.close()
migrations.bootstrap(engine)
with engine.begin() as conn:
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(actions)"))}
assert "context_snapshot" in columns
assert "context_snapshot_z" not in columns, "the swap left the scratch column behind"
def test_the_backfill_aborts_rather_than_dropping_unconvertible_data(
db, adventure, monkeypatch
):
"""Migration 44 destroys the original. If anything cannot be converted the
whole run has to roll back with the column still there — the alternative is
losing somebody's prompts to a bug in this file."""
seed_pre_43(db, adventure)
db.close()
def broken_unpack(_blob):
return {"not": "what went in"}
monkeypatch.setattr(compression, "unpack", broken_unpack)
with pytest.raises(RuntimeError, match="round trip"):
migrations.bootstrap(engine)
with engine.begin() as conn:
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(actions)"))}
version = conn.execute(text("PRAGMA user_version")).scalar()
surviving = conn.execute(
text("SELECT count(*) FROM actions WHERE context_snapshot IS NOT NULL")
).scalar()
assert "context_snapshot" in columns
assert version < migrations.SNAPSHOT_COMPRESS_VERSION, \
"the version advanced past a backfill that failed"
assert surviving == 4, "the prompts did not survive the rollback"