-
Notifications
You must be signed in to change notification settings - Fork 715
UN-3883 [FEAT] Cut dashboard metrics cron DB load: narrower source windows, monthly from daily, two new indexes - #2276
Conversation
...from the daily tier (#2255) * UN-3973 Derive monthly metrics from the daily tier, narrow source window to 2 days The dashboard aggregation widened its DAY-granularity query to the first of the previous month so monthly buckets could be summed in Python from the same rows. Every run re-read 32-62 days of source data per metric, per org, 96 times a day. Monthly is now rolled up from event_metrics_daily in one statement for all orgs, so the source queries only need the daily window. That window drops to 2 days, sized against the measured worst created_at -> terminal-status lag of ~2h. A once-daily 7-day pass reruns the same task at a wider bound to repair gaps left by cron downtime. The active-org prefilter is decoupled from the daily window and pinned at 7 days: metrics filtered on another column (hitl_completions on approved_at) can land for an org whose executions are older than the source window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Address Sonar and Greptile review findings Sonar: - S117: rename apps.get_model() locals in 0004 to snake_case - S3776: cut _run_aggregation cognitive complexity from 22 by hoisting the static metric config tables to module level and extracting the per-org body, the active-org prefilter and the result shape into helpers Greptile: - Monthly rows in the rebuilt window whose daily rows are gone are now deleted alongside the upsert, so the two tiers cannot disagree. An empty daily tier still short-circuits, so a wiped tier cannot cascade into deleting monthly history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Add tests covering the source window, rollup SQL and reconciliation schedule Closes the acceptance criteria that had no automated check: - the monthly rollup issues no source-table SQL, asserted by capturing the queries it actually sends - the window ladder at 2 / 7 / 62 days, including a row that finishes after the narrow window has moved past its created_at and so never re-enters it - the reconciliation schedule row, its idempotency and its reverse The schedule tests call the migration's function directly. The suite runs with --no-migrations, so data migrations never execute and asserting on the beat row would fail regardless of the migration being correct. Also moves the dotenv load in settings/base.py above the Celery block. CELERY_BROKER_BASE_URL, _USER and _PASS were read above it, so they could not be supplied by an env file at all and had to be ambient. Ambient values still take precedence, so deployed behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Trim comments in tasks.py and revert the unrelated settings change Cut the verbose comments and docstrings down to the purpose and the non-obvious bits. Code is unchanged. Restore backend/settings/base.py to main — moving the dotenv load ahead of get_required_setting was a local test convenience, not part of this change. The test rig exports the broker vars itself, so CI never needed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Renumber the reconciliation migration to 0005 UN-3445 landed 0004_pg_periodic_tasks on main after this branch was cut, leaving dashboard_metrics with two 0004s depending on 0003 and nothing depending on either. Django saw two leaf nodes and refused to build the graph, so `migrate` failed before applying anything — every app, not just this one. Depend on 0004_pg_periodic_tasks and renumber to match, so the short prefix form stays usable for a rollback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 [FIX] Address review: PG transport, scoped orphan sweep, retry posture The reconciliation row could not run on the PG transport — two functions share the task name dashboard_metrics.aggregate_from_sources and the worker one took no arguments, so the mirrored row dispatched source_window_days into a zero-arg function and the message was dropped. The worker proxy and the internal endpoint now plumb it, and 0005 declares the PG twin rather than leaving the mirror to invent one. The orphan sweep is scoped to the (organization, month) partitions the rollup actually produced: an incomplete daily tier passed the empty-tier guard and deleted monthly rows it could not vouch for. Its deletion count now reaches the task result and a WARNING. DatabaseError and OperationalError propagate from the monthly rollup so the configured autoretry fires, instead of being logged once behind success: True. The prefilter is never narrower than the query window, so a widened source_window_days cannot skip the orgs it exists to repair. bulk_create takes an explicit batch_size. The Beat/PG drift guard named 0002 and 0004, so it kept comparing three schedules against three while this PR added a fourth. It now discovers every migration in the app, replays their RunPython forwards in order, derives the Beat cadence from the schedule row, binds every declared kwarg to its task signature, and asserts every post-install Beat write bumps PeriodicTasks.last_update. The reconcile row moves to 04:40 UTC: */15 fires at minute 0, and the per-tier lock keys do not block each other, so 04:00 started two full aggregations at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 [FIX] Address Athul's review: upsert-only monthly, per-schedule lock, honest success The orphan delete is removed. The design agreed on this ticket (comments 44768/45016) is a pure INSERT ... ON CONFLICT DO UPDATE; the delete was scope beyond it, and it converts a recoverable undercount into unrecoverable loss — the daily rows that would rebuild a deleted monthly row are exactly the ones that were missing. A stale total is recoverable with backfill_metrics. The reconciliation pass no longer shares a lock key with the 15-minute schedule. The 15-minute row is an IntervalSchedule and drifts against a fixed crontab, so on a shared key the once-daily repair loses the race roughly one day in seven, returns skipped=True and is never retried. A run in which every metric for every org failed no longer reports success: True. The result's success now reflects the error count, the completion log rises to WARNING, and the worker-side guard reads skipped_reason and errors as well as skipped — it saw none of these three did-nothing shapes before. A failed monthly rollup is distinguishable from an empty one: upserted=0 collided with the legitimate no-op and the no_active_orgs return. source_window_days is validated and bounded. It arrives as JSON from a Beat row that is editable in the admin: negative puts the window in the future, 0 never refreshes yesterday, 365 restores the multi-month scan this ticket exists to remove. Tests: a golden test seeds source rows, lets the real aggregation populate daily, and compares the rolled-up monthly against the pre-change derivation computed independently from get_documents_processed — AC-4 was claimed Met and had no equivalence assertion. Fixture offsets derive from the month boundary rather than fixed day counts, which land in the wrong month for the last days of any month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
...ecution on (status, created_at) (#2264) * UN-3972 [PERF] Index workflow_file_execution on (status, created_at) The dashboard metrics cron's documents_processed and failed_pages queries filter this table on status + a created_at window, but all four existing indexes lead with workflow_execution_id. With no entry point here the planner drives top-down from the org and sequentially scans all 1.28M rows of workflow_execution — 83% of the cron's DB time on production. Built CONCURRENTLY with atomic = False; a plain AddIndex would hold a SHARE lock over a 3.4GB table taking live inserts. Guarded against a leftover INVALID index from an interrupted build, which IF NOT EXISTS would otherwise keep silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3972 [PERF] Trim the migration docstring to the project ceiling The docstring restated the prod plan, deployment runbook and recovery steps. That detail belongs in the PR, not in a file every future agent scans. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3972 [PERF] Guard the index migration's non-atomic CONCURRENTLY shape with tests The suite runs with --no-migrations, so 0007 is never executed in CI. Regenerating it with makemigrations, or dropping atomic = False / CONCURRENTLY while tidying, would land a plain AddIndex — a SHARE lock held for the whole build on a 3.4 GB table that takes live inserts — with every test still green. Five DB-free assertions on the migration module and the model's Meta.indexes: non-atomic, concurrent in both directions, the INVALID-index guard present, AddIndex confined to state_operations, and model/migration agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3972 [FIX] Assert the index definition, not just its validity, and pin reversibility The CREATE INDEX CONCURRENTLY IF NOT EXISTS matches on name alone, so a hand-built index with different columns was kept while Django recorded (status, created_at) into model state — a permanent, invisible divergence that makemigrations --check cannot see. The guard now compares pg_get_indexdef against the expected btree definition and qualifies the lookup by current_schema(), since app tables live in the unstract schema. Also pin that every database_operation is reversible: dropping the guard's reverse_sql=noop killed the whole rollback path with all five tests still green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3972 [FIX] Address Athul's review: guard semantics, whole-migration assertions Three mutations that were green are now caught: appending a bare AddIndex after the SeparateDatabaseAndState (a real lock-taking build on a 3.4 GB table, invisible because every assertion read operations[0]); flipping the guard's NOT indisvalid polarity, which either raises on every healthy deploy or never fires at all; and a typo in reverse_sql, which makes rollback a silent no-op through IF EXISTS while Django unapplies the migration. The CREATE assertion matches the column order by regex instead of an exact byte sequence — removing one space used to fail it, a false-failure mode whose only outcome is someone loosening the assertion. Docstrings: the plan citation now points at UN-4045, which supersedes the earlier workflow_file_execution reading; "every existing index leads with workflow_execution_id" was false (the PK leads with id); the exact CREATE statement an operator should run out of band is spelled out, with a warning off the struck two-index variant; and the models.py comment no longer implies the index fixes both cron queries when it fixes one until UN-3973 narrows the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
...edule by tier and indexing workflow_execution on created_at (#2265) * UN-3973 Derive monthly metrics from the daily tier, narrow source window to 2 days The dashboard aggregation widened its DAY-granularity query to the first of the previous month so monthly buckets could be summed in Python from the same rows. Every run re-read 32-62 days of source data per metric, per org, 96 times a day. Monthly is now rolled up from event_metrics_daily in one statement for all orgs, so the source queries only need the daily window. That window drops to 2 days, sized against the measured worst created_at -> terminal-status lag of ~2h. A once-daily 7-day pass reruns the same task at a wider bound to repair gaps left by cron downtime. The active-org prefilter is decoupled from the daily window and pinned at 7 days: metrics filtered on another column (hitl_completions on approved_at) can land for an org whose executions are older than the source window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Address Sonar and Greptile review findings Sonar: - S117: rename apps.get_model() locals in 0004 to snake_case - S3776: cut _run_aggregation cognitive complexity from 22 by hoisting the static metric config tables to module level and extracting the per-org body, the active-org prefilter and the result shape into helpers Greptile: - Monthly rows in the rebuilt window whose daily rows are gone are now deleted alongside the upsert, so the two tiers cannot disagree. An empty daily tier still short-circuits, so a wiped tier cannot cascade into deleting monthly history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Add tests covering the source window, rollup SQL and reconciliation schedule Closes the acceptance criteria that had no automated check: - the monthly rollup issues no source-table SQL, asserted by capturing the queries it actually sends - the window ladder at 2 / 7 / 62 days, including a row that finishes after the narrow window has moved past its created_at and so never re-enters it - the reconciliation schedule row, its idempotency and its reverse The schedule tests call the migration's function directly. The suite runs with --no-migrations, so data migrations never execute and asserting on the beat row would fail regardless of the migration being correct. Also moves the dotenv load in settings/base.py above the Celery block. CELERY_BROKER_BASE_URL, _USER and _PASS were read above it, so they could not be supplied by an env file at all and had to be ambient. Ambient values still take precedence, so deployed behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Trim comments in tasks.py and revert the unrelated settings change Cut the verbose comments and docstrings down to the purpose and the non-obvious bits. Code is unchanged. Restore backend/settings/base.py to main — moving the dotenv load ahead of get_required_setting was a local test convenience, not part of this change. The test rig exports the broker vars itself, so CI never needed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [PERF] Split the dashboard metrics schedule by tier and index workflow_execution on created_at Schedule split. One schedule ran every 15 minutes and wrote all three metric tiers. Dashboard daily and monthly figures do not need 15-minute freshness, so they move to hourly — 96 runs a day becomes 24 for the expensive DAY-granularity half of the work, while the hourly tier keeps its cadence. Both schedule rows point at the same task and differ only in a `tier` kwarg; a second task name would need its own worker registration and internal endpoint for the PG path. The lock is now keyed per tier, so the two runs that collide at the top of every hour do not starve each other. Omitting `tier` still writes all three tiers, so a manual trigger never silently writes nothing. Prefilter index. The active-org prefilter measures 1,849ms per call on production — the slowest single query on the instance. Nothing on workflow_execution leads with created_at: the two composite indexes are date-ordered only within one workflow or pipeline, and the partial index is empty in steady state. The split raises this query's call count, and UN-4045 will leave three more metric queries on the same bare date-range shape, so the index lands with the split rather than after it. Built CONCURRENTLY with atomic = False and guarded against a leftover INVALID index, matching migration 0026. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [PERF] Keep scheduler ownership out of the split migration and cut _run_aggregation's complexity Migration 0005 used update_or_create for the PG row of the schedule it was only re-keying, which reset pg_owned to False. converge_pg_scheduler disables a row's Beat twin when the PG scheduler adopts it, so on an adopted deployment the migration would have left the aggregation with no firer at all — Beat disabled, PG no longer owning it. It now updates only task_kwargs on that row, leaving enabled and pg_owned to the scheduler that owns them. Rollback is symmetric. Threading the tier through _run_aggregation took its cognitive complexity from 25 to 27 against a limit of 15. Extracted _collect_org_metrics and _aggregate_org, and hoisted the two static metric tables to module level so they are not rebuilt per call. Names match the same extraction on #2255 so the two reconcile cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [PERF] Trim comments and docstrings to the project ceiling The two index migrations carried 50-60 line docstrings restating the prod plan, deployment runbook and recovery steps. That detail belongs in the PR, not in files every future agent scans. Cut to purpose and key behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [PERF] Cover all three acceptance criteria with tests The suite runs with --no-migrations, so neither 0005 nor 0029 ever executes in CI, and nothing pinned the schedule split's behaviour at all. 46 tests, at least one per acceptance criterion. AC-1 — cadence, and the tier reaching the task. 0005 creates one row and rewrites one, both scheduler tables agreeing, and the rewrite touches neither pg_owned nor enabled: on an adopted deployment converge_pg_scheduler has already disabled the Beat twin, so handing ownership back would leave the aggregation with no firer. Separately the internal endpoint and the worker proxy are pinned to carry `tier` — that leg fails silently, since _call_internal builds a body only when a tier is given and the existing worker test called the task without one. AC-2 — the split changes no figure. Runs the real _run_aggregation three times and diffs the metrics tables: `hourly` reproduces the pre-split hourly figures exactly, and hourly + daily_monthly reproduce every row `all` writes. Two guards keep it from going vacuous, the second because mutation testing caught the first version passing while _aggregate_single_metric was broken — the fixture produced only LLM metrics, leaving half the split unverified. AC-3 — the index. Migration shape (non-atomic, CONCURRENTLY both directions, the INVALID guard, AddIndex confined to state_operations), plus an integration test that EXPLAINs the query the aggregation actually issues, captured rather than rewritten: a hand-copied queryset would keep passing after the prefilter changed, which is the one thing it is for. Rows are inserted in ascending created_at order so the heap matches production's append order. The Query Insights half of AC-3 is a production reading and is deliberately not faked here. Every test verified to fail when the thing it guards breaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 Renumber the reconciliation migration to 0005 UN-3445 landed 0004_pg_periodic_tasks on main after this branch was cut, leaving dashboard_metrics with two 0004s depending on 0003 and nothing depending on either. Django saw two leaf nodes and refused to build the graph, so `migrate` failed before applying anything — every app, not just this one. Depend on 0004_pg_periodic_tasks and renumber to match, so the short prefix form stays usable for a rollback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 Renumber the schedule-split migration to 0006 behind UN-3973's 0005 UN-3445's 0004_pg_periodic_tasks is the parent of both this migration and UN-3973's reconciliation migration, so landing both would leave dashboard_metrics with two leaf nodes and no applicable graph. Depend on 0005_add_reconciliation_task instead, which puts the intended merge order (UN-3973 then UN-3974) in the graph rather than in the merge queue. This branch cannot migrate on its own until UN-3973 lands; its tests are unaffected, since the suite runs with --no-migrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 [FIX] Address review: PG transport, scoped orphan sweep, retry posture The reconciliation row could not run on the PG transport — two functions share the task name dashboard_metrics.aggregate_from_sources and the worker one took no arguments, so the mirrored row dispatched source_window_days into a zero-arg function and the message was dropped. The worker proxy and the internal endpoint now plumb it, and 0005 declares the PG twin rather than leaving the mirror to invent one. The orphan sweep is scoped to the (organization, month) partitions the rollup actually produced: an incomplete daily tier passed the empty-tier guard and deleted monthly rows it could not vouch for. Its deletion count now reaches the task result and a WARNING. DatabaseError and OperationalError propagate from the monthly rollup so the configured autoretry fires, instead of being logged once behind success: True. The prefilter is never narrower than the query window, so a widened source_window_days cannot skip the orgs it exists to repair. bulk_create takes an explicit batch_size. The Beat/PG drift guard named 0002 and 0004, so it kept comparing three schedules against three while this PR added a fourth. It now discovers every migration in the app, replays their RunPython forwards in order, derives the Beat cadence from the schedule row, binds every declared kwarg to its task signature, and asserts every post-install Beat write bumps PeriodicTasks.last_update. The reconcile row moves to 04:40 UTC: */15 fires at minute 0, and the per-tier lock keys do not block each other, so 04:00 started two full aggregations at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [FIX] Address review: Beat reload, inherited ownership, boundary validation Rewriting live Beat rows through historical models fires no post_save, so DatabaseScheduler never reloaded: the existing row kept firing with no tier and the new row never fired at all. 0006 now bumps PeriodicTasks.last_update in both directions, as scheduler/ownership.py and mirror_pg_periodic_tasks.py already do. The new row inherits pg_owned and both enabled flags from the row it is split from instead of hardcoding Beat. In a PG-adopted environment the daily and monthly tiers had no firer at all while the hourly run still returned success. It also moves to minute 20. Minute 0 collides with */15 — and so does the suggested minute 30, since */15 fires at :00 :15 :30 :45 — and the per-tier locks are built so the two runs cannot block each other. An unrecognised tier is now rejected in post(), and the blanket except ValueError in _run is gone, so a ValueError from inside the aggregation reaches the logged 500 path rather than reading as a bad request body. Tests: the JSON round-trip assertion was a stdlib tautology that never read what the migration writes — replaced with an assertion on the updated row, the one firing the hourly tier in production. The ALL default is pinned off inspect.signature. The RunSQL table and column are derived from the model rather than grepped. The planner-choice assertion is deleted: a cost model on 12,000 rows is not production evidence. Also drops a full Organization count that ran on every tier for one log field, and gives two test modules the Django bootstrap their siblings carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3973 [FIX] Address Athul's review: upsert-only monthly, per-schedule lock, honest success The orphan delete is removed. The design agreed on this ticket (comments 44768/45016) is a pure INSERT ... ON CONFLICT DO UPDATE; the delete was scope beyond it, and it converts a recoverable undercount into unrecoverable loss — the daily rows that would rebuild a deleted monthly row are exactly the ones that were missing. A stale total is recoverable with backfill_metrics. The reconciliation pass no longer shares a lock key with the 15-minute schedule. The 15-minute row is an IntervalSchedule and drifts against a fixed crontab, so on a shared key the once-daily repair loses the race roughly one day in seven, returns skipped=True and is never retried. A run in which every metric for every org failed no longer reports success: True. The result's success now reflects the error count, the completion log rises to WARNING, and the worker-side guard reads skipped_reason and errors as well as skipped — it saw none of these three did-nothing shapes before. A failed monthly rollup is distinguishable from an empty one: upserted=0 collided with the legitimate no-op and the no_active_orgs return. source_window_days is validated and bounded. It arrives as JSON from a Beat row that is editable in the admin: negative puts the window in the future, 0 never refreshes yesterday, 365 restores the multi-month scan this ticket exists to remove. Tests: a golden test seeds source rows, lets the real aggregation populate daily, and compares the rolled-up monthly against the pre-change derivation computed independently from get_documents_processed — AC-4 was claimed Met and had no equivalence assertion. Fixture offsets derive from the month boundary rather than fixed day counts, which land in the wrong month for the last days of any month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc * UN-3974 [FIX] Address Athul's review: lock covers what is written, both kwargs, graph guard The lock is keyed by granularity written, not by enum member. ALL took a third key that excluded nothing, so an ALL run and the scheduled hourly run wrote EventMetricsHourly concurrently — reachable from the documented manual trigger and from the endpoint's own "omit tier" contract. ALL now takes both keys and releases whatever it took if it cannot take them all. Keys are namespaced by source window so the once-daily reconciliation pass, which is never retried, is not starved by the 15-minute schedule. source_window_days is accepted on all three legs. 0006 hard-depends on 0005, so the reconciliation row is a certainty rather than a hypothetical, and this branch's signatures rejected the kwarg it dispatches. The tier predicates come from one membership table, so a member added without an entry raises instead of acquiring the lock, iterating every org, writing nothing and returning success. The migration's bulk updates check their row counts. A filtered update matching nothing reported success while leaving the old row on kwargs="{}" — every tier every 15 minutes — alongside the new hourly row: strictly more load than before, silently. tier is validated at the request boundary with a warning log, and an explicit null is treated as omitted. New test_migration_graph.py builds the migration graph, which is what catches 0006's dependency on a node that is not on this branch; --no-migrations means nothing else does. Lock behaviour is now exercised rather than its key string asserted, merge_schedules has coverage at all, the equivalence file carries one absolute expectation and a frozen clock, and the prefilter asserts the index is usable under enable_seqscan=off rather than that the planner chose it on 12,000 synthetic rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| backend/dashboard_metrics/tasks.py | Splits aggregation by tier, narrows daily source windows, introduces coordinated locking, and rolls monthly metrics up from daily rows. |
| backend/dashboard_metrics/internal_views.py | Validates and forwards aggregation tier and source-window arguments through the internal worker API. |
| backend/dashboard_metrics/migrations/0005_add_reconciliation_task.py | Adds the seven-day reconciliation schedule to both Celery Beat and PostgreSQL scheduler transports. |
| backend/dashboard_metrics/migrations/0006_split_aggregation_schedule.py | Splits hourly and daily/monthly schedules while preserving scheduler ownership. |
| workers/scheduler/dashboard_metrics_tasks.py | Reports actual rows written when an empty organization shortlist still allows monthly rollup work. |
| backend/workflow_manager/file_execution/migrations/0007_wfe_status_created_idx.py | Adds a concurrent validated index supporting status and creation-time metric queries. |
| backend/workflow_manager/workflow_v2/migrations/0029_we_created_at_idx.py | Adds a concurrent validated creation-time index for workflow execution metric queries. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
S[Source metric tables] --> H[Hourly aggregation<br/>every 15 minutes]
S --> D[Daily aggregation<br/>hourly at :20]
S --> R[Seven-day reconciliation<br/>daily at 04:40 UTC]
H --> EH[(EventMetricsHourly)]
D --> ED[(EventMetricsDaily)]
R --> EH
R --> ED
ED --> M[Monthly rollup]
M --> EM[(EventMetricsMonthly)]
Reviews (9): Last reviewed commit: "UN-3883 [FIX] Stop reporting a successfu..." | Re-trigger Greptile
...e metrics docs Remediation pass over #2276. Fixes only — no aggregation behaviour changes. High: - test_aggregation_tier.py needed a live Redis but sat in the unit tier, which provides none: 8 errors, `test (unit)` red. Settings never override CACHES, so the suite inherits production django_redis. The lock cases now pin locmem, which has identical add/get/delete semantics; they run in CI for the first time. - backfill_metrics computed start_date without truncating to midnight while the cron truncates, so the oldest day was written as a partial day's bucket. The monthly rollup now sums the persisted daily tier rather than recomputing from source, so that short value became permanent once past the reconcile window. - 0006's rollback runbook named `migrate dashboard_metrics 0005` with no ordering. 0005 and 0006 do not exist in the previous release, so that command errors after the image rolls back; and 0005's own row carries source_window_days, which the old signature also rejects. Corrected to 0004, reversed before the image. Medium: - source_window_days > 90 passed the view and raised inside the task, returning 500 for a bad request. _int_arg now takes the task's own bound. - test_a_blocked_run_releases_whatever_it_took never executed the rollback it claimed to prove: keys sort daily_monthly first, so ALL failed on its first key and the rollback loop ran zero times. Reordered; verified it now fails without the rollback. - 0029's index guard checked validity only, so a hand-built (created_at DESC) index passed while Django recorded fields=["created_at"]. Lifted 0007's definition and current_schema() checks across, with tests. - Releasing the lock keys was unisolated: one cache.delete raise replaced a completed run's return value and stranded the remaining keys. - The "every 15 minutes" cadence was restated in five places the split made wrong. Removed the cadence from prose rather than restating it; it lives in 0005/0006. - The rollup docstring and its test claimed a deleted day leaves the monthly total in place. It does not — the group survives with a smaller sum. - README: the 7-day window is a lag ceiling, not only a downtime one; the hourly tier never self-repairs beyond 24h; added the deploy backfill at --days 62 (60 misses a day when run on the 31st). - docker-compose and the cloud chart both asserted the periodics never overlap and the Redis lock self-guards. The split made both false; comments corrected. Also: ruff 0.3.4 (the pinned gate) reported 13 errors and 8 unformatted files, all in tests this PR adds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aja2YP7hoVWqocsUd6jCkN
...ation reverse on the operation Two siblings of fixes in 3a49d7479 that the first pass missed. - TestTheLockIsPerSchedule (test_tasks.py) takes un-namespaced lock keys on the real django_redis cache and brackets each test with cache.clear(), which django-redis implements as FLUSHDB — it wipes every key in that database, and the Celery broker shares db 0 in the test env. Pinned to locmem like its sibling in test_aggregation_tier.py. Verified: the class fails 4/4 without the override on an unreachable Redis and passes 4/4 with it. - test_it_builds_and_drops_concurrently greps the migration source, and the guard added in 3a49d7479 put "DROP INDEX CONCURRENTLY IF EXISTS" into two RAISE EXCEPTION messages — so the assertion held whatever reverse_sql was. Asserted on the rendered operation, as the 0007 sibling already does. Verified: mutating reverse_sql to RunSQL.noop now fails the test; before this it left all nine green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aja2YP7hoVWqocsUd6jCkN
Three small fixes from a review pass; no behaviour change on an install with active organizations. _run_aggregation returned on no_active_orgs before reaching the monthly rollup. That was free while monthly was computed inside the per-org loop, but UN-3973 made the rollup org-agnostic — it sums the whole daily tier and takes no org argument — so the early return now skips it entirely. An install with no workflow execution in the prefilter's 7-day lookback never derives monthly, including on the documented recovery path, where backfill_metrics repairs the daily tier and the next pass is supposed to roll monthly up from it. test_wfe_status_created_idx.py imported a model at module scope without the django.setup() bootstrap its 15 sibling modules carry, so collection aborted unless another module had already initialised Django. Running that directory on its own failed, and the full run only worked because dashboard_metrics/tests is listed first. CI is unaffected — it sets DJANGO_SETTINGS_MODULE — so this is a local and IDE break only. Removed a test docstring claiming an "orphan cleanup" bounded to the rebuilt window. _rollup_monthly_from_daily performs no deletion at all; the docstring is a leftover from an orphan-delete that was removed under review, and the test it sits on asserts the opposite property. The test name already states it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFv4oHBnWPThh9wAuufQeX
...tions" This reverts commit 37fc0fd.
... empty _run_aggregation returned early on an empty prefilter, before the monthly rollup. On main that was correct: monthly was written by _bulk_upsert_monthly inside the per-org loop, so no active organizations meant nothing to write. This PR moves monthly out of the loop into _rollup_monthly_from_daily, which takes no org argument and sums the whole daily tier — so the early return now skips work that is not org-scoped, and the regression is this PR's own. It lands on the deploy path the README documents: backfill_metrics --skip-monthly repairs the daily tier and leaves monthly to the rollup, which never runs on an installation with no workflow execution in the prefilter's 7-day lookback. Reproduced against Postgres — daily summing to 30.0 with monthly left at 20.0, and the same sequence self-correcting to 30.0 with one recent execution present. Removes the early return rather than adding a second rollup call site, so the tier guard stays in one place and a later exit cannot skip it again. An empty id__in compiles to EmptyResultSet and issues no query, so the loop costs nothing when the shortlist is empty. skipped_reason is still reported from the prefilter, since that fact is unchanged and independent of what was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFv4oHBnWPThh9wAuufQeX
...ork" d248fda made the monthly rollup run when the active-org prefilter is empty. Before it, skipped_reason could only appear alongside zero rows written — the early return preceded both the org loop and the rollup — so "did no work" was always true. It can now be false: a quiet installation upserts monthly from existing daily rows and still reports skipped_reason=no_active_orgs. Fixed in the consumer, not the producer. The task's fact is correct — no organisation had recent activity — and suppressing it when rows were written would hide the empty-prefilter warning for as long as the rollup still finds daily rows to sum. The prefilter looks back 7 days and the rollup sums 29-62, so that gap can run to two months on an installation that has gone quiet. _log_if_skipped now reports the prefilter reason with the row count, which distinguishes an empty shortlist that still rebuilt derived tiers from a run that genuinely wrote nothing. The lock-held and per-metric-error arms are unchanged, and a healthy run stays silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFv4oHBnWPThh9wAuufQeX
Quality Gate Passed Quality Gate passed
Issues
5 New issues
0 Accepted issues
Measures
0 Security Hotspots
0.0% Coverage on New Code
0.7% Duplication on New Code
Unstract test resultsPer-group results
Critical paths
|
@athul-rs
athul-rs
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Standardized pre-merge review — REQUEST CHANGES
Critical: 0 · High: 5 · Medium: 13 · Low: 10 · Lenses run: 16/16
Reviewed against the team's 16-lens rubric via the PR Review Toolkit specialists (unstract plugin v0.18.1), INITIAL mode, at efe01f2b45.
This is careful work and the docstrings are unusually good — several of the findings below exist because a comment made a precise, checkable claim, which is a better failure mode than most PRs offer. The index migrations in particular are stronger than what UN-3972 specified: the pg_get_indexdef assertion catches the IF NOT EXISTS name-collision case on top of the INVALID case, and the (created_at DESC) index struck in comment 45015 stayed struck.
Nothing here is certain-on-merge data loss, which is why this is not a BLOCK. But five High findings, three of which sit on the deploy path and compound each other, is squarely request-changes.
The three that interact
H1 (tasks.py:208) the monthly rollup blind-overwrites monthly totals from a tier with no completeness check; H2 (0005:82) the reconciliation pass — the only automatic repair for that tier — lands with no firer wherever the periodics are already PG-adopted; H3 (backfill_metrics.py:105) the pre-deploy backfill that is supposed to make H1 safe does roughly double the work it should, inside the window it must beat. Each is individually fixable in a few lines. Together they mean the safety net has a hole, the repair path may never run, and the manual mitigation is slower than advertised.
H4 (tasks.py:758) and H5 (0006:120) are independent.
Unanchored findings
[Medium] [Lens 16] — The PR description contradicts its own README, twice.
- "Nothing a customer sees changes... which is the one deliberate trade."
README.md:169, added in this same PR, documents that a row whose status turns terminal more than 7 days aftercreated_atis counted in no daily row and therefore in no monthly total — "Before the monthly tier was derived from daily this was caught by the wider monthly source window." That is an unrecoverable under-count of a customer-visible figure, not a staleness trade. The PR's own risk bullet documents a second one. This was flagged on UN-3973 (comment 45708) as being corrected; the README was, the PR body was not. - "
ALLtakes both, so it genuinely excludes a concurrent hourly run." False for the only scheduledALLrun — the 04:40 reconcile carriessource_window_days=7and no tier, so its keys are disjoint from the*/15run's.docker-compose.yaml:422-425says the opposite and is correct. See the inline comment ontasks.py:305.
Suggested replacement for (1): dashboards keep the same shape and the same hourly freshness; daily/monthly gain up to an hour of lag; and the correctness envelope narrows from ~32-62 days of lag tolerance to 7 — which the measurement study (zero rows over 7d across 405,951) says is safe today, but is a real silent loss class if that distribution shifts.
Low
_TIER_WRITES with an empty or wrong entry degrades into a false lock_held skip, because _acquire_aggregation_locks([]) returns [] and the caller reads that as contention · "granularity" names the tier labels throughout _aggregation_lock_keys while a real Granularity enum with different members is imported 10 lines away · the DO $$ guards never check indrelid, so a same-named index on another table satisfies both IF NOT EXISTS and the assertion (also _ is a LIKE wildcard in INDEX_DEF_SUFFIX) · 0005:66 hand-writes the Beat JSON string instead of json.dumps(spec["task_kwargs"]), breaking its own "single source for both directions" — CI catches it, which is why this is Low · tier: str is annotated but defaults to an enum member, then validated in three layers · backfill_metrics imports the private _truncate_to_day across a module boundary for what is genuinely a shared contract · the two cleanup views' 400 paths log nothing while the aggregate view's does · the aggregate endpoint silently ignores unknown body keys, so {"teir": "hourly"} returns 200 having run every tier · AGGREGATION_LOCK_TIMEOUT == 900 == the hourly period, so "not outlive the shortest schedule period" holds only at equality.
Lens checklist
| # | Lens | Result |
|---|---|---|
| 1 | Spec & intent | See H3, unanchored |
| 2 | Architectural fit & precedent | See tasks.py:305, Low |
| 3 | Correctness & edge cases | See H1, H4, tasks.py:640 |
| 4 | Security | Clean — no new auth surface; internal endpoint pre-existing with boundary validation; _base_manager org-scope bypass deliberate and unchanged for a cron context; no secrets, no PII in new logs |
| 5 | Data integrity & migrations | See H1, H2, H5, 0007:17. Migration graph verified conflict-free against origin/main |
| 6 | Concurrency | See tasks.py:305, tasks.py:361 |
| 7 | API & contract compatibility | See H5, tasks.py:663 |
| 8 | Reliability & resilience | See H1, H4 |
| 9 | Performance & cost | See H3, tasks.py:746 |
| 10 | Observability | See tasks.py:663, dashboard_metrics_tasks.py:107 |
| 11 | Operational safety | See H2, H5, 0007:17 |
| 12 | LLM/agent | N/A — no model calls, prompts, tools or agent paths; get_llm_metrics_combined aggregates usage rows |
| 13 | Testing | See the four test-file comments |
| 14 | Dependencies & build | N/A — no dependency, lockfile, Dockerfile or CI manifest touched |
| 15 | Code quality | See Low |
| 16 | Doc & comment accuracy | See 0007:17, tasks.py:663, README.md:168, unanchored |
Open questions
- Has
mirror_pg_periodic_tasks --adoptalready run for thedashboard_metrics_*periodics in prod? That decides whether H2 is live or latent. SELECT date, count(*) FROM event_metrics_daily WHERE date >= date_trunc('month', now()) - interval '1 month' GROUP BY 1on prod would bound H1 exactly.- Does the deploy pipeline run
migrateas a pre-deploy job, and are rollbacks automated? That sets H5's real severity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 3 — Correctness · Lens 5 — Data integrity · Lens 8 — Reliability] — The monthly rollup blind-overwrites customer-visible totals with no completeness check on the tier it now derives from
_rollup_monthly_from_daily replaces every event_metrics_monthly row for the current and previous month with Sum(event_metrics_daily) (update_conflicts=True, update_fields=[metric_type, metric_value, metric_count]). No coverage check, no comparison against the prior value, no gate on the prescribed pre-deploy backfill having run. The docstring concedes the shape: "a month the daily tier still covers partially is overwritten with the sum of the days present — a smaller number, not the previous total."
The compounding half: monthly_start reaches up to 61 days back, but the widest daily repair that exists is the 04:40 pass at DASHBOARD_RECONCILE_WINDOW_DAYS = 7. Any daily hole older than 7 days is permanent and is now written through into monthly. Pre-change this self-healed, because monthly was re-queried from source over the full two months every run.
To be fair on blast radius: in steady state the daily tier is complete — _bulk_upsert_daily is an upsert with no delete and the only deleter is cleanup_daily_metrics at 365-day retention, so every day passes through the rolling window as it ages and stays. This will not collapse monthly on deploy. The exposure is an outage longer than 7 days, a metric failing persistently for a week, or an install whose cron has not covered the rollup window.
Suggested fix: gate the rollup in code rather than in a runbook line — compare COUNT(DISTINCT date) per month against elapsed days and skip + logger.error + stats["errors"] += 1 when short. That also protects every future deploy and every environment, which the runbook step does not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 5 — Data integrity · Lens 11 — Operational safety] — The reconciliation pass lands with no firer in a PG-adopted environment, silently
This row is written pg_owned=False with Beat enabled=True. workers/queue_backend/pg_queue/pg_scheduler.py:309 dispatches system periodics with WHERE pg_owned AND enabled, so the PG scheduler will never fire it. mirror_pg_periodic_tasks.py's stated acceptance gate is "Celery scaled to zero" — in that end state this row has no firer at all.
0006, the next migration, solves exactly this for the row it adds, via _inherited_ownership(), with a docstring naming the failure: "Hardcoding Beat would leave the daily/monthly tier with no firer wherever the metrics periodics are already PG-adopted." 0005 is the outlier.
This is what makes the monthly-rollup finding dangerous rather than theoretical: the reconciliation pass is the only automatic repair path once the routine window is 2 days, and its non-firing produces no error, no queue depth, and no failed pod.
Suggested fix: give 0005 the same _inherited_ownership treatment (the function is already written next door), or add mirror_pg_periodic_tasks --adopt to Deploy Steps. TestSeededInert::test_nothing_is_declared_pg_owned needs to exempt this row either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 1 — Spec & intent · Lens 9 — Performance · Lens 16 — Doc accuracy] — --skip-hourly does not skip the hourly source queries, so the mandatory deploy step does roughly double the work
(Anchored here on the flag help text; the code involved is at :209 and :366-411, outside this diff.)
skip_hourly is read once at :129 and used once at :209, where it guards _bulk_upsert_hourly. _collect_metrics (:296) takes no skip flags and issues Granularity.HOUR queries unconditionally at :368 and :399. The command builds hourly_agg over 62 days for all 38 orgs and throws it away.
The prescribed deploy command is backfill_metrics --days 62 --skip-hourly --skip-monthly. It therefore reinstates, once at deploy time, the 62-day scan across workflow_file_execution / page_usage / usage_v2 that this PR exists to remove — while the operator believes it was skipped. On 4 vCPU that is the difference between fitting before the next :20 tick and not, which is what the monthly-rollup risk depends on.
Suggested fix: thread the skip flags into _collect_metrics and short-circuit the HOUR blocks. If out of scope, drop --skip-hourly from the runbook (it buys nothing) and state a measured runtime.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 3 — Correctness · Lens 8 — Reliability] — This bare except Exception swallows DatabaseError/OperationalError, making the task's own autoretry_for dead
The task declares autoretry_for=(DatabaseError, OperationalError) (:371), which can only fire for an exception that escapes the body. This handler catches Exception flat around _aggregate_org, as do the per-metric handlers at :588 and :603. A lost connection or statement timeout inside _bulk_upsert_daily is counted, never retried; on a broken connection every subsequent org fails identically and the run returns errors=38, success=False, HTTP 200, acked.
_roll_up_monthly twelve lines below re-raises those two deliberately: "Configured on the task for autoretry — swallowing them here would leave monthly permanently stale behind successful-looking runs." The identical argument now applies to the daily tier, because daily feeds monthly. The handler predates this PR; its blast radius does not.
Suggested fix: except (DatabaseError, OperationalError): raise ahead of the general handler here and in _collect_org_metrics — the shape already used one function away.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 7 — API compatibility · Lens 11 — Operational safety] — Rollback cannot be executed by an automated rollback, and the forward window is undocumented
After 0005+0006 all three schedule rows carry kwargs. origin/main's aggregate_metrics_from_sources() and dashboard_metrics_aggregate() both take no parameters, so every tick on both transports raises TypeError — not covered by autoretry_for, dropped at MAX_ATTEMPTS=1.
- Rollback: requires
migrate dashboard_metrics 0004from the outgoing image, before the image reverts. Any platform-driven rollback (ArgoCD revision revert, image tag pin) skips that by construction. The procedure lives only in two migration docstrings;README.mdcarries the rest of this change's ops content but not this. - Forward (old pods mid-rollout): bounded and self-healing — but both docstrings analyse only the rollback direction.
Note this line rewrites the pre-existing row rather than leaving it on kwargs="{}", which turns a partial rollback failure into a total one: an untouched {} row would still bind under the old signature and keep writing all three tiers.
Suggested fix: minimum — put the procedure in README.md beside the backfill step and mark the release as blocking automated rollback. Cheap structural alternative: **_ignored on both signatures for one release makes the class a non-event.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [Lens 13 — Testing] — first() ignores the filter, so the ownership-inheritance tests cannot detect 0006 reading the wrong row
filter() records self._filtered_on but first() returns self._existing unconditionally. If _inherited_ownership filtered on the wrong name — the new row's name, say, which does not exist when 0006 runs — every test in TestTheNewRowInheritsWhoeverFiresTheRowItSplitsFrom still passes.
Against a real database that filter returns None, owner falls back to {beat_enabled: True, pg_owned: False}, and the daily/monthly row lands Beat-owned in a PG-adopted environment with Beat scaled to zero — exactly the "no firer at all" bug the class docstring says it exists to prevent, and the same bug I have flagged on 0005.
Suggested fix: return self._existing if self._filtered_on == EXISTING_AGGREGATE_ROW else None.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [Lens 13 — Testing] — Wall-clock non-determinism: a run straddling UTC midnight fails with no code change
TestSourceWindow and TestMonthlyThroughTheTask compare a window computed from one timezone.now() against one _run_aggregation computed from a different timezone.now(). If the two calls land either side of 00:00 UTC the truncated days differ; TestMonthlyThroughTheTask has the same shape against the month boundary, so it fails on the 1st.
test_tier_split_equivalence.py:151-161 freezes the clock for exactly this reason and says so: "three unpatched invocations compute three different window starts — and a run straddling an hour or a month boundary would fail on a non-regression." test_tasks.py never patches timezone.now.
Suggested fix: apply the same patch("dashboard_metrics.tasks.timezone.now", return_value=frozen) in TestSourceWindow, TestMonthlyThroughTheTask and TestMonthlyMatchesTheOldDerivation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [Lens 13 — Testing] — This changed window arithmetic has no test, and the command has no test module at all
start_date is now truncated to match the cron's daily_start. The reasoning in the comment above is right — an untruncated boundary writes the oldest day as a partial bucket, which the rollup then makes permanent once it ages past the reconcile window. But nothing exercises it: backend/dashboard_metrics/ has no test module for management/, and the three mentions of backfill_metrics in test_tasks.py are all inside docstrings.
This is the mandatory pre-deploy step for the whole change, and this PR modified it.
Suggested fix: one TestCase calling call_command("backfill_metrics", days=3, skip_hourly=True, skip_monthly=True) over seeded source rows, asserting the oldest covered day is whole and that --skip-daily without --skip-monthly emits the warning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [Lens 13 — Testing] — This index is verified only as file text, while its sibling in the same PR gets a real planner test
Every assertion here reads Migration.operations[0] attributes or greps Meta.indexes. UN-3972's acceptance criterion — index present with indisvalid = t — is not tested by anything, and neither is the pairing between the index and the queries it exists for (get_documents_processed on COMPLETED, get_failed_pages on ERROR). Change those queries to stop leading with status and the index stays built, valid and dead, with a green suite.
The other index added in this PR does get this treatment: backend/dashboard_metrics/tests/test_active_org_prefilter.py:123-142 runs EXPLAIN with enable_seqscan = off and asserts Index Scan using we_created_at_idx, with a docstring saying why — "Either half can drift without the other noticing."
Suggested fix: mirror that file — seed at production-ish status ratios, ANALYZE, and assert the index is usable for both queries.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [Lens 16 — Doc accuracy] — An hourly gap does not need backfill_metrics; gaps under 24h self-repair on the next tick
hourly_start = end_date - timedelta(hours=24) (tasks.py:731) is unconditional on tier and on source_window_days, and _aggregate_single_metric re-queries and re-upserts that whole window every run. So the next */15 tick rewrites any missing hour.
As written, an operator following this line after a routine few-minute outage runs a full multi-day source scan across every org — on the tables this PR exists to relieve.
Suggested fix: "The hourly tier re-queries the last 24 h on every run, so an hourly gap shorter than 24 h repairs itself on the next tick; only a gap older than 24 h needs backfill_metrics."
Two more in this file, lower priority: :154 still says writes use update_or_create with a single hourly-shaped unique constraint (all three writers use bulk_create(update_conflicts=True) against three different constraints — this PR corrected the same error 80 lines below), and :36 still points at --days=30 as the "run first!" command rather than the deploy-critical --days 62 --skip-hourly --skip-monthly.
Uh oh!
There was an error while loading. Please reload this page.
What
Three changes to the dashboard metrics cron, already reviewed and merged individually as #2255, #2264 and #2265:
Why
The cron was using roughly 55 minutes of database time every 6 hours on production. It re-read between 32 and 62 days of raw data for all 38 organisations, 96 times a day, to produce figures that only change once a month.
Nothing a customer sees changes. Hourly dashboard numbers are still at most 15 minutes old; daily and monthly numbers are now at most an hour old instead of 15 minutes, which is the one deliberate trade.
How
_rollup_monthly_from_dailyreplaces the per-org monthly source queries with oneINSERT ... ON CONFLICT DO UPDATEoverevent_metrics_dailyfor every org. Upsert-only, per the design agreed on UN-3973: a monthly row the daily tier no longer produces is left in place, because a stale total is recoverable withbackfill_metricsand a deleted one is not.aggregate_metrics_from_sources(tier, source_window_days)is called by three schedule rows. Both kwargs travel on both transports — Beat calls the Django task directly, the PG scheduler goes worker proxy → internal endpoint → the same function.ALLtakes both, so it genuinely excludes a concurrent hourly run; distinct windows are distinct jobs, so the once-daily reconciliation pass can never be starved by the 15-minute schedule it is never retried after.CONCURRENTLYunderatomic = False, withAddIndexconfined tostate_operations. Each migration asserts the index is valid and has the expected definition before recording itself applied.Can this PR break any existing features?
The realistic risks, and what bounds each:
event_metrics_daily. Gaps shorter than 7 days repair themselves on the next reconciliation pass; anything older needsbackfill_metrics. Bounded to the current and previous month.0006is reversed — the schedule rows carry atierkwarg the previous release's signature rejects. Documented in the migration docstring;migrate dashboard_metrics 0004restores it —0005is not far enough, its reconciliation row carriessource_window_days, which the previous release rejects the same way.tierand raisesTypeErroruntil it rolls. Self-healing, and no aggregation is lost because the next tick succeeds.Database Migrations
Four, in three apps. No schema changes to any metrics table.
dashboard_metrics/0005_add_reconciliation_taskdashboard_metrics/0006_split_aggregation_schedulefile_execution/0007_wfe_status_created_idxworkflow_file_execution (status, created_at)workflow_v2/0029_we_created_at_idxworkflow_execution (created_at)Both index migrations no-op via
IF NOT EXISTSif the index was built out of band first, which is the preferred production path — the exact statement is in each migration's docstring.Env Config
None.
Deploy Steps
Run once, before the first aggregation after deploy:
Monthly is now derived from the daily tier, so that tier has to be complete across the rollup window first. The rollup starts at the first day of the previous month, which is up to 61 days back, hence 62.
--skip-monthlyis deliberate: repair daily and let the rollup derive monthly.Relevant Docs
backend/dashboard_metrics/README.mdis updated — schedules, windows, staleness bounds, and the ownership overlap withbackfill_metrics.Related Issues or PRs
Merged into this branch: #2255 (UN-3973), #2264 (UN-3972), #2265 (UN-3974). Parent: UN-3883.
Dependencies Versions
No dependency changes.
Notes on Testing
event_metrics_daily, not source tablesindisvalid = tCONCURRENTLY, no write-blocking lockget_documents_processedfree of a seq scan onworkflow_file_executionget_failed_pagesfree of a seq scan onworkflow_executionget_recent_activityunder 1 s(created_at DESC)index descoped in comment 45015The two Confirm on prod rows are post-deploy readings against Query Insights, not outstanding work.
Screenshots
Not applicable — no UI change.
Checklist
I have read and understood the Contribution Guidelines.