c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
"""Revision-chain primitives for graph/vector database migrations.
|
|
|
|
Mirrors Alembic's model: each migration declares a stable ``revision`` and the
|
|
``down_revision`` it builds on, forming a single linear chain. The database
|
|
stores the last-applied revision; the runner walks the chain forward to head.
|
|
Cognee versions are never compared for ordering — they are recorded only as
|
|
informational metadata on each migration and on the database row.
|
|
|
|
The revision IS the slug. It is stored verbatim in the database so an operator
|
|
reading a ``dataset_database`` row can see exactly which migration last ran —
|
|
no hashing, no lookup table. Slugs are therefore append-only and immutable:
|
|
renaming one orphans every stamped database (see ``pending_migrations``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Awaitable, Callable, Optional
|
|
from uuid import UUID
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Unknown stored revisions already warned about (once per process per value):
|
|
# the check runs on every startup AND on every cognify() pending-check, so an
|
|
# unrepaired legacy row must not flood the logs.
|
|
_warned_unknown_revisions: set = set()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MigrationContext:
|
|
"""Everything a migration needs to touch every store that holds a node id.
|
|
|
|
A single Entity/EntityType id is a key in three places — the graph DB, the
|
|
vector DB (point id), and the relational ledger (``nodes.slug`` /
|
|
``edges.source_node_id`` / ``edges.destination_node_id``). A migration that
|
|
rewrites ids must update all three in lockstep, so it receives all the
|
|
handles here rather than just one engine.
|
|
|
|
Attributes:
|
|
graph_engine: Resolved graph engine for this database.
|
|
vector_engine: Resolved vector engine for this database.
|
|
dataset_id: The dataset whose ledger rows this migration may touch
|
|
(access control on: one database pair per dataset), or ``None`` in
|
|
global mode (access control off: one global pair backs every
|
|
dataset, so ledger updates apply unscoped).
|
|
"""
|
|
|
|
graph_engine: Any
|
|
vector_engine: Any
|
|
dataset_id: Optional[UUID] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Migration:
|
|
"""A single graph or vector database migration.
|
|
|
|
Attributes:
|
|
slug: Human-readable unique identifier; stored verbatim as the revision.
|
|
IMMUTABLE once shipped — renaming orphans every stamped database.
|
|
cognee_version: Release this migration shipped in (informational only).
|
|
up: Async callable receiving a :class:`MigrationContext`.
|
|
down_revision: Slug of the migration this builds on (``None`` for the
|
|
first migration in a chain).
|
|
down: Optional reverse transformation (same signature as ``up``).
|
|
A chain can only be downgraded through migrations that define it.
|
|
"""
|
|
|
|
slug: str
|
|
cognee_version: str
|
|
up: Callable[["MigrationContext"], Awaitable[None]]
|
|
down_revision: Optional[str] = None
|
|
down: Optional[Callable[["MigrationContext"], Awaitable[None]]] = None
|
|
|
|
@property
|
|
def revision(self) -> str:
|
|
return self.slug
|
|
|
|
|
|
def order_migrations(migrations: list[Migration]) -> list[Migration]:
|
|
"""Return migrations ordered from the root of the chain to its head.
|
|
|
|
Raises ``ValueError`` if the chain branches (two migrations share a parent)
|
|
or is disconnected (a parent is missing), enforcing Alembic's single-head,
|
|
linear-history guarantee.
|
|
"""
|
|
if not migrations:
|
|
return []
|
|
|
|
by_down: dict[Optional[str], Migration] = {}
|
|
for migration in migrations:
|
|
if migration.down_revision in by_down:
|
|
raise ValueError(
|
|
f"Migration chain branches at down_revision={migration.down_revision!r}: "
|
|
f"{by_down[migration.down_revision].slug!r} and {migration.slug!r}"
|
|
)
|
|
by_down[migration.down_revision] = migration
|
|
|
|
ordered: list[Migration] = []
|
|
cursor: Optional[str] = None # the chain root has down_revision == None
|
|
while cursor in by_down:
|
|
migration = by_down.pop(cursor)
|
|
ordered.append(migration)
|
|
cursor = migration.revision
|
|
|
|
if by_down:
|
|
raise ValueError(
|
|
"Disconnected migrations (unreachable parents): "
|
|
+ ", ".join(sorted(m.slug for m in by_down.values()))
|
|
)
|
|
|
|
return ordered
|
|
|
|
|
|
def head_revision(migrations: list[Migration]) -> Optional[str]:
|
|
"""Return the revision at the head of the chain (``None`` if there are none)."""
|
|
ordered = order_migrations(migrations)
|
|
return ordered[-1].revision if ordered else None
|
|
|
|
|
|
def pending_migrations(
|
|
migrations: list[Migration],
|
|
stored_revision: Optional[str],
|
|
target_revision: str = "head",
|
|
) -> list[Migration]:
|
|
"""Return the migrations needed to bring ``stored_revision`` up to ``target_revision``.
|
|
|
|
- ``None`` stored revision -> everything up to the target (a database with
|
|
no recorded revision runs every migration).
|
|
- stored revision at/beyond the target -> empty list.
|
|
- ``target_revision`` is ``"head"`` (default) or a slug; an unknown slug
|
|
RAISES — an explicit target is an operator action, never best-effort.
|
|
- stored revision unknown to this chain -> empty list, with a WARNING.
|
|
Unlike Alembic (which raises), this tolerates a database stamped by newer
|
|
code (rollback); but the same state also arises from a renamed migration
|
|
slug or a corrupted revision row — silently disabled migrations — so it
|
|
must never pass without a trace in the logs.
|
|
"""
|
|
ordered = order_migrations(migrations)
|
|
|
|
if target_revision == "head":
|
|
end_index = len(ordered)
|
|
else:
|
|
revisions = [migration.revision for migration in ordered]
|
|
if target_revision not in revisions:
|
|
raise ValueError(
|
|
f"Target revision {target_revision!r} is unknown to this chain; cannot upgrade."
|
|
)
|
|
end_index = revisions.index(target_revision) + 1
|
|
|
|
if stored_revision is None:
|
|
return ordered[:end_index]
|
|
|
|
for index, migration in enumerate(ordered):
|
|
if migration.revision == stored_revision:
|
|
return ordered[index + 1 : end_index]
|
|
|
|
if stored_revision not in _warned_unknown_revisions:
|
|
_warned_unknown_revisions.add(stored_revision)
|
|
logger.warning(
|
|
"Stored migration revision %r is unknown to this chain (head: %r) — no migrations "
|
|
"will run for this database. Expected only after a rollback to older code; if no "
|
|
"rollback happened, a migration slug was renamed or the revision row is corrupted, "
|
|
"and this database will silently skip all future migrations until repaired "
|
|
"(`cognee-cli stamp base --dataset <id>` re-arms the chain). Logged once per process.",
|
|
stored_revision,
|
|
ordered[-1].revision if ordered else None,
|
|
)
|
|
return []
|
|
|
|
|
|
def migrations_to_downgrade(
|
|
migrations: list[Migration],
|
|
stored_revision: Optional[str],
|
|
target_revision: Optional[str] = None,
|
|
) -> list[Migration]:
|
|
"""Return the migrations to revert (newest first) to reach ``target_revision``.
|
|
|
|
- ``None`` stored revision -> nothing applied, nothing to revert.
|
|
- ``None`` target -> revert every applied migration (back to pre-chain state).
|
|
- Unlike :func:`pending_migrations`, an unknown stored or target revision
|
|
RAISES: downgrading is always an explicit operator action against a state
|
|
that must be fully understood, never a best-effort no-op.
|
|
- Every migration in the returned span must define ``down`` — a chain
|
|
cannot skip a step — otherwise this raises.
|
|
"""
|
|
ordered = order_migrations(migrations)
|
|
if stored_revision is None:
|
|
return []
|
|
|
|
revisions = [migration.revision for migration in ordered]
|
|
if stored_revision not in revisions:
|
|
raise ValueError(
|
|
f"Stored revision {stored_revision!r} is unknown to this chain; cannot downgrade."
|
|
)
|
|
stored_index = revisions.index(stored_revision)
|
|
|
|
if target_revision is None:
|
|
target_index = -1
|
|
else:
|
|
if target_revision not in revisions:
|
|
raise ValueError(
|
|
f"Target revision {target_revision!r} is unknown to this chain; cannot downgrade."
|
|
)
|
|
target_index = revisions.index(target_revision)
|
|
if target_index > stored_index:
|
|
raise ValueError(
|
|
f"Target revision {target_revision!r} is ahead of stored "
|
|
f"{stored_revision!r}; nothing to downgrade."
|
|
)
|
|
|
|
to_revert = list(reversed(ordered[target_index + 1 : stored_index + 1]))
|
|
irreversible = [migration.slug for migration in to_revert if migration.down is None]
|
|
if irreversible:
|
|
raise ValueError(
|
|
"Cannot downgrade: migration(s) without a down() in the span: "
|
|
+ ", ".join(irreversible)
|
|
)
|
|
return to_revert
|