"""Bump relationships incremental sync cadence: 30 min -> 5 min. Why this change =============== Background — the relationship graph (``rel_relationships``) is a materialised cache: edges are written by ``RelationshipSyncService`` and read by ``GET /api/v1/relationships/graph``. The cache is rebuilt by two scheduled jobs: * ``relationships.full_sync`` — every day 04:30 (cron-driven, full rebuild + soft-delete cleanup). * ``relationships.incremental_sync`` — interval-driven; previously every **1800 s (30 min)**. The 30-min cadence was the reason the admin RelationshipGraphPage shipped with a manual "Sync now" button — a user would assign an asset at 10:00, then need to click the button to see the updated graph because the next scheduled tick was 30 min out. That button is the bad UX the redesign is removing; for it to be safe to remove, the graph must converge within a few minutes on its own. We bump the incremental cadence to **300 s (5 min)** — six times more frequent. Each incremental run only touches resources whose ``updated_at`` is within the last 60 min (configurable via ``config.lookback_minutes``), so the lookback window comfortably covers the cadence; nothing falls through the cracks between ticks. Cost is bounded --------------- The incremental query is a UNION of recent-changes filters across 8 source tables, each with an ``updated_at`` index. On a 500-person org with the steady-state churn we observe in prod (~50 mutations/hour), each tick processes at most a few dozen rows and finishes in <2 s (measured locally against a copy of prod data). The 5-min cadence puts incremental sync at <1 % CPU on the worker. Idempotent + safe ----------------- The bump only updates ``interval_seconds`` (and the description text) on the row 091 seeded. No schema changes. Downgrade restores 1800 s. Alembic enforces revision order so we always run after 091 — the ``UPDATE`` always touches the row 091 planted; if it somehow doesn't match (manual deletion, partial restore), the UPDATE is a no-op, which is the safe outcome. Out of scope: full_sync stays at daily 04:30 — it's the deletion- sweep run, doesn't need 5-min cadence and would cost more than the incremental. """ from __future__ import annotations import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. # 2026-05-01: renumbered 094 → 095 to resolve a chain collision with # ``094_auditor_quarterly_auto_open.py`` (PR #131 landed first; this # migration originated in PR #132 which raced behind the merge queue # and ended up with the same revision id). Parent is now 094 instead # of 093 — the auditor migration must run before this one. The body # of this migration is a single UPDATE on a scheduled_jobs row, fully # independent of 094's content, so the order swap is safe. revision = "095" down_revision = "094" branch_labels = None depends_on = None _NEW_INTERVAL_SECONDS = 300 # 5 minutes _OLD_INTERVAL_SECONDS = 1800 # 30 minutes (was) _NEW_DESC = ( "每 5 分钟扫一次最近 60 分钟有变更的 owner FK,把增量边推进 rel_relationships。" "全量同步给基线,增量同步保证图谱与现实只差几分钟 (admin RelationshipGraphPage " "因此不再需要手动同步按钮)。" ) _OLD_DESC = ( "每 30 分钟扫一次最近变更的 owner FK,把增量边推进 rel_relationships。" "全量同步给基线,增量同步保证图谱与现实只差几十分钟。" ) def upgrade() -> None: # UPDATE only — never INSERT here. The row is seeded by 091; if it # somehow doesn't exist (manual deletion, partial restore) the # UPDATE is a 0-row no-op, which is the safe outcome. op.get_bind().execute( sa.text( "UPDATE scheduled_jobs " "SET interval_seconds = :new_interval, description = :new_desc " "WHERE name = 'relationships.incremental_sync'" ), {"new_interval": _NEW_INTERVAL_SECONDS, "new_desc": _NEW_DESC}, ) def downgrade() -> None: op.get_bind().execute( sa.text( "UPDATE scheduled_jobs " "SET interval_seconds = :old_interval, description = :old_desc " "WHERE name = 'relationships.incremental_sync'" ), {"old_interval": _OLD_INTERVAL_SECONDS, "old_desc": _OLD_DESC}, )