Files
docqube_backend/alembic/versions/c3_0_org_unit_path.py
T
2026-09-08 11:00:05 +05:30

78 lines
2.7 KiB
Python

"""C3 — a materialised path, so a subtree is a prefix match
`ScopeService` resolved authority with a recursive CTE, once per access code per
request. That is correct and it is the thing that gets slower as the tree gets
deeper, in the way that shows up as "the admin page feels slow" rather than as
an error — the query-count budgets cannot catch it, because the *count* does not
change.
A materialised path turns the walk into a prefix match:
/a1b2.../c3d4.../e5f6.../
The subtree of a unit is every row whose path starts with that unit's path.
Indexed with `text_pattern_ops`, that is an index range scan.
**Text, not `ltree`.** `ltree` is the tidier type and needs an extension, which
is a deployment prerequisite for a performance improvement — a poor trade. Text
with a prefix index needs nothing and behaves identically for this query.
**Why the delimiters matter.** Ids are stored between slashes, so
`LIKE '/a/b/%'` cannot match `/a/bc/...`. Without them a prefix match would leak
across sibling branches whose ids share a leading substring — which for UUIDs is
rare, and rare is worse than never.
The column is nullable and the CTE remains as a fallback for any row that has
not been backfilled, so this migration cannot break resolution even if the
backfill is incomplete.
Revision ID: c3_0_org_unit_path
Revises: c5_0_access_groups
"""
import sqlalchemy as sa
from alembic import op
revision = "c3_0_org_unit_path"
down_revision = "c5_0_access_groups"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("org_units", sa.Column("path", sa.Text(), nullable=True))
# Prefix matching needs `text_pattern_ops`; the default opclass supports
# equality and ordering but not `LIKE 'prefix%'` as an index scan.
op.execute(
"CREATE INDEX ix_org_units_path_prefix "
"ON org_units (path text_pattern_ops)"
)
op.create_index("ix_org_units_tenant_path", "org_units", ["tenant_id", "path"])
# Backfill, walking down from the roots. Done once here so the application
# never has to compute a path it did not write.
op.execute(
"""
WITH RECURSIVE tree AS (
SELECT id, '/' || id::text || '/' AS path
FROM org_units
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, t.path || c.id::text || '/'
FROM org_units c
JOIN tree t ON c.parent_id = t.id
)
UPDATE org_units u
SET path = tree.path
FROM tree
WHERE u.id = tree.id
"""
)
def downgrade() -> None:
op.drop_index("ix_org_units_tenant_path", table_name="org_units")
op.execute("DROP INDEX IF EXISTS ix_org_units_path_prefix")
op.drop_column("org_units", "path")