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
264 lines
8.4 KiB
Python
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"
|