Skip to content

perf(dashboard): stop rebuilding the migration plan on every operations request - #2370

Merged
glennmichael123 merged 1 commit into
mainfrom
fix/operations-migration-plan-cost
Aug 27, 2026
Merged

perf(dashboard): stop rebuilding the migration plan on every operations request#2370
glennmichael123 merged 1 commit into
mainfrom
fix/operations-migration-plan-cost

Conversation

@glennmichael123

Copy link
Copy Markdown
Member

/operations/migrations took 5.9s and /operations/changes 4.1s, against 14ms for a normal dashboard endpoint and 0.94s for the next slowest one.

Where it went

Both index actions call migrationPlan(), which runs a full model-versus-schema diff plus a ledger audit per request. Measured against this project's 93 models:

call cost
previewPendingMigrations() 2.3s
auditMigrationLedger() 1.4s
dry-run reconcile (MigrationIndexAction only) 1.3s

Nothing was cached, so opening either page paid the whole cost, moving between the two paid it twice, and a refresh paid it again. The dry-run reconcile re-reads the same ledger the audit had just walked.

What changed

The plan and the repair preview are held for 30 seconds. That covers a page load, a tab switch between the two operations pages, and a few refreshes, while staying short enough that editing a model and reloading shows the new plan without a manual step.

endpoint before after (within window)
/operations/changes 4.1s 0.002s
/operations/migrations 5.9s 0.001s

Cold cost is unchanged. The first view after the window still does the work (measured 3.9s on a warm server). What goes away is doing it a second time for an answer that cannot have changed. Reads also share one computation while it is in flight, so the two pages loading together no longer run the same diff concurrently for identical output.

Writes deliberately do not use the cache

revision is an optimistic-concurrency token: a caller sends back the revision it reviewed, and the server refuses the write if the plan has moved since. Comparing that token against a plan computed up to 30s ago would admit exactly the drift the gate exists to catch.

So applyMigrationPlan and MigrationReconcileAction both ask for a fresh plan, and both drop the cached state afterwards, because a migration that just ran must not keep showing as pending. A dry-run reconcile changes nothing and is cached; a real one runs every time.

A forced read also refuses to join a computation already in flight, since that one may have started before whatever made the caller ask for a fresh value.

Testing

The caching rules live in cached-computation.ts with an injectable clock, so the TTL boundary, single-flight, invalidation and failure-recovery behaviour are tested exactly rather than with sleeps and a live database. Seven tests, including that a rejected computation does not wedge later readers onto the same rejected promise.

A second test pins which call sites opt out of the cache. That is a correctness property, not a preference, and a future edit dropping fresh: true would silently reopen the write gate. I verified the guard fails when fresh: true is removed from MigrationReconcileAction, rather than assuming it would.

  • bun test ./tests: 365 pass, 0 fail
  • operations suite: 15 pass, 0 fail
  • ./buddy lint: 3195 files, 0 errors, 0 warnings
  • ./buddy typecheck: clean

Not fixed here

previewPendingMigrations also dumps ~148 lines of -- Detected column change: to stdout per call. It is gated on bun-query-builder's verbose, and configureQueryBuilder() sets verbose: false right before calling it, so something is putting it back. I forced utils.ts's verbose: getEnv() !== 'production' to false and the output was unchanged, which rules out the obvious culprit. Left for a separate look. Caching does reduce how often it fires.

🤖 Generated with Claude Code

…ns request

`/operations/migrations` took 5.9s and `/operations/changes` 4.1s, against
14ms for a normal dashboard endpoint and 0.94s for the next slowest one.

Both index actions called `migrationPlan()`, which runs a full
model-versus-schema diff plus a ledger audit on every request. Measured
against this project's 93 models:

    previewPendingMigrations   2.3s
    auditMigrationLedger       1.4s

and `MigrationIndexAction` then spent another 1.3s on a dry-run ledger
reconcile reading the same ledger the audit had just walked. None of it was
cached, so opening either page paid the whole cost, moving between the two
paid it twice, and a refresh paid it again.

The plan and the repair preview are now held for 30 seconds. That covers a
page load, a tab switch between the two operations pages, and a few
refreshes, while staying short enough that editing a model and reloading
shows the new plan without a manual step.

    /operations/changes      4.1s -> 0.002s
    /operations/migrations   5.9s -> 0.001s

Cold cost is unchanged: the first view after the window still does the work.
What goes away is doing it again for an answer that cannot have changed.

Reads share one computation while it is in flight, so the two pages loading
together no longer run the same diff concurrently for identical output.

Writes deliberately do not use the cache. `revision` is an
optimistic-concurrency token: `applyMigrationPlan` and `MigrationReconcileAction`
compare a caller's token against the current plan and refuse the write if the
plan moved. Checking that token against a plan computed up to 30s ago would
admit exactly the drift the gate exists to catch, so both ask for a fresh
plan, and both drop the cached state afterwards - a migration that just ran
must not keep showing as pending. A dry-run reconcile changes nothing and is
cached; a real one runs every time.

The caching rules live in `cached-computation.ts` with an injectable clock, so
the TTL boundary, single-flight, invalidation and failure behaviour are tested
exactly rather than with sleeps and a live database. A second test pins which
call sites opt out of the cache, since that is a correctness property and a
future edit dropping `fresh: true` would silently reopen the gate.
@github-actions github-actions Bot added the storage @stacksjs/storage label Aug 27, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

@stacksjs/actions

npm i https://pkg.pr.new/@stacksjs/actions@2370

@stacksjs/ai

npm i https://pkg.pr.new/@stacksjs/ai@2370

@stacksjs/alias

npm i https://pkg.pr.new/@stacksjs/alias@2370

@stacksjs/analytics

npm i https://pkg.pr.new/@stacksjs/analytics@2370

@stacksjs/api

npm i https://pkg.pr.new/@stacksjs/api@2370

@stacksjs/arrays

npm i https://pkg.pr.new/@stacksjs/arrays@2370

@stacksjs/audio

npm i https://pkg.pr.new/@stacksjs/audio@2370

@stacksjs/auth

npm i https://pkg.pr.new/@stacksjs/auth@2370

@stacksjs/browser

npm i https://pkg.pr.new/@stacksjs/browser@2370

@stacksjs/browser-extension

npm i https://pkg.pr.new/@stacksjs/browser-extension@2370

@stacksjs/buddy

npm i https://pkg.pr.new/@stacksjs/buddy@2370

@stacksjs/build

npm i https://pkg.pr.new/@stacksjs/build@2370

@stacksjs/cache

npm i https://pkg.pr.new/@stacksjs/cache@2370

@stacksjs/calendar-api

npm i https://pkg.pr.new/@stacksjs/calendar-api@2370

@stacksjs/charts

npm i https://pkg.pr.new/@stacksjs/charts@2370

@stacksjs/chat

npm i https://pkg.pr.new/@stacksjs/chat@2370

@stacksjs/cli

npm i https://pkg.pr.new/@stacksjs/cli@2370

@stacksjs/cloud

npm i https://pkg.pr.new/@stacksjs/cloud@2370

@stacksjs/cms

npm i https://pkg.pr.new/@stacksjs/cms@2370

@stacksjs/collections

npm i https://pkg.pr.new/@stacksjs/collections@2370

@stacksjs/commerce

npm i https://pkg.pr.new/@stacksjs/commerce@2370

@stacksjs/composables

npm i https://pkg.pr.new/@stacksjs/composables@2370

@stacksjs/config

npm i https://pkg.pr.new/@stacksjs/config@2370

@stacksjs/cron

npm i https://pkg.pr.new/@stacksjs/cron@2370

@stacksjs/database

npm i https://pkg.pr.new/@stacksjs/database@2370

@stacksjs/datetime

npm i https://pkg.pr.new/@stacksjs/datetime@2370

@stacksjs/defaults

npm i https://pkg.pr.new/@stacksjs/defaults@2370

@stacksjs/desktop-build

npm i https://pkg.pr.new/@stacksjs/desktop-build@2370

@stacksjs/dns

npm i https://pkg.pr.new/@stacksjs/dns@2370

@stacksjs/docs

npm i https://pkg.pr.new/@stacksjs/docs@2370

@stacksjs/email

npm i https://pkg.pr.new/@stacksjs/email@2370

@stacksjs/enums

npm i https://pkg.pr.new/@stacksjs/enums@2370

@stacksjs/env

npm i https://pkg.pr.new/@stacksjs/env@2370

@stacksjs/error-handling

npm i https://pkg.pr.new/@stacksjs/error-handling@2370

@stacksjs/events

npm i https://pkg.pr.new/@stacksjs/events@2370

@stacksjs/faker

npm i https://pkg.pr.new/@stacksjs/faker@2370

@stacksjs/feature-flags

npm i https://pkg.pr.new/@stacksjs/feature-flags@2370

@stacksjs/forms

npm i https://pkg.pr.new/@stacksjs/forms@2370

@stacksjs/git

npm i https://pkg.pr.new/@stacksjs/git@2370

@stacksjs/github

npm i https://pkg.pr.new/@stacksjs/github@2370

@stacksjs/health

npm i https://pkg.pr.new/@stacksjs/health@2370

@stacksjs/http

npm i https://pkg.pr.new/@stacksjs/http@2370

@stacksjs/i18n

npm i https://pkg.pr.new/@stacksjs/i18n@2370

@stacksjs/image

npm i https://pkg.pr.new/@stacksjs/image@2370

@stacksjs/lint

npm i https://pkg.pr.new/@stacksjs/lint@2370

@stacksjs/logging

npm i https://pkg.pr.new/@stacksjs/logging@2370

@stacksjs/mobile

npm i https://pkg.pr.new/@stacksjs/mobile@2370

@stacksjs/newsletter

npm i https://pkg.pr.new/@stacksjs/newsletter@2370

@stacksjs/notifications

npm i https://pkg.pr.new/@stacksjs/notifications@2370

@stacksjs/objects

npm i https://pkg.pr.new/@stacksjs/objects@2370

@stacksjs/orm

npm i https://pkg.pr.new/@stacksjs/orm@2370

@stacksjs/path

npm i https://pkg.pr.new/@stacksjs/path@2370

@stacksjs/payments

npm i https://pkg.pr.new/@stacksjs/payments@2370

@stacksjs/push

npm i https://pkg.pr.new/@stacksjs/push@2370

@stacksjs/query-builder

npm i https://pkg.pr.new/@stacksjs/query-builder@2370

@stacksjs/queue

npm i https://pkg.pr.new/@stacksjs/queue@2370

@stacksjs/realtime

npm i https://pkg.pr.new/@stacksjs/realtime@2370

@stacksjs/registry

npm i https://pkg.pr.new/@stacksjs/registry@2370

@stacksjs/repl

npm i https://pkg.pr.new/@stacksjs/repl@2370

@stacksjs/router

npm i https://pkg.pr.new/@stacksjs/router@2370

@stacksjs/scheduler

npm i https://pkg.pr.new/@stacksjs/scheduler@2370

@stacksjs/search-engine

npm i https://pkg.pr.new/@stacksjs/search-engine@2370

@stacksjs/security

npm i https://pkg.pr.new/@stacksjs/security@2370

@stacksjs/server

npm i https://pkg.pr.new/@stacksjs/server@2370

@stacksjs/shell

npm i https://pkg.pr.new/@stacksjs/shell@2370

@stacksjs/sites

npm i https://pkg.pr.new/@stacksjs/sites@2370

@stacksjs/skills

npm i https://pkg.pr.new/@stacksjs/skills@2370

@stacksjs/slug

npm i https://pkg.pr.new/@stacksjs/slug@2370

@stacksjs/sms

npm i https://pkg.pr.new/@stacksjs/sms@2370

@stacksjs/socials

npm i https://pkg.pr.new/@stacksjs/socials@2370

@stacksjs/storage

npm i https://pkg.pr.new/@stacksjs/storage@2370

@stacksjs/strings

npm i https://pkg.pr.new/@stacksjs/strings@2370

@stacksjs/testing

npm i https://pkg.pr.new/@stacksjs/testing@2370

@stacksjs/tinker

npm i https://pkg.pr.new/@stacksjs/tinker@2370

@stacksjs/tunnel

npm i https://pkg.pr.new/@stacksjs/tunnel@2370

@stacksjs/types

npm i https://pkg.pr.new/@stacksjs/types@2370

@stacksjs/ui

npm i https://pkg.pr.new/@stacksjs/ui@2370

@stacksjs/utils

npm i https://pkg.pr.new/@stacksjs/utils@2370

@stacksjs/validation

npm i https://pkg.pr.new/@stacksjs/validation@2370

@stacksjs/video

npm i https://pkg.pr.new/@stacksjs/video@2370

@stacksjs/whois

npm i https://pkg.pr.new/@stacksjs/whois@2370

commit: 72ce9d6

@glennmichael123
glennmichael123 merged commit f354e7f into main Aug 27, 2026
11 checks passed
@glennmichael123
glennmichael123 deleted the fix/operations-migration-plan-cost branch August 27, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

storage @stacksjs/storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant