Skip to content

fix(stats): faster backfill and prevent overlap - #1638

Closed
steebchen wants to merge 4 commits into
mainfrom
fix/stats-backfill-performance
Closed

steebchen wants to merge 4 commits into
mainfrom
fix/stats-backfill-performance

Conversation

@steebchen

@steebchen steebchen commented Feb 10, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Replace slow LEFT JOIN backfill discovery (scanned all 10M+ logs every run) with sequential hour-based iteration using generate_series + NOT EXISTS against the small stats table
  • Fix gap detection: use NOT EXISTS to find actual unprocessed hours instead of frontier-based approach (max(hour_timestamp) + 1) which skipped gaps left by previous partial runs
  • Add concurrency guard (isRunning flag) to prevent overlapping runs from stacking up DB connections when a batch takes longer than the refresh interval
  • Change log-process log level to trace

Test plan

  • pnpm build:core passes
  • Verify backfill processes hours with gaps (not just from frontier forward)
  • Verify DB connection count stays stable under load
  • Verify aggregation counts match log counts after full backfill

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of project statistics aggregation with enhanced hourly processing logic.
    • Fixed potential concurrency issues in statistics refresh operations with added safeguards.
  • Chores

    • Updated internal processing to use standardized hourly boundaries for consistent statistics reporting.

steebchen and others added 2 commits February 11, 2026 03:05
- Replace slow LEFT JOIN backfill discovery (scanned
  all logs) with sequential hour-based iteration using
  generate_series and the created_at index
- Add concurrency guard to prevent overlapping runs
  from stacking up DB connections
- Change log-process log level to trace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace frontier-based backfill (max hour + 1) with
generate_series + NOT EXISTS to find actual unprocessed
hours, including gaps left by previous partial runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 10, 2026 20:14
@coderabbitai

coderabbitai Bot commented Feb 10, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Refactored the project stats aggregator service to transition from LEFT JOIN-based bucket detection to per-hour processing. Added hour-truncation utilities, reworked the backfill loop to iterate over hourly ranges, and implemented a module-level concurrency guard to prevent overlapping invocations.

Changes

Cohort / File(s) Summary
Stats Aggregator Refactoring
apps/worker/src/services/project-stats-aggregator.ts
Replaced backfill query strategy from bucket-based to per-hour processing with explicit hour-truncation utilities (truncateToHourUTC, getCurrentHourStart). Reworked backfill loop to iterate over hourly ranges using NOT EXISTS checks. Added module-level concurrency guard with isRunning flag to prevent overlapping invocations. Updated logging to reflect hourly granularity and adjusted completion messaging. Phase 2 stale processing updated for hour-based timestamp handling. Removed unused isNull import.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • #1612 — Directly introduces the backfill logic and stat calculation functions that this PR refactors from bucket-based to per-hour processing.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: replacing slow backfill with faster hour-based iteration (faster backfill) and adding a concurrency guard (prevent overlap).
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/stats-backfill-performance

