Pluggable Tree Strategies & Zero Downtime Migrations
This document explains the architecture of the pluggable tree strategy system in Framework M, how recursive CTEs are leveraged, and how to safely migrate tree strategies in a production environment without downtime.
Architecture Design
Framework M uses a Strategy Pattern to support multiple tree representations transparently. All tree operations are abstracted through a uniform interface:
Strategy Resolution
When a repository operation (insert, update, or fetch tree) is performed, TreeManager inspects the DocType's mixin hierarchy:
- If the DocType inherits from
NestedSetMixin, theNestedSetTreeStrategyis instantiated. - If it inherits from
AdjacencyListMixin, theAdjacencyListTreeStrategyis instantiated.
This decoupling guarantees that the generic repository and the controller routes do not need to concern themselves with the specifics of tree bounds shifting or recursive SQL generation.
Recursive SQL CTEs in Adjacency List
Unlike the Nested Set model, which encodes hierarchy bounds directly in each row via lft and rgt integers, the Adjacency List model relies entirely on parent pointers (parent_value).
To query subtrees or validate relationships efficiently in SQL without making multiple roundtrips, the framework generates Recursive Common Table Expressions (CTEs).
1. Querying a Subtree
To fetch a node and all of its descendants recursively, the strategy constructs a recursive SELECT query:
- Anchor Member: Selects the target parent node by ID.
- Recursive Member: Joins the target table back to the anchor on
child.parent_value = parent.id.
WITH RECURSIVE tree_cte AS (
-- Anchor
SELECT id, parent_value, name, is_group
FROM todo
WHERE id = :parent_id
UNION ALL
-- Recursive Join
SELECT t.id, t.parent_value, t.name, t.is_group
FROM todo t
INNER JOIN tree_cte ON t.parent_value = tree_cte.id
)
SELECT id, parent_value, name, is_group FROM tree_cte;
2. Preventing Circular Dependencies (The Ancestor Trap)
When updating a node's parent, the framework must ensure the new parent is not a child (or deeper descendant) of the node itself. To check this:
- The strategy runs a CTE query starting from the proposed new parent and recursively tracing upward to all its ancestors.
- If the node's own ID appears in the ancestor list, a loop is detected, and a
ValueErroris raised.
Zero Downtime Migrations (ZDM)
Migrating an active production database table from one tree strategy to another involves schema modifications (adding or dropping lft/rgt columns) and data backfills (generating nested set bounds or rebuilding parent pointers).
- Rebuilding bounds synchronously locks the entire table, causing request queues to back up.
- High-concurrency environments will experience deadlocks and OOMs.
- Schema changes and data backfills must be performed as asynchronous, multi-phase operations.
Scenario A: Migrating from Adjacency List to Nested Set
To switch a table from AdjacencyListMixin to NestedSetMixin in production:
-
Phase 1: Schema Extension (Additive)
- Create an Alembic migration to add nullable
lftandrgtcolumns to the table. - Run the migration. The application continues running on the old code (using the adjacency list logic).
- Create an Alembic migration to add nullable
-
Phase 2: Asynchronous Data Backfill
- Write a background worker script (using Taskiq) to calculate and backfill
lftandrgtbounds in chunks or within a controlled transaction:# Run as a background task to avoid blocking the web serverasync def backfill_nested_set_bounds():# Calculate lft/rgt bounds based on parent_value hierarchy# and update rows in small batches or with an exclusive table lock# during a low-traffic window.
- Write a background worker script (using Taskiq) to calculate and backfill
-
Phase 3: Code Deployment & Constraints
- Update the DocType in python to inherit from
NestedSetMixininstead ofAdjacencyListMixin. - Run
m migrate syncto register the schema shift. - Deploy the new application version. The framework now uses
NestedSetTreeStrategyfor all reads and writes. - Run a final Alembic patch to set
NOT NULLconstraints onlftandrgtcolumns once all rows have been backfilled.
- Update the DocType in python to inherit from
Scenario B: Migrating from Nested Set to Adjacency List
To switch a table from NestedSetMixin to AdjacencyListMixin in production:
-
Phase 1: Code Update
- Update the DocType class to inherit from
AdjacencyListMixininstead ofNestedSetMixin. - Deploy the code. The application immediately switches to using recursive CTEs.
lftandrgtcolumns are ignored by the application but still exist in the database.
- Update the DocType class to inherit from
-
Phase 2: Cleanup (Schema Reduction)
- After confirming the application is stable under the new strategy, generate an Alembic migration to drop the
lftandrgtcolumns. - Run the migration. This frees up database pages and reduces storage overhead.
- After confirming the application is stable under the new strategy, generate an Alembic migration to drop the