UN-3973 [DEV] Cut dashboard cron DB time by deriving monthly metrics from the daily tier - #2255
Open
kirtimanmishrazipstack wants to merge 2 commits into
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
Contributor
|
| Filename | Overview |
|---|---|
| backend/dashboard_metrics/migrations/0004_add_reconciliation_task.py | Adds an idempotent daily Celery Beat reconciliation task with a reversible data migration. |
| backend/dashboard_metrics/tasks.py | Narrows source windows, separates organization collection, and derives monthly metrics from daily aggregates. |
| backend/dashboard_metrics/tests/test_tasks.py | Covers monthly grouping, month boundaries, overwrite and cleanup behavior, and configurable source windows. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Beat[15-minute Celery schedule] --> Aggregate[Aggregate source metrics]
Reconcile[Daily 7-day reconciliation] --> Aggregate
Aggregate --> Hourly[(Hourly metrics)]
Aggregate --> Daily[(Daily metrics)]
Daily --> Rollup[Monthly rollup]
Rollup --> Monthly[(Monthly metrics)]
Reviews (4): Last reviewed commit: "UN-3973 Address Sonar and Greptile revie..." | Re-trigger Greptile
kirtimanmishrazipstack
marked this pull request as draft
August 25, 2026 14:11
kirtimanmishrazipstack
marked this pull request as ready for review
August 25, 2026 14:38
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
|
Contributor
Author
|
@greptile-apps please review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What
event_metrics_monthlyis now derived by rolling upevent_metrics_daily, instead of re-querying source tables per organisation.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.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.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.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— 18 pass (9 pre-existing, 9 new). New coverage:_rollup_monthly_from_dailysums daily rows into the right month bucketmonth_startare excludedON CONFLICTpath)metric_typediffers across days in one month yields one row, not a crashAlso 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