No actionable comments were generated in the recent review. 🎉


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/worker/src/services/project-stats-aggregator.ts`:
- Around line 434-449: The current hours generation marks an hour as processed
if any row exists in projectHourlyStats for that hour, which skips
partially-processed hours; to fix, change the logic so the missing-check is done
per-project: either (A) modify the SQL that populates hoursToProcess to
join/compare distinct projects-with-logs against distinct projects-with-stats
per hour (include project identifier in the NOT EXISTS check against
projectHourlyStats and select only hours where some project is missing), or (B)
keep the generate_series query but inside the per-hour processing loop filter
the projects list by performing a NOT EXISTS check per (hour, project) against
projectHourlyStats (use projectHourlyStats.hourTimestamp and project id column)
so only projects that lack a stats row are processed; update references to
hoursToProcess, projectHourlyStats, projectHourlyStats.hourTimestamp and the
per-hour loop that iterates over STATS_BATCH_SIZE accordingly.
🧹 Nitpick comments (2)
apps/worker/src/services/project-stats-aggregator.ts (2)

424-424: Unbounded generate_series when STATS_BACKFILL_DAYS=0.

When backfill days is set to 0 (unlimited), rangeStart becomes 1970-01-01 00:00:00, producing ~480K+ hour slots in the series. PostgreSQL's generate_series will materialize this before applying the NOT EXISTS filter and LIMIT. On a system with few missing hours this is still fast, but it's worth noting the potential for a large intermediate set.

Consider falling back to the earliest log created_at instead of epoch when unlimited backfill is configured, e.g. a quick SELECT to_char(date_trunc('hour', min(created_at)), ...) FROM log to bound the range.


456-484: Sequential per-project, per-hour processing issues 4 separate queries — consider batching.

For each project in each hour, 4 sequential INSERT ... ON CONFLICT calls are made. During initial backfill of many hours with many projects, this could be very slow. Since this is a background job it won't block users, but it does extend the window during which the concurrency guard blocks subsequent runs.

Consider wrapping the 4 recalculate calls per project in a single transaction to reduce round-trips, or processing all projects for an hour in parallel (e.g., Promise.all with a concurrency limit).

Comment on lines +434 to +449
const hoursToProcess = await database.execute<{
hour_timestamp: string;
}>(sql`
SELECT to_char(h, 'YYYY-MM-DD HH24:MI:SS') AS hour_timestamp
FROM generate_series(
${rangeStart}::timestamp,
${currentHourStart}::timestamp - interval '1 hour',
interval '1 hour'
) AS h
WHERE NOT EXISTS (
SELECT 1 FROM ${projectHourlyStats}
WHERE ${projectHourlyStats.hourTimestamp} = h
)
.groupBy(log.projectId, sql`date_trunc('hour', ${log.createdAt})`)
.orderBy(sql`date_trunc('hour', ${log.createdAt}) ASC`)
.limit(STATS_BATCH_SIZE);
ORDER BY h ASC
LIMIT ${STATS_BATCH_SIZE}
`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Backfill skips hours that are only partially aggregated (some projects missing).

The NOT EXISTS check on Line 443–446 considers an hour "processed" if any project_hourly_stats row exists for that hour_timestamp, regardless of project. If a previous run aggregated project A for hour X but crashed before processing project B, that hour will never be retried by Phase 1. Phase 2 (stale detection) also won't help because project B has no existing stats row to compare against.

Add the project dimension to the check, or change the approach to compare distinct projects-with-logs against distinct projects-with-stats per hour. One lightweight fix: instead of filtering hours globally, keep the current hour list but inside the per-hour loop, use NOT EXISTS per-project to skip only projects already aggregated:

Proposed approach sketch

Keep the generate_series query as-is to find candidate hours (hours where at least one project may be missing), but change the NOT EXISTS to look for hours where at least one project is missing stats:

 SELECT to_char(h, 'YYYY-MM-DD HH24:MI:SS') AS hour_timestamp
 FROM generate_series(
     ${rangeStart}::timestamp,
     ${currentHourStart}::timestamp - interval '1 hour',
     interval '1 hour'
 ) AS h
-WHERE NOT EXISTS (
-    SELECT 1 FROM ${projectHourlyStats}
-    WHERE ${projectHourlyStats.hourTimestamp} = h
-)
+WHERE EXISTS (
+    SELECT 1 FROM ${log}
+    WHERE ${log.createdAt} >= h
+      AND ${log.createdAt} < h + interval '1 hour'
+    LIMIT 1
+)
+AND NOT EXISTS (
+    SELECT 1 FROM ${projectHourlyStats}
+    WHERE ${projectHourlyStats.hourTimestamp} = h
+)
 ORDER BY h ASC
 LIMIT ${STATS_BATCH_SIZE}

Alternatively, within the per-hour loop (lines 460–478), filter out projects that already have a stats row for that hour so partially-processed hours are completed on future runs — though that requires a separate change to include partially-processed hours in the candidate list.

🤖 Prompt for AI Agents
In `@apps/worker/src/services/project-stats-aggregator.ts` around lines 434 - 449,
The current hours generation marks an hour as processed if any row exists in
projectHourlyStats for that hour, which skips partially-processed hours; to fix,
change the logic so the missing-check is done per-project: either (A) modify the
SQL that populates hoursToProcess to join/compare distinct projects-with-logs
against distinct projects-with-stats per hour (include project identifier in the
NOT EXISTS check against projectHourlyStats and select only hours where some
project is missing), or (B) keep the generate_series query but inside the
per-hour processing loop filter the projects list by performing a NOT EXISTS
check per (hour, project) against projectHourlyStats (use
projectHourlyStats.hourTimestamp and project id column) so only projects that
lack a stats row are processed; update references to hoursToProcess,
projectHourlyStats, projectHourlyStats.hourTimestamp and the per-hour loop that
iterates over STATS_BATCH_SIZE accordingly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the worker’s hourly stats aggregation by changing how backfill discovers missing aggregation windows, reducing log-processing verbosity, and preventing overlapping refresh runs from piling up database work.

Changes:

  • Downgrade per-log processing output from info to trace.
  • Replace the backfill “find missing buckets” query with an hour-based generate_series + NOT EXISTS approach and per-hour project discovery.
  • Add an in-process isRunning guard to skip concurrent refresh executions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
apps/worker/src/worker.ts Lowers log processing verbosity to reduce log volume.
apps/worker/src/services/project-stats-aggregator.ts Reworks backfill discovery/iteration logic and adds an overlap guard for refresh runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

),
);

if (projects.length === 0) {

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated hour list can include hours that have no logs. In that case projects.length === 0 and the code continues without writing any stats row, so that hour will remain "missing" forever and will be selected again on the next run (potentially blocking progress at the start of the range). Consider adding an EXISTS filter on the logs table in the SQL that selects hoursToProcess, or otherwise skipping hours with no logs in a way that won’t reselect them indefinitely.

Suggested change
if (projects.length === 0) {
if (projects.length === 0) {
logger.info(
`[backfill] Skipping hour ${i + 1}/${hoursToProcess.rows.length}: ${hourTimestamp} (no projects with logs)`,
);
totalBucketsProcessed++;

Copilot uses AI. Check for mistakes.
Comment on lines +456 to +479
for (let i = 0; i < hoursToProcess.rows.length; i++) {
const hourTimestamp = hoursToProcess.rows[i].hour_timestamp;

// Find all projects with logs in this hour
const projects = await database
.selectDistinct({ projectId: log.projectId })
.from(log)
.where(
and(
sql`${log.createdAt} >= ${hourTimestamp}::timestamp`,
sql`${log.createdAt} < ${hourTimestamp}::timestamp + interval '1 hour'`,
),
);

if (projects.length === 0) {
continue;
}

for (const { projectId } of projects) {
await recalculateProjectHourlyStats(projectId, hourTimestamp);
await recalculateProjectHourlyModelStats(projectId, hourTimestamp);
await recalculateApiKeyHourlyStats(projectId, hourTimestamp);
await recalculateApiKeyHourlyModelStats(projectId, hourTimestamp);
}

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STATS_BATCH_SIZE is now applied to number of hours, but the work per hour is unbounded (it scales with number of projects that had logs in that hour, and each project triggers 4 aggregation queries). This can cause a single run to take much longer than before and make progress unpredictable. Consider adding a secondary cap (e.g., max project-hour buckets per run) or restructuring to limit the number of (projectId, hourTimestamp) buckets processed per invocation.

Copilot uses AI. Check for mistakes.
`[backfill] Processed bucket ${i + 1}/${backfillBuckets.length}: project=${bucket.projectId} hour=${bucket.hourTimestamp}`,
`[backfill] Processed hour ${i + 1}/${hoursToProcess.rows.length}: ${hourTimestamp} (${projects.length} projects)`,
);
totalBucketsProcessed++;

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalBucketsProcessed is incremented once per processed hour in the backfill phase, but in the stale phase it is incremented by the number of project-hour buckets. This mixes units while the log line reports "total buckets processed", which can make metrics misleading. Consider incrementing by the number of project-hour buckets processed in backfill (e.g., projects.length) or renaming/logging separate counters for hours vs buckets.

Suggested change
totalBucketsProcessed++;
totalBucketsProcessed += projects.length;

Copilot uses AI. Check for mistakes.
Comment on lines +412 to +415
// Phase 1: Backfill — iterate hours sequentially from the frontier.
// Instead of scanning all logs with a LEFT JOIN (slow on large tables),
// we find the last processed hour and walk forward, processing each hour
// individually using the created_at index for fast lookups.

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated backfill comment says it "find[s] the last processed hour and walk[s] forward", but the implementation generates all hours in the window and filters via NOT EXISTS (no frontier-based start). Please align the comment with the actual approach, or implement the described frontier logic to avoid confusion during future maintenance.

Suggested change
// Phase 1: Backfill — iterate hours sequentially from the frontier.
// Instead of scanning all logs with a LEFT JOIN (slow on large tables),
// we find the last processed hour and walk forward, processing each hour
// individually using the created_at index for fast lookups.
// Phase 1: Backfill — identify and process unaggregated hourly buckets.
// Instead of scanning all logs with a LEFT JOIN (slow on large tables),
// we generate all hours in the backfill window and use NOT EXISTS against
// the small, indexed stats tables to find hours that still need aggregation.

Copilot uses AI. Check for mistakes.
Comment on lines +430 to 446
// Find hours with gaps: generate all hours in the backfill window,
// then exclude hours that already have stats rows.
// This is fast because generate_series is instant and the NOT EXISTS
// checks against the small project_hourly_stats table (indexed).
const hoursToProcess = await database.execute<{
hour_timestamp: string;
}>(sql`
SELECT to_char(h, 'YYYY-MM-DD HH24:MI:SS') AS hour_timestamp
FROM generate_series(
${rangeStart}::timestamp,
${currentHourStart}::timestamp - interval '1 hour',
interval '1 hour'
) AS h
WHERE NOT EXISTS (
SELECT 1 FROM ${projectHourlyStats}
WHERE ${projectHourlyStats.hourTimestamp} = h
)

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backfill gap detection is currently only checking for existence of any row in project_hourly_stats for a given hour (WHERE projectHourlyStats.hourTimestamp = h). Since the table is unique per (projectId, hourTimestamp), this will incorrectly treat an hour as “processed” if just one project has stats for that hour, leaving gaps for other projects undiscovered. Consider driving the backfill off missing (projectId, hourTimestamp) pairs (e.g., join projects-with-logs per hour and NOT EXISTS on both columns) or otherwise ensure per-project gaps are detected.

Copilot uses AI. Check for mistakes.
steebchen and others added 2 commits February 11, 2026 03:23
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
generate_series starting from a non-hour-aligned timestamp
(e.g. 20:27:48) produced non-aligned hour buckets. Each
worker restart had a different offset, creating new rows
that didn't match previous ones via NOT EXISTS, causing
counts to grow indefinitely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@steebchen steebchen closed this Feb 10, 2026
@steebchen
steebchen deleted the fix/stats-backfill-performance branch February 10, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants