perf(dashboard): stop rebuilding the migration plan on every operations request - #2370
Merged
Merged
Conversation
…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.
@stacksjs/actions
@stacksjs/ai
@stacksjs/alias
@stacksjs/analytics
@stacksjs/api
@stacksjs/arrays
@stacksjs/audio
@stacksjs/auth
@stacksjs/browser
@stacksjs/browser-extension
@stacksjs/buddy
@stacksjs/build
@stacksjs/cache
@stacksjs/calendar-api
@stacksjs/charts
@stacksjs/chat
@stacksjs/cli
@stacksjs/cloud
@stacksjs/cms
@stacksjs/collections
@stacksjs/commerce
@stacksjs/composables
@stacksjs/config
@stacksjs/cron
@stacksjs/database
@stacksjs/datetime
@stacksjs/defaults
@stacksjs/desktop-build
@stacksjs/dns
@stacksjs/docs
@stacksjs/email
@stacksjs/enums
@stacksjs/env
@stacksjs/error-handling
@stacksjs/events
@stacksjs/faker
@stacksjs/feature-flags
@stacksjs/forms
@stacksjs/git
@stacksjs/github
@stacksjs/health
@stacksjs/http
@stacksjs/i18n
@stacksjs/image
@stacksjs/lint
@stacksjs/logging
@stacksjs/mobile
@stacksjs/newsletter
@stacksjs/notifications
@stacksjs/objects
@stacksjs/orm
@stacksjs/path
@stacksjs/payments
@stacksjs/push
@stacksjs/query-builder
@stacksjs/queue
@stacksjs/realtime
@stacksjs/registry
@stacksjs/repl
@stacksjs/router
@stacksjs/scheduler
@stacksjs/search-engine
@stacksjs/security
@stacksjs/server
@stacksjs/shell
@stacksjs/sites
@stacksjs/skills
@stacksjs/slug
@stacksjs/sms
@stacksjs/socials
@stacksjs/storage
@stacksjs/strings
@stacksjs/testing
@stacksjs/tinker
@stacksjs/tunnel
@stacksjs/types
@stacksjs/ui
@stacksjs/utils
@stacksjs/validation
@stacksjs/video
@stacksjs/whois
commit: |
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.
/operations/migrationstook 5.9s and/operations/changes4.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:previewPendingMigrations()auditMigrationLedger()MigrationIndexActiononly)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.
/operations/changes/operations/migrationsCold 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
revisionis 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
applyMigrationPlanandMigrationReconcileActionboth 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.tswith 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: truewould silently reopen the write gate. I verified the guard fails whenfresh: trueis removed fromMigrationReconcileAction, rather than assuming it would.bun test ./tests: 365 pass, 0 fail./buddy lint: 3195 files, 0 errors, 0 warnings./buddy typecheck: cleanNot fixed here
previewPendingMigrationsalso dumps ~148 lines of-- Detected column change:to stdout per call. It is gated on bun-query-builder'sverbose, andconfigureQueryBuilder()setsverbose: falseright before calling it, so something is putting it back. I forcedutils.ts'sverbose: getEnv() !== 'production'tofalseand 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