UN-3973 [DEV] Cut dashboard cron DB time by deriving monthly metrics from the daily tier - #2255
Conversation
…dow 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
|
| Filename | Overview |
|---|---|
| backend/dashboard_metrics/migrations/0004_add_reconciliation_task.py | Adds an idempotent and reversible 04:00 UTC reconciliation task using the seven-day source window. |
| backend/dashboard_metrics/tasks.py | Narrows routine source aggregation, globally rolls monthly data up from the daily tier, and transactionally removes obsolete derived keys. |
| backend/dashboard_metrics/tests/test_tasks.py | Adds focused coverage for monthly derivation, month boundaries, reconciliation windows, late terminal rows, query shape, and migration behavior. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
S[Source metric tables] -->|Every 15 minutes: 2-day window| D[(EventMetricsDaily)]
S -->|Daily reconciliation: 7-day window| D
D -->|Current and previous month rollup| M[(EventMetricsMonthly)]
B[Manual backfill for older gaps] --> D
Reviews (9): Last reviewed commit: "Merge branch 'UN-3883-Optimize-DB-cron-q..." | Re-trigger Greptile
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
|
@greptile-apps please review |
…iation 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
|
@greptile-apps please review again |
|
@greptile-apps On the summary verdict — the mechanism is right, "not safe to merge" isn't. This is the trade the ticket asks for, and it was in the PR description before the review. It's documented and accepted. Under "Can this PR break any existing features", item 1:
Monthly stopped self-healing over 62 days deliberately — that 62-day re-derivation from source tables is the DB load UN-3973 exists to remove. Widening the rollup's source window to make orphan cleanup safe against arbitrarily old gaps would revert the PR. You did surface one thing the description understated, and I've fixed it. "Inherits gaps" reads as undercounting; the deletion case is worse than that:
Confirmed against the test DB ( The one-time exposure is real, and it's a deploy step rather than a code change. Gaps already sitting in the daily tier would propagate into monthly on the first rollup after this ships. Added a That rebuilds daily from source across the rollup window; the scheduled rollup then derives monthly from a complete tier. Ongoing gaps beyond 7 days remain the accepted trade, with |
|
You're right. Given that this tradeoff is explicit, ticket-approved, bounded to the current and previous month, and addressed operationally by the one-time daily backfill, it should not be a merge blocker. The orphan-deletion behavior is a real consequence, but it is now documented rather than an unaddressed regression. I withdraw the “not safe to merge” verdict. The remaining operational caveat is to run the documented backfill before the first rollup and use |
|
@greptile-apps can you review and generate new confidence score? |
…hange 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
…d 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
…into UN-3973-optimize-db-queries-reduce-monthly-metrics
|



What
event_metrics_monthlyis now derived by rolling upevent_metrics_daily, instead of re-querying source tables per organisation.Acceptance criteria — all five met
event_metrics_daily, not source tables_rollup_monthly_from_dailyreads onlyEventMetricsDaily;_aggregate_orgno longer receivesmonthly_starttest_monthly_rollup_never_touches_source_tablescaptures the SQL and asserts noworkflow_file_execution/workflow_execution/page_usageDASHBOARD_SOURCE_WINDOW_DAYS = 2, the default on both the task and_run_aggregationtest_default_window_bounds_the_daily_query,test_late_terminal_status_does_not_re_enter_the_narrow_window; a live run reporteddaily.start = today − 20004creates the beat row — 04:00 UTC daily, queuedashboard_metric_events,kwargs={"source_window_days": 7}, enabledtest_migration_schedules_the_pass_at_0400_with_a_7_day_window,test_migration_is_idempotent_and_reversibleMATCHon value and count, 2 orgs × 2 months (2026-07 and 2026-08), compared againstbackfill_metrics— which still computes monthly the old way and is untouched by this PRtest_month_boundary_keeps_months_separate;test_reconciliation_window_widens_the_daily_query,test_reconciliation_recovers_a_day_the_narrow_window_missed, plus the two schedule testsThe parity check in row 4 is the one that matters most, since it is the only check that asks are the numbers the same as before? rather than is the code self-consistent? Every row agreed exactly:
Why
_aggregate_single_metricand_aggregate_llm_combinedwidened their DAY-granularity query tomonthly_start— the first of the previous calendar month — so monthly buckets could be summed in Python from the same rows, saving a third query per metric. The cost was that every run re-read 32–62 days of source data for each of 38 organisations, 96 times a day.Six of the eight cron queries are index-backed (11–107 ms avg), so their cost is proportional to that range. Per the analysis on UN-3883, ~460 s per 6 h is recoverable by narrowing it, with no schema change.
Two supporting facts make the rollup safe:
event_metrics_dailyretains 365 days against a 2-month monthly window (11 MB / 25 k rows), and the code already derives monthly by summing DAY buckets — this only moves where the sum happens, not what it sums.The 2-day window is sized on measurement, not guesswork: over 30 days and 405,951 terminal rows on the read replica, the worst
created_at→ terminal-status lag was 2 h 16 m, with zero rows beyond 6 h. It is bounded byFILE_PROCESSING_TASK_TIME_LIMIT(2 h) and the 2.5 h stuck-execution reaper, so 2 days leaves ~21× margin.This will not move the top-line 55-minute figure. It cannot touch
get_documents_processed(44% of cron time) orget_failed_pages(43%) — those are UN-3972's. Measure it against §4 rows 5, 6, 7, 10 and 14.How
All changes are in
backend/dashboard_metrics/.Constants —
DASHBOARD_SOURCE_WINDOW_DAYS = 2,DASHBOARD_RECONCILE_WINDOW_DAYS = 7,DASHBOARD_ACTIVE_ORG_LOOKBACK_DAYS = 7.aggregate_metrics_from_sources(source_window_days=2)threads the window through to_run_aggregation. The reconciliation pass is the same task at a different bound, so it needs no separate code path — just a secondPeriodicTaskrow._aggregate_single_metric/_aggregate_llm_combinedlost theirmonthly_startandmonthly_aggparameters. The DAY query now bindsdaily_start, and theif day_ts >= daily_startguard is gone — it was a Python filter over rows the query had already fetched, and the bind value does that work now. Still 2 queries per metric, just a narrow one._bulk_upsert_monthly→_rollup_monthly_from_daily(month_start)— one ORM aggregate (TruncMonth+Sum) overevent_metrics_daily, called once after the org loop for all organisations. The monthly tier stops scaling with tenant count.Two details worth review attention: the grouping matches
unique_monthly_metric—(organization, month, metric_name, project, tag)— exactly, andmetric_typeis aggregated (Min) rather than grouped, because it is not part of that constraint. Grouping on it would let a metric whose type ever changed mid-month produce two rows on one key, whichON CONFLICT DO UPDATErejects outright. TheMinis aliasedmtypebecause Django refuses an annotation named after a model field.The 15-line January special case for
monthly_startcollapsed to two lines using the existing_truncate_to_monthhelper, which handles the year rollover on its own.The active-org prefilter is decoupled from
daily_startand pinned at 7 days — see the breakage section below.One change outside
dashboard_metrics/:backend/settings/base.pymoves thefind_dotenv()/load_dotenv()call above the Celery block.CELERY_BROKER_BASE_URL,_USERand_PASSwere read on the lines above it, so they could not be supplied by an env file at all — only ambiently. Ambient values still take precedence (load_dotenvdefaults tooverride=False) and deployments set them in the chart, so runtime behaviour is unchanged; it only makes a localpytestrun work without a four-variable prefix. Happy to split this into its own PR if reviewers prefer.Chose the ORM over the raw
INSERT … SELECTsketched on the ticket: identical arithmetic, no string-built SQL, and it reuses the existing_base_managerconvention for Celery context.Can this PR break any existing features. If yes, please list possible items. If no, please explain why.
Three real behaviour changes, all deliberate:
Monthly now inherits gaps in
event_metrics_daily. The 62-day source window used to re-derive monthly from source tables, so it incidentally self-healed the monthly tier after cron downtime. Post-change, monthly is only as complete as the daily tier, and the reconciliation pass heals 7 days back. Downtime longer than 7 days needs thebackfill_metricsmanagement command. This is the trade the ticket accepts by design.One case is worth stating precisely, because "inherits gaps" understates it. Where some daily rows for a key survive the gap, monthly is recomputed low. Where every daily row for that key is missing, the key never enters
fresh_keysand_delete_orphan_monthlyremoves the monthly row outright. Both are bounded to the current and previous month (month__gte=month_start), and both are closed for gaps that already exist by the one-time backfill under Deploy Steps.Source deletions stop self-correcting beyond the window. Deleting a workflow cascades to its executions and file executions. Pre-change, monthly was re-derived from source over 62 days, so that correction propagated. Post-change, monthly sums the daily tier and the daily tier keeps the stale bucket, so only the last 2 days — 7 on the reconciliation pass — self-correct. Older buckets stay as they were until a
backfill_metricsrun. The durable fix is invalidation when the workflow is deleted, not a wider scan in the cron; raised by Greptile and answered in-thread.Lock contention on the reconciliation pass. Both schedules share
AGGREGATION_LOCK_KEY, so a reconciliation pass that fires while a 15-minute run is in flight will skip that day (~10% of days). Nothing is lost — the next day's pass still covers 7 days back — unless a collision and multi-day downtime coincide. Judged not worth a retry branch.What is not at risk: every dashboard metric filters and buckets on the same column (
get_hitl_completionsusesapproved_atfor both, the rest usecreated_at), so a row can only ever be counted in the bucket of the timestamp it is filtered by. A 2-day window therefore cannot silently drop a late-arriving row into an uncounted bucket.One near-miss caught during implementation and fixed here: the active-org prefilter reused
daily_start, so narrowing that variable would have silently narrowed the prefilter too. Sinceget_hitl_completionsfiltersapproved_at, an org approving today a document processed five days ago would have fallen out ofactive_org_idsand lost the metric. The prefilter now has its own constant and stays at 7 days — its own 1,849 ms cost is UN-3974's problem, not this PR's.Database Migrations
dashboard_metrics/0004_add_reconciliation_task.py— data migration only, no schema change. Creates onedjango_celery_beatPeriodicTasknameddashboard_metrics_reconcile_source_window(04:00 UTC, queuedashboard_metric_events,kwargs={"source_window_days": 7}), chosen to sit clear of the existing 02:00 and 03:00 cleanup tasks. Usesupdate_or_create, so it is safe to re-run; the reverse deletes the row by name.0002_setup_periodic_tasks.pyis untouched.Deploy Steps
One-time, after the migration and before the first scheduled rollup:
Monthly is derived from
event_metrics_dailyfrom this PR onward, so the daily tier has to be complete across the rollup window — current + previous month — before the first rollup runs. A gap already sitting in the daily tier, from downtime longer than the old 7-day window, would otherwise propagate straight into monthly on that first run: undercounted where the gap is partial, deleted where a key lost every daily row behind it.--skip-monthlyis deliberate. The point is to repair daily and let the scheduled rollup derive monthly from it, not to write monthly from source here.--skip-hourlykeeps the run cheap; hourly has its own 24-hour window and 30-day retention and is untouched by this change.Needed once. After it, the 04:00 reconciliation pass carries the daily tier forward.
Env Config
Relevant Docs
Related Issues or PRs
UN-3883-Optimize-DB-cron-queries-causing-high-DB-load, notmain.Dependencies Versions
Notes on Testing
backend/dashboard_metrics/tests/test_tasks.py— 26 pass (9 pre-existing, 17 new).cd backend DJANGO_SETTINGS_MODULE=backend.settings.test_cloud uv run pytest dashboard_metrics/ --reuse-dbTestMonthlyRollup(8) — sums day rows into the right month bucket; month boundary, rows spanning the 1st land in two separate rows with no bleed; rows older thanmonth_startexcluded; rerun overwrites rather than doubles (ON CONFLICT); a metric whosemetric_typediffers across days yields one row rather than crashing; an empty daily tier is a no-op; a monthly row is dropped once its day rows go; months before the window are left alone.TestRollupQueryShape(1) — the rollup issues no source-table SQL. Without this, a regression to re-counting raw records would leave every other test green and silently undo the saving.TestSourceWindow(6) — the per-run and reconciliation windows each bound the daily query; the task forwards its kwarg; and three tests that seed real source rows to document the ladder 2 days → 7 days → manual backfill: a 5-day-old row recovered only by the wider pass, a row that finishes after the narrow window moved past itscreated_atand so never re-enters it, and a 62-day gap that neither scheduled pass reaches.TestReconciliationSchedule(2) — the beat row's contents, its idempotency and its reverse. These call the migration's function directly: the suite runs with--no-migrations, so data migrations never execute and asserting on the row itself would fail regardless of the migration being correct.Every new behaviour was mutation-checked rather than assumed: setting the window to 7 and the cron hour to 5 made exactly the three intended tests fail, and no others.
These reach CI automatically —
conftest.pyauto-marks DB-bound testsintegration, so they run under the rig'sintegration-backendgroup with no manifest edit.Also verified manually against a local Postgres: the migration applies and all four
PeriodicTaskrows read back correct;makemigrations --checkreports no model drift; and an end-to-end_run_aggregationover the real source tables returnederrors: 0withmonthly.start = 2026-07-01anddaily.startseven days back for the reconciliation pass.Screenshots
N/A — backend only.
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KGZBF68CShem3pbUJM2tBc