Manages a directed acyclic graph (DAG) of migrations and their dependencies.
This service builds and maintains a graph where nodes represent migrations and
edges represent dependencies between them. It can identify which migrations are
ready to execute based on their dependencies' states.
The graph is lazily built and automatically rebuilt when the migrations list
changes, so you never need to manually refresh it.
Attributes:
| Name |
Type |
Description |
migrations |
list[Migration]
|
List of migrations to manage.
|
Source code in src/cloe_delta_table_manager/migrations/migration_graph_service.py
| class MigrationGraphService:
"""Manages a directed acyclic graph (DAG) of migrations and their dependencies.
This service builds and maintains a graph where nodes represent migrations and
edges represent dependencies between them. It can identify which migrations are
ready to execute based on their dependencies' states.
The graph is lazily built and automatically rebuilt when the migrations list
changes, so you never need to manually refresh it.
Attributes:
migrations: List of migrations to manage.
"""
def __init__(self, migrations: list[Migration]) -> None:
"""Initialize the MigrationGraphService with a list of migrations.
Args:
migrations: List of Migration objects to manage.
"""
self.migrations: list[Migration] = migrations
self._cached_graph: nx.DiGraph | None = None
self._cached_migration_ids: set[int] | None = None
self._validate_acyclic()
@property
def graph(self) -> nx.DiGraph:
"""Get the migration dependency graph.
The graph is lazily built and automatically rebuilt if the migrations
list has changed since the last access.
Returns:
A directed graph where nodes are migration IDs and edges
represent dependencies.
"""
current_ids = {m.operation_hash for m in self.migrations}
# Check if we need to rebuild the graph
if self._cached_graph is None or self._cached_migration_ids != current_ids:
self._cached_migration_ids = current_ids
self._cached_graph = self._create_graph()
return self._cached_graph
def _create_graph(self) -> nx.DiGraph:
"""Create a directed acyclic graph (DAG) of migrations and their dependencies.
Each node in the graph represents a migration (identified by its operation_hash),
and each edge represents a dependency relationship where the source node must
be executed before the target node.
Returns:
A NetworkX DiGraph with operation hashes as nodes.
"""
g: nx.DiGraph = nx.DiGraph()
# Add all migrations as nodes using their deterministic operation_hash
migration_hashes = {m.operation_hash for m in self.migrations}
g.add_nodes_from(migration_hashes)
# Add edges for dependencies
edges = set()
for migration in self.migrations:
dep: Migration
for dep in migration.depends_on: # type: ignore[assignment]
# Edge from dependency to migration (dependency must run first)
edges.add((dep.operation_hash, migration.operation_hash))
g.add_edges_from(edges)
return g
def _validate_acyclic(self) -> None:
"""Validate that the migration graph is acyclic.
Raises:
RuntimeError: If a circular dependency is detected in the migration graph.
"""
# Access graph property to ensure it's up to date
graph = self.graph
if not nx.is_directed_acyclic_graph(graph):
cycles = list(nx.simple_cycles(graph))
# Convert cycles from IDs to readable format
migration_map = {m.operation_hash: m for m in self.migrations}
readable_cycles = []
for cycle in cycles:
cycle_names = []
for node_id in cycle:
migration = migration_map[node_id]
# Use source_path if available, otherwise use id
name = (
str(migration.source_path)
if hasattr(migration, "source_path") and migration.source_path
else f"Migration(id={node_id})"
)
cycle_names.append(name)
readable_cycles.append(" -> ".join(cycle_names))
cycle_str = "\n".join(f" - {cycle}" for cycle in readable_cycles)
raise RuntimeError(f"Circular dependency detected in migrations. Cycles found:\n{cycle_str}")
def get_execution_order(self) -> list[Migration]:
"""Return NEW migrations in topological execution order.
Returns:
List of NEW migrations ordered so that all dependencies come before dependants.
"""
migration_map = {m.operation_hash: m for m in self.migrations}
new_hashes = {m.operation_hash for m in self.migrations if m.status == MigrationStatus.NEW}
return [migration_map[h] for h in nx.topological_sort(self.graph) if h in new_hashes]
def get_ready_migration(self) -> Migration | None:
"""Get the next migration that is ready to be applied.
A migration is ready to execute if:
1. Its state is NEW
2. All its dependencies have state APPLIED
Returns:
The first Migration object that is ready to execute, or None if no migrations are ready.
"""
for migration in self.migrations:
if migration.status != MigrationStatus.NEW:
continue
# Check if all dependencies are applied
all_dependencies_applied = all(
dep.status == MigrationStatus.APPLIED # type: ignore[attr-defined]
for dep in migration.depends_on
)
if all_dependencies_applied:
migration.status = MigrationStatus.PENDING
return migration
return None
|
graph
property
Get the migration dependency graph.
The graph is lazily built and automatically rebuilt if the migrations
list has changed since the last access.
Returns:
| Type |
Description |
DiGraph
|
A directed graph where nodes are migration IDs and edges
|
DiGraph
|
|
__init__(migrations)
Initialize the MigrationGraphService with a list of migrations.
Parameters:
| Name |
Type |
Description |
Default |
migrations
|
list[Migration]
|
List of Migration objects to manage.
|
required
|
Source code in src/cloe_delta_table_manager/migrations/migration_graph_service.py
| def __init__(self, migrations: list[Migration]) -> None:
"""Initialize the MigrationGraphService with a list of migrations.
Args:
migrations: List of Migration objects to manage.
"""
self.migrations: list[Migration] = migrations
self._cached_graph: nx.DiGraph | None = None
self._cached_migration_ids: set[int] | None = None
self._validate_acyclic()
|
get_execution_order()
Return NEW migrations in topological execution order.
Returns:
| Type |
Description |
list[Migration]
|
List of NEW migrations ordered so that all dependencies come before dependants.
|
Source code in src/cloe_delta_table_manager/migrations/migration_graph_service.py
| def get_execution_order(self) -> list[Migration]:
"""Return NEW migrations in topological execution order.
Returns:
List of NEW migrations ordered so that all dependencies come before dependants.
"""
migration_map = {m.operation_hash: m for m in self.migrations}
new_hashes = {m.operation_hash for m in self.migrations if m.status == MigrationStatus.NEW}
return [migration_map[h] for h in nx.topological_sort(self.graph) if h in new_hashes]
|
get_ready_migration()
Get the next migration that is ready to be applied.
A migration is ready to execute if:
1. Its state is NEW
2. All its dependencies have state APPLIED
Returns:
| Type |
Description |
Migration | None
|
The first Migration object that is ready to execute, or None if no migrations are ready.
|
Source code in src/cloe_delta_table_manager/migrations/migration_graph_service.py
| def get_ready_migration(self) -> Migration | None:
"""Get the next migration that is ready to be applied.
A migration is ready to execute if:
1. Its state is NEW
2. All its dependencies have state APPLIED
Returns:
The first Migration object that is ready to execute, or None if no migrations are ready.
"""
for migration in self.migrations:
if migration.status != MigrationStatus.NEW:
continue
# Check if all dependencies are applied
all_dependencies_applied = all(
dep.status == MigrationStatus.APPLIED # type: ignore[attr-defined]
for dep in migration.depends_on
)
if all_dependencies_applied:
migration.status = MigrationStatus.PENDING
return migration
return None
|