Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions nw/graph_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def open_graph_store(
db_path: Path | str,
*,
asset_id: AssetIdLike,
tenant: Optional[AssetIdLike] = None,
scope: Optional[str] = None,
rate: Optional[int] = None,
env: Mapping[str, str] | None = None,
Expand All @@ -156,6 +157,10 @@ def open_graph_store(
tenant anchor — or a zero-argument callable returning it. Ignored
in SQLite mode, where a callable is never called (so a hot path
can pass ``lambda: storage.asset_id`` without paying to derive it).
tenant: What keys the Postgres tenant (same string-or-callable form),
when it is not the asset id. A project's graph is keyed on the id
minted when it was created, so a recreated project does not
inherit the old one's graph. Defaults to ``asset_id``.
scope: The logical scope name (``"graph"`` / ``"storyboard"`` /
``"alignment"`` / …). Defaults to deriving it from ``db_path``.
rate: Project-wide rate for the Postgres store. Defaults to lacing's
Expand All @@ -174,7 +179,12 @@ def open_graph_store(
scope = scope_name_for_db(db_path)

if selected_backend(env) == _POSTGRES:
store = _open_postgres(asset_id=asset_id, scope=scope, rate=rate, env=env)
store = _open_postgres(
asset_id=tenant if tenant is not None else asset_id,
scope=scope,
rate=rate,
env=env,
)
if store is not None:
return store
# _open_postgres logged a warning and we fall through to SQLite.
Expand Down Expand Up @@ -225,6 +235,7 @@ def iter_scope_stores(
scope_paths: Mapping[str, Path],
*,
asset_id: AssetIdLike,
tenant: Optional[AssetIdLike] = None,
env: Mapping[str, str] | None = None,
) -> Iterator[Iterator[IntervalAnnotationStore]]:
"""Yield an iterator of open stores, one per scope, backend chosen by env.
Expand All @@ -243,6 +254,8 @@ def iter_scope_stores(
asset_id: The project's ``project_asset_id`` (Postgres tenant anchor),
or a zero-argument callable returning it (never called in SQLite
mode).
tenant: What keys each Postgres tenant instead of ``asset_id``; see
:func:`open_graph_store`.
env: Environment mapping. Defaults to ``os.environ``.

Yields:
Expand All @@ -258,7 +271,9 @@ def _gen() -> Iterator[IntervalAnnotationStore]:
for scope, db_path in scope_paths.items():
if backend == _SQLITE and not Path(db_path).exists():
continue
store = open_graph_store(db_path, asset_id=asset_id, scope=scope, env=env)
store = open_graph_store(
db_path, asset_id=asset_id, tenant=tenant, scope=scope, env=env
)
try:
yield store
finally:
Expand Down
25 changes: 23 additions & 2 deletions nw/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ def open_project_graph(project_root: Path) -> IntervalAnnotationStore:
"""
db = project_graph_db_path(project_root)
store = open_graph_store(
db, asset_id=lambda: project_asset_id(project_root), scope=SCOPE_GRAPH
db,
asset_id=lambda: project_asset_id(project_root),
tenant=lambda: project_tenant_id(project_root),
scope=SCOPE_GRAPH,
)
_ensure_tiers(store)
return store
Expand Down Expand Up @@ -157,7 +160,10 @@ def open_project_graph_readonly(project_root: Path) -> IntervalAnnotationStore:
db = project_graph_db_path(project_root)
if selected_backend(os.environ) == "postgres":
return open_graph_store(
db, asset_id=lambda: project_asset_id(project_root), scope=SCOPE_GRAPH
db,
asset_id=lambda: project_asset_id(project_root),
tenant=lambda: project_tenant_id(project_root),
scope=SCOPE_GRAPH,
)
if not Path(db).exists():
raise FileNotFoundError(f"no graph database for project: {project_root}")
Expand Down Expand Up @@ -188,6 +194,20 @@ def project_asset_id(project_root: Path) -> str:
return asset_id_of(project_root, spec)


def project_tenant_id(project_root: Path) -> str:
"""The id a shared backend keys the project's graph on.

The id minted at ``Project.init`` (``project.json``'s ``tenant_id``); for a
project that predates it, its historical :func:`project_asset_id`. The rule
is :func:`nw.storage.tenant_id_of`.
"""
from .storage import tenant_id_of

project_json = Path(project_root) / "project.json"
spec = json.loads(project_json.read_text()) if project_json.exists() else None
return tenant_id_of(project_root, spec)


# ---------------------------------------------------------------------------
# Migration
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -370,6 +390,7 @@ def migrate_to_graph(
"song": spec.get("song"),
"global_style": spec.get("global_style", ""),
"notes": spec.get("notes", ""),
**({"tenant_id": spec["tenant_id"]} if spec.get("tenant_id") else {}),
# Tombstone marker so a human reading project.json knows where the
# graph data went.
"_graph_db": _PROJECT_GRAPH_DB_NAME,
Expand Down
16 changes: 14 additions & 2 deletions nw/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import shutil
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from typing import Any, Iterable, Optional


Expand Down Expand Up @@ -72,7 +73,7 @@ def _seconds_to_rt(seconds: float):
remove_annotations_with_traces,
)
from .bodies import GENRE_ENVELOPE_TIER, UNPRODUCED_OUTPUT_TIER, VERIFYING_TRACE_TIER
from .storage import FolderStorage, ProjectStorage, as_project_storage
from .storage import TENANT_KEY, FolderStorage, ProjectStorage, as_project_storage
from .migrate import (
_TIER_CHARACTER_REF,
_TIER_DECISION,
Expand Down Expand Up @@ -261,7 +262,14 @@ def init(
schema_version=SCHEMA_VERSION,
title=title or root.name,
)
docs[_PROJECT_FILE_NAME] = json.loads(spec.model_dump_json())
# ``tenant_id`` is minted here, once, and is what a shared (Postgres)
# backend keys the graph on: root + title is not an identity, because
# a removed folder recreated at the same path would inherit the old
# graph (nw#109).
docs[_PROJECT_FILE_NAME] = {
**json.loads(spec.model_dump_json()),
TENANT_KEY: uuid4().hex,
}

# New projects are graph-native from the start; mark migrated so the
# auto-migrator skips them. The graph store is created lazily on first
Expand Down Expand Up @@ -380,6 +388,10 @@ def write_spec(self, spec: ProjectSpec) -> None:
"notes": spec.notes,
"_graph_db": "project.annot.sqlite",
}
# The minted tenant id outlives every rewrite of project.json.
tenant = self._docs[_PROJECT_FILE_NAME].get(TENANT_KEY)
if tenant:
meta[TENANT_KEY] = tenant
self._docs[_PROJECT_FILE_NAME] = meta

# Graph entities — sync each tier to the spec by removing entries
Expand Down
34 changes: 31 additions & 3 deletions nw/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,25 @@ def asset_id_of(root: str | Path, spec: Optional[Mapping[str, Any]]) -> str:
return hashlib.sha256(seed).hexdigest()


#: The key in ``project.json`` holding the id minted at ``Project.init``.
TENANT_KEY = "tenant_id"


def tenant_id_of(root: str | Path, spec: Optional[Mapping[str, Any]]) -> str:
"""The id a shared (Postgres) backend keys this project's graph on.

The id minted at ``Project.init`` and stored in ``project.json``, so a
project folder removed and created again at the same path with the same
title is a *new* tenant with an empty graph. A project that predates the
mint has none; it keeps its historical tenant (:func:`asset_id_of`), so the
graph it already has stays readable.
"""
minted = (spec or {}).get(TENANT_KEY)
if isinstance(minted, str) and minted:
return minted
return asset_id_of(root, spec)


def _project_doc(docs: Mapping[str, Any]) -> Optional[Mapping[str, Any]]:
try:
return docs[PROJECT_FILE_NAME]
Expand Down Expand Up @@ -202,6 +221,11 @@ def asset_id(self) -> str:

return project_asset_id(self.root)

@property
def tenant_id(self) -> str:
"""The shared-backend tenant key (:func:`tenant_id_of`)."""
return tenant_id_of(self.root, _project_doc(self.docs))

def scope_paths(self) -> dict[str, Path]:
"""``{scope_name: legacy_sqlite_path}`` for this project's stores."""
from .migrate import project_graph_db_path
Expand All @@ -224,10 +248,12 @@ def open_graph_readonly(self) -> IntervalAnnotationStore:

@contextmanager
def open_stores(self) -> Iterator[Iterator[IntervalAnnotationStore]]:
# Lazy: only the Postgres backend keys a tenant on the asset id, and
# deriving it hashes the song file.
# Lazy: only the Postgres backend keys a tenant, and deriving the
# asset id hashes the song file.
with iter_scope_stores(
self.scope_paths(), asset_id=lambda: self.asset_id
self.scope_paths(),
asset_id=lambda: self.asset_id,
tenant=lambda: self.tenant_id,
) as stores:
yield stores

Expand Down Expand Up @@ -393,6 +419,8 @@ def as_project_storage(project: Any) -> ProjectStorage:
"ProjectStorage",
"as_project_storage",
"asset_id_of",
"tenant_id_of",
"TENANT_KEY",
"json_docs",
"project_storages",
"register_project_storage",
Expand Down
15 changes: 13 additions & 2 deletions nw/storyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@

from .graph import remove_annotations_with_traces
from .graph_backend import SCOPE_STORYBOARD, open_graph_store, selected_backend
from .migrate import project_tenant_id
from .project import Project
from .transforms import OnFailure

Expand Down Expand Up @@ -107,7 +108,12 @@ def open_storyboard(project: Project) -> Storyboard:
# panels.
if selected_backend() == "sqlite" and not db.exists():
return Storyboard(asset_id=asset_id)
store = open_graph_store(db, asset_id=asset_id, scope=SCOPE_STORYBOARD)
store = open_graph_store(
db,
asset_id=asset_id,
tenant=lambda: project_tenant_id(project.root),
scope=SCOPE_STORYBOARD,
)
try:
return load_storyboard(store, asset_id=asset_id)
finally:
Expand All @@ -130,7 +136,12 @@ def save_storyboard(
"""
db = storyboard_db_path(project)
asset_id = project_asset_id(project)
store = open_graph_store(db, asset_id=asset_id, scope=SCOPE_STORYBOARD)
store = open_graph_store(
db,
asset_id=asset_id,
tenant=lambda: project_tenant_id(project.root),
scope=SCOPE_STORYBOARD,
)
try:
# The store enforces a foreign key on tier; register it first so the
# save is not rejected. Idempotent — re-registering is a no-op in lacing.
Expand Down
104 changes: 104 additions & 0 deletions tests/test_tenant_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""A project's shared-backend tenant is minted at ``Project.init`` (nw#109).

Under Postgres a project's graph lives in a tenant keyed on an id. Keyed on
root + title (or the song's hash), a project folder removed and created again
with the same name and title reused the old tenant and the old graph — including
its ``project-meta`` row and stable id — came back with the new project.

No Postgres is needed: a stand-in ``PostgresStore`` keeps one in-memory store
per ``project_id``, which is exactly the persistence a real tenant has.
"""

from __future__ import annotations

import json
import shutil

import pytest
from lacing import MemoryStore, TimeInterval
import lacing.store

import nw
from nw.migrate import project_asset_id, project_tenant_id
from nw.storage import TENANT_KEY, tenant_id_of

_TENANTS: dict[str, MemoryStore] = {}


class _FakePostgresStore:
"""Delegates to one shared ``MemoryStore`` per ``project_id``."""

def __init__(self, db_url, *, rate, owner_id, project_id):
self._project_id = project_id
self._inner = _TENANTS.setdefault(project_id, MemoryStore())

def __getattr__(self, name):
return getattr(self._inner, name)

def close(self): # the tenant outlives the connection
pass


@pytest.fixture
def pg(monkeypatch):
_TENANTS.clear()
monkeypatch.setenv("NW_GRAPH_BACKEND", "postgres")
monkeypatch.setenv("NW_GRAPH_DB_URL", "postgresql://stand-in")
monkeypatch.setattr(lacing.store, "PostgresStore", _FakePostgresStore)
yield _TENANTS
_TENANTS.clear()


def _graph_size(project: nw.Project) -> int:
return len(list(nw.iter_all_annotations(project.root)))


def test_init_mints_a_tenant_id_that_survives_write_spec(tmp_path):
project = nw.Project.init(tmp_path / "p", title="t")
minted = json.loads((project.root / "project.json").read_text())[TENANT_KEY]
assert minted
project.write_spec(project.read_spec())
assert json.loads((project.root / "project.json").read_text())[TENANT_KEY] == minted
assert project.storage.tenant_id == minted


def test_two_projects_get_distinct_tenant_ids(tmp_path):
a = nw.Project.init(tmp_path / "a", title="same")
b = nw.Project.init(tmp_path / "b", title="same")
assert a.storage.tenant_id != b.storage.tenant_id


def test_a_project_that_predates_the_mint_keeps_its_tenant(tmp_path):
root = tmp_path / "old"
root.mkdir()
spec = {"schema_version": 1, "title": "old"}
(root / "project.json").write_text(json.dumps(spec))
assert tenant_id_of(root, spec) == project_asset_id(root)
assert project_tenant_id(root) == project_asset_id(root)


def test_recreated_project_does_not_inherit_the_old_graph(tmp_path, pg):
root = tmp_path / "p"
first = nw.Project.init(root, title="t")
first.graph.upsert_shot(_shot_body(), TimeInterval.from_seconds(0, 4))
assert _graph_size(first) == 1
old_tenants = set(pg)

shutil.rmtree(root)
second = nw.Project.init(root, title="t")
assert _graph_size(second) == 0
assert set(pg) - old_tenants, "the new project got a tenant of its own"
# The old tenant is untouched.
assert set(old_tenants) <= set(pg)


def test_storyboard_and_graph_follow_the_same_minted_tenant(tmp_path, pg):
project = nw.Project.init(tmp_path / "p", title="t")
project.graph.upsert_shot(_shot_body(), TimeInterval.from_seconds(0, 4))
assert all(project.storage.tenant_id in pid for pid in pg)


def _shot_body():
from nw.bodies import ShotBodyV1

return ShotBodyV1(shot_id="s01")
Loading