78 lines
2.7 KiB
Python
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")
|