Skip to content

perf: batch Loops profile checks and index cap delete, recovery and caps list queries - #3393

Merged
richiemcilroy merged 4 commits into
mainfrom
perf/db-hot-paths-batch-loops
Oct 26, 2026
Merged

richiemcilroy merged 4 commits into
mainfrom
perf/db-hot-paths-batch-loops

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Four database hot spots found by checking query plans against PlanetScale Insights. Behaviour is unchanged in every case: same schedules, same results, same retry and lease semantics. The database simply does far less work.

Loops contact sync: batch the unchanged checks

Most scheduled profile checks find nothing changed, but each one paid for its own claim transaction, four source reads, a license read and a completion commit. The worker now claims up to 50 due jobs at once, reads their profiles with one query per source table, and completes every unchanged job in a single update. Jobs whose profile did change, or that need an identity hold or a Stripe customer, still go through the existing per-job path, one at a time, with their lease renewed first.

flowchart LR
  A[claimJobs: 50 due jobs, one transaction] --> B[readLoopsProfiles: 1 query per source table]
  B --> C{profile hash unchanged?}
  C -- yes --> D[completeUnchangedJobs: 1 update per next-check time]
  C -- no --> E[renewLease, then existing per-job sync]
  A -. deadline or 401/403/429 .-> F[releaseJobs: unprocessed leases freed]
Loading
Per 50 unchanged jobs Before After
Cap statements 400 9 to 10
Cap commits 100 2 to 3
License database statements 50 1
  • Check cadence (nextProfileCheck), hashing, retry delays and Loops API calls are untouched, so contacts stay exactly as fresh as before.
  • readLoopsProfiles (packages/database/loops/sources.ts:165) returns the same LoopsProfileSource as readLoopsProfile for every user. Ids are matched case-insensitively, like the column collation the single reads use. License rows are attributed with FIELD(u.email, ...), so email matching keeps the column collation too.
  • completeUnchangedJobs (packages/database/loops/jobs.ts:99) keeps the revision guard per row, (userId, revision) IN (...), so a sync queued mid-batch stays immediately due.
  • If the batch read fails, every job falls back to the per-job path, which is the old error handling.
  • Queue SQL moved into packages/database/loops/jobs.ts so the MySQL contract tests can import it without the worker's app dependencies.

Deleting a cap: index notifications.videoId

DELETE FROM notifications WHERE videoId = ? runs inside the cap delete transaction (API, agent and organization deletes). No index starts with videoId, so it scanned the whole table, and under REPEATABLE READ it locked every scanned row until commit. That blocked new notifications while the delete ran.

Recovery crons: index video_uploads.started_at

The stale upload recovery queries filter on a recent started_at window but scanned the whole table to find it.

Caps list: fetch the page before aggregating

The /dashboard/caps query joined comments, organizations, users and uploads for the owner's entire root library, grouped it, and only then applied LIMIT. Its cost grew with library size on every page view. It now selects the page ids first (apps/web/app/(org)/dashboard/caps/page.tsx:154), then aggregates those rows only. A new (orgId, ownerId, folderId, effectiveCreatedAt) index lets the page subquery read exactly limit index entries in reverse.

SELECT ... FROM videos
INNER JOIN (
  SELECT videos.id FROM videos LEFT JOIN organizations ON videos.orgId = organizations.id
  WHERE videos.ownerId = ? AND videos.orgId = ? AND videos.folderId IS NULL AND organizations.tombstoneAt IS NULL
  ORDER BY videos.effectiveCreatedAt DESC, videos.id DESC LIMIT ? OFFSET ?
) caps_page ON videos.id = caps_page.id
LEFT JOIN comments ... GROUP BY ... ORDER BY videos.effectiveCreatedAt DESC, videos.id DESC
  • Each page returns the same videos with the same fields. The only difference is that caps created in the same second now have a fixed order (id DESC), so paging can't repeat or skip them.

Benchmarks

MySQL 8.4, synthetic dataset (200k videos, 250k notifications, 30k uploads, one owner with 4.5k root caps), median server time from EXPLAIN ANALYZE, main vs this PR:

Query Before After
Caps list, first page 32.3 ms, reads 4,500 videos 0.13 ms, reads 15
notifications by videoId 11.5 ms, full index scan 0.002 ms, index lookup
Stale upload recovery scan 6.7 ms, reads 30,000 uploads 0.54 ms, reads 553

Schema

packages/database/migrations/0051_fancy_talkback.sql is additive only: three CREATE INDEX statements, no drops or column changes.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the latest change fixes the batch-stopping profile error.

Summary

This PR batches Loops profile checks, adds three database indexes, and selects each caps page before counting related rows.

  • Loops checks up to 50 profiles together before syncing changed contacts.
  • The caps page picks its results before joining their details.
  • Cap deletes find matching notifications through a videoId index.
  • Upload recovery finds recent uploads through a started_at index.

Reviews (3) · Last reviewed commit: "fix(loops): defer only the job whose bat..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Oct 26, 2026 2:14pm UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread packages/database/loops/worker.ts Resolved
@vercel
vercel Bot temporarily deployed to Preview – cap-status October 26, 2026 14:14 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot 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.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit 06c4974 into main Oct 26, 2026
53 checks passed

This branch was successfully deployed

1 active and 1 inactive deployments
Preview – cap-web — 75964546 Deployed Oct 26, 2026 by vercel[bot]
Preview – cap-status — 75964546 Deployed Oct 26, 2026 by vercel[bot]
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.

1 participant