feat(6845): every instance has an id lineage, and every full backup carries it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / integration (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 22s

instance_identity is one row made by migration 0127 with a random id, so an
instance has it before its first account. A full backup carries it in its
header as instance_id (the tar manifest header and the JSON top level);
BACKUP_VERSION is 28. The table rides in _NOT_INCLUDED: a restore decides from
the header value rather than copying a row.

Two stores with the same id number their rows the same way. That is what lets
a later step roll a live instance back by original id, refuse a backup whose
ids would land on unrelated rows, and have a fresh-instance restore adopt the
backup's id (adopt_instance_id).

Milestone 582, step 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 19:53:45 -05:00
co-authored by Claude Opus 5.5
parent 3c01e17559
commit 6d8db0bb65
7 changed files with 171 additions and 4 deletions
@@ -0,0 +1,39 @@
"""instance identity — the id lineage a backup carries
Revision ID: 0127
Revises: 0126
Create Date: 2026-11-05
One row naming this database's id lineage. Every full backup carries it, a
restore into a fresh instance adopts it (the restore keeps the original ids),
and rolling a live instance back requires the backup's to match, so a backup's
ids never land on unrelated rows. Made here with a random value, so every
instance has one before its first account exists.
"""
import sqlalchemy as sa
from alembic import op
revision = "0127"
down_revision = "0126"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"instance_identity",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("instance_id", sa.Text(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint("id = 1", name="ck_instance_identity_single_row"),
)
op.execute(
"INSERT INTO instance_identity (id, instance_id) VALUES (1, gen_random_uuid()::text)"
)
def downgrade() -> None:
op.drop_table("instance_identity")
+1
View File
@@ -94,3 +94,4 @@ from grimoire.models.family import ( # noqa: E402, F401
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform, FamilyIdeaReference, Platform, ProjectPlatform,
) )
from grimoire.models.instance import InstanceIdentity # noqa: E402, F401
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from grimoire.models import Base
class InstanceIdentity(Base):
"""The one row naming this database's id lineage.
Two stores with the same instance id number their rows the same way: one
is the other, or a restore of it made with the original ids kept. That is
what lets a backup roll a live instance back by id, and what refuses a
backup whose ids would land on unrelated rows. Made by the migration, so
an instance has it before anyone registers.
"""
__tablename__ = "instance_identity"
__table_args__ = (CheckConstraint("id = 1", name="ck_instance_identity_single_row"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
instance_id: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+10 -1
View File
@@ -111,7 +111,9 @@ logger = logging.getLogger(__name__)
# v26 user_profiles: what each user wrote on their Profile page. # v26 user_profiles: what each user wrote on their Profile page.
# v27 drops user_profiles with the Profile page. Older archives carrying the # v27 drops user_profiles with the Profile page. Older archives carrying the
# section still restore — the key is simply not read. # section still restore — the key is simply not read.
BACKUP_VERSION = 27 # v28 instance_id in the header: the id lineage of the store the backup came
# from. A live instance rolls back only to a backup with its own id.
BACKUP_VERSION = 28
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is # below, these two lists must together account for the entire schema — which is
@@ -195,6 +197,9 @@ _NOT_INCLUDED = [
# rebuilt from the repo archive by every coverage sync, and carries no # rebuilt from the repo archive by every coverage sync, and carries no
# judgment — the first refresh after a restore recreates it exactly. # judgment — the first refresh after a restore recreates it exactly.
"code_shape_consumers", "code_shape_consumers",
# Not a section: the full backup carries the one value in its header as
# `instance_id`, and a restore decides from it rather than copying a row.
"instance_identity",
] ]
@@ -979,7 +984,10 @@ async def export_full_backup() -> dict:
runs, which only works when unchanged rows serialise to the same bytes in runs, which only works when unchanged rows serialise to the same bytes in
the same place. the same place.
""" """
from grimoire.services.instance import get_instance_id
async with async_session() as session: async with async_session() as session:
instance_id = await get_instance_id(session)
users =(await session.execute(select(User).order_by(User.id))).scalars().all() users =(await session.execute(select(User).order_by(User.id))).scalars().all()
projects = (await session.execute(select(Project).order_by(Project.id))).scalars().all() projects = (await session.execute(select(Project).order_by(Project.id))).scalars().all()
milestones = (await session.execute(select(Milestone).order_by(Milestone.id))).scalars().all() milestones = (await session.execute(select(Milestone).order_by(Milestone.id))).scalars().all()
@@ -1077,6 +1085,7 @@ async def export_full_backup() -> dict:
return { return {
"version": BACKUP_VERSION, "version": BACKUP_VERSION,
"scope": "full", "scope": "full",
"instance_id": instance_id,
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": ( "_security_notice": (
"This backup contains hashed passwords. " "This backup contains hashed passwords. "
+46
View File
@@ -0,0 +1,46 @@
"""This instance's id lineage (models/instance.py says what it means)."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from grimoire.models import async_session
from grimoire.models.instance import InstanceIdentity
async def _read_or_make(session: AsyncSession) -> str:
current = (await session.execute(select(InstanceIdentity.instance_id))).scalar_one_or_none()
if current:
return current
# The migration makes the row; this covers a schema built some other way.
await session.execute(
insert(InstanceIdentity)
.values(id=1, instance_id=str(uuid.uuid4()))
.on_conflict_do_nothing(index_elements=["id"])
)
return (await session.execute(select(InstanceIdentity.instance_id))).scalar_one()
async def get_instance_id(session: AsyncSession | None = None) -> str:
"""This instance's id. Inside `session` when given, so a caller's
transaction sees the same value it may be about to change."""
if session is not None:
return await _read_or_make(session)
async with async_session() as own:
value = await _read_or_make(own)
await own.commit()
return value
async def adopt_instance_id(session: AsyncSession, value: str) -> None:
"""Take a backup's id as this instance's own, inside the caller's
transaction: after a restore that kept the backup's original ids, this
store and the one the backup came from number their rows the same way."""
await session.execute(
insert(InstanceIdentity)
.values(id=1, instance_id=value)
.on_conflict_do_update(index_elements=["id"], set_={"instance_id": value})
)
@@ -0,0 +1,42 @@
"""Real-Postgres proof that every instance has one id lineage, that a full
backup carries it, and that adopting a backup's id replaces this one's."""
import io
import pytest
from grimoire.models import async_session
from grimoire.services import backup
from grimoire.services.backup_file import read_archive, write_archive_to
from grimoire.services.instance import adopt_instance_id, get_instance_id
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
async def test_the_migration_gave_this_instance_an_id_and_it_stays_put():
first = await get_instance_id()
assert first and len(first) >= 32
assert await get_instance_id() == first
async def test_a_full_backup_carries_the_instance_id_through_the_tar():
payload = await backup.export_full_backup()
assert payload["instance_id"] == await get_instance_id()
out = io.BytesIO()
write_archive_to(payload, out, app_version="test")
out.seek(0)
assert read_archive(out)["instance_id"] == payload["instance_id"]
async def test_adopting_a_backups_id_replaces_this_instances():
own = await get_instance_id()
try:
async with async_session() as s:
await adopt_instance_id(s, "adopted-lineage")
await s.commit()
assert await get_instance_id() == "adopted-lineage"
finally:
async with async_session() as s:
await adopt_instance_id(s, own)
await s.commit()
assert await get_instance_id() == own
+7 -3
View File
@@ -11,7 +11,7 @@ The full FK-remapping round-trip needs real Postgres and lives in the
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -22,7 +22,7 @@ def test_backup_version_is_current():
"""The bump is the point of the test — a payload section added without """The bump is the point of the test — a payload section added without
moving the version produces backups that are structurally different and moving the version produces backups that are structurally different and
indistinguishable by inspection.""" indistinguishable by inspection."""
assert backup.BACKUP_VERSION == 27 assert backup.BACKUP_VERSION == 28
def _exportable_note(**over): def _exportable_note(**over):
@@ -561,11 +561,15 @@ class _CM:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_export_full_backup_contains_every_declared_section(): async def test_export_full_backup_contains_every_declared_section():
with patch("grimoire.services.backup.async_session", lambda: _CM()): with patch("grimoire.services.backup.async_session", lambda: _CM()), patch(
"grimoire.services.instance.get_instance_id", AsyncMock(return_value="test-lineage"),
):
out = await backup.export_full_backup() out = await backup.export_full_backup()
assert out["version"] == backup.BACKUP_VERSION assert out["version"] == backup.BACKUP_VERSION
assert out["scope"] == "full" assert out["scope"] == "full"
# v28: the id lineage a live rollback is checked against.
assert out["instance_id"] == "test-lineage"
assert "api_keys" in out["_not_included"] assert "api_keys" in out["_not_included"]
# The sections v2 silently dropped, the six v5 added, v6's # The sections v2 silently dropped, the six v5 added, v6's
# note_supersessions, and v7's code_shapes (all empty here). # note_supersessions, and v7's code_shapes (all empty here).