fix(server): unblock startup readiness and cut SQLite write amplification - #54
Merged
tusharbhardwaj-bk merged 3 commits intoAug 6, 2026
Conversation
On bkt3.dev the startup ownership backfill took 146 s on every restart, so each deploy disconnected everyone for over two minutes. All 334 threads and 8 projects already had an owner: the pass re-selected the 266 admin-owned rows forever because its WHERE clause treats "owned by the admin" as work to redo, and each row drove correlated subqueries over a 258k-row event log with no index on event_type. Three changes: a cheap guard that returns immediately when no row is ownerless and the one-time admin repair is recorded; a durable marker so that repair runs at most once instead of on every boot; and an index on orchestration_events(event_type, stream_id). The pass also moves to forkParked, alongside welcome.autobootstrap, so it can never hold readiness again. Fail-soft semantics are unchanged. Measured on a VACUUM INTO snapshot of the live database: the repair pass goes from 55.8 s to 3 ms, and the new steady-state guard from 146 s to under a millisecond. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server writes roughly 150 GB/day physically for about 80 MB/day of logical data. Two SQLite defaults cause most of it: synchronous=FULL fsyncs on every COMMIT, and wal_autocheckpoint=1000 copies the WAL back into a 1.86 GB main database every ~4 MB, so every byte is written at least twice. synchronous=NORMAL cannot corrupt a WAL database — recovery replays the WAL on open. The only exposure is losing the last few committed transactions to a power cut or kernel panic; a clean process restart or crash loses nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tracer emits one span per SQL statement, each carrying the full query text. One observed window held 61,175 sql.execute spans, roughly 70-80% of all trace bytes, rotating a 10 MB file every 40 seconds. Raising T3CODE_TRACE_MIN_LEVEL to Warn silences the file completely and takes the in-app diagnostics dashboard down with it, since it reads the same NDJSON. Instead the sink now takes a retain predicate, and the server drops sql.execute spans that finished faster than T3CODE_TRACE_SQL_SLOW_MS (default 250 ms). Slow statements and failures are kept — a 97.9 s span is exactly how the ownership backfill was found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tusharbhardwaj-bk
force-pushed
the
fix/bkt3-startup-backfill-and-sqlite-write-amplification
branch
from
August 6, 2026 16:47
1af9a58 to
66e2f03
Compare
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.
bkt3.dev disconnected every user for over two minutes on every restart, and the box was writing ~150 GB/day for ~80 MB/day of real data. Three independent causes, fixed here.
Phase A — the startup ownership backfill (fork-only)
Evidence. Journal from the live host:
Listening on http://…:18083at 15:11:14, thenownership backfill complete+T3 Code server is ready.at 15:13:40 — 146 s, on every boot. The trace file held a singlesql.executespan of 97.9 s annotatedstartup.phase: "ownership.backfill", running theUPDATE projection_threads … SET owner_user_id = (…)pass.It had nothing to do. All 334 rows in
projection_threadsand all 8 inprojection_projectsalready hadowner_user_idset — zero ownerless rows. It never got cheaper because the hot clause wasWHERE (owner_user_id IS NULL OR owner_user_id = :admin), and 266 of the 334 threads are admin-owned, so they were re-processed forever. Each row then drove correlated subqueries filteringorchestration_eventsonevent_typewithjson_extracton the payload — and there is no index onevent_type. Every existing index leads withaggregate_kind, so those subqueries scanned a 258,840-row / 874 MB table repeatedly.Changes.
planOwnershipBackfillchecks for any ownerless row and whether the one-time repair is recorded. In steady state it logs at debug and returns.OR owner_user_id = :adminexists to undo a historical bulk assignment to the admin — inherently a one-time migration concern, not a per-boot one. It is now gated on a durable marker.orchestration_events(event_type, stream_id), migration1007in the fork's 1000+ lane (current max was 1006).runStartupPhase(...)toforkParked(runStartupPhase(...)), the pattern already used forwelcome.autobootstrapin the same file, so readiness can never wait on it again.Fail-soft semantics are unchanged: it still never crashes startup, and a failure leaves the marker absent so the next boot retries.
Marker mechanism. A two-column
maintenance_markers (marker TEXT PRIMARY KEY, completed_at TEXT NOT NULL)table, created in the same migration. The alternatives were worse:projection_stateis keyed by projector and means "last applied sequence", so overloading it would lie to the projection pipeline; a column onenvironment_userswould tie a database-wide fact to a user row; and the migrations table cannot express "ran, but only after an admin could be resolved". A dedicated table is three lines and the next one-time repair can reuse it.Measured against a
VACUUM INTOsnapshot of the live 1.86 GB database:EXPLAIN QUERY PLAN, both subqueriesSCAN transferred/SCAN created EXISTSSEARCH … USING COVERING INDEX idx_orchestration_events_event_type_stream (event_type=? AND stream_id=?)The index build itself costs 6.4 s cold on that database — a one-time migration cost on the next deploy. The first boot after this lands still runs the admin repair once (now indexed, and off the readiness path); every boot after that takes the guard.
Phase B — SQLite pragmas (upstream-owned file)
apps/server/src/persistence/Layers/Sqlite.tsset onlyjournal_mode = WALandforeign_keys = ON, leavingsynchronous=FULL(an fsync per COMMIT) andwal_autocheckpoint=1000— a ~4 MB checkpoint that re-copies WAL frames into the 1.86 GB main database, doubling every byte written. Added:synchronous = NORMAL,wal_autocheckpoint = 10000,cache_size = -65536,journal_size_limit = 67108864.Safety of
synchronous = NORMAL: it cannot corrupt a WAL-mode database — recovery replays the WAL on open. The only exposure is losing the last few committed transactions to a power loss or kernel panic. A clean process restart, or even a process crash, loses nothing.These are hardcoded with comments rather than made configurable: the
setuplayer is shared withSqlitePersistenceMemoryand has noServerConfigin scope, and there is no existing env pattern for storage-engine knobs.Phase C — trace volume, without blinding the dashboard
The tracer emitted a span for every SQL statement, each carrying the full
db.query.text. One observed window held 61,175sql.executespans, roughly 70–80% of all trace bytes, rotating a 10 MB file about every 40 seconds.The stopgap on the host (
T3CODE_TRACE_MIN_LEVEL=Warn) silences the trace file completely — anddiagnostics/TraceDiagnostics.tsreads that same NDJSON to power the in-app dashboard, so Top Span Names / Slowest Spans / Most Common Failures went dark.Instead,
makeTraceSinknow takes an optionalretainpredicate applied before buffering, and the server passesretainSlowSqlSpans(config.traceSqlSlowMs). Fast successfulsql.executespans are dropped; anything at or above the threshold is kept, as are failed and interrupted spans, which is what the dashboard's failure panels count. Tracing stays at Info and every non-SQL span is untouched.Configurable via
T3CODE_TRACE_SQL_SLOW_MS, default 250;0disables the filter and records every statement. Losing slow-query visibility was not acceptable — a 97.9 s span is exactly how Phase A was found — so the threshold retains it.Deploy follow-up: once this ships, remove the host drop-in
/etc/systemd/system/t3-bkmain.service.d/60-trace.confso the trace level returns to Info.Expected effect
Upstream split
Phase A files are fork-only. Phase B (
persistence/Layers/Sqlite.ts) and Phase C (packages/shared/src/observability.tsplus the server config plumbing) are upstream-owned, and their diffs are kept minimal and self-contained so each can go up as a separate upstream PR later. They are in separate commits here for that reason.Verification
Scoped local runs only (shared dev host; CI owns the full suite):
vp test run apps/server/src/orchestration/ownershipBackfill.test.ts packages/shared/src/observability.test.ts apps/server/src/cli/config.test.ts apps/server/src/environment/ServerEnvironment.test.ts— 34 passedvp test run apps/server/src/bin.test.ts— 17 passedvp run --filter t3 typecheckand--filter @t3tools/shared typecheck— cleanvp linton the changed files — cleanEXPLAIN QUERY PLANand timings above, against a read-onlyVACUUM INTOsnapshot of the live databaseNew test coverage: the four
planOwnershipBackfillskip/repair states and therepairAdminAssignments: falsepath;retainSlowSqlSpansbehaviour and the sink honouringretain.Written by Claude Opus 5 in Claude Code.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.