···11+---
22+"@atmo-dev/contrail-appview": minor
33+"@atmo-dev/contrail-base": minor
44+---
55+66+perf: gate schema replay on a fingerprint; add opt-in planner-stat maintenance
77+88+Two independent performance fixes found while profiling a D1 consumer.
99+1010+**Cold-start schema replay (always on).** `initSchema` ran ~40 base/collection/
1111+index/fts/feed/spaces DDL statements serially on every `init()` call, with no
1212+gate. Consumers call `init()` once per isolate and Workers isolates recycle
1313+constantly, so the first request to each cold isolate paid ~40 sequential
1414+round-trips to the D1 storage object before any real work. `initSchema` now
1515+records a fingerprint of the resolved schema (hash of the generated DDL +
1616+`CONTRAIL_SCHEMA_VERSION`) in a new `_contrail_meta` table and, on a match,
1717+skips all DDL after a single read. Steady-state cold start drops from ~40
1818+round-trips to one; the full apply only runs on first init or an actual schema
1919+change. Concurrent-init safety on Postgres is unchanged (the gate just wraps the
2020+existing idempotent apply).
2121+2222+**Query-planner statistics (opt-in).** Without `ANALYZE`, SQLite's planner picks
2323+the least-selective index for multi-predicate queries (measured ~50x more rows
2424+read on a `subject.uri` + `status` filter). New opt-in config:
2525+2626+```ts
2727+maintenance: { optimize: true } // or { intervalMs, analysisLimit }
2828+```
2929+3030+When enabled, the ingest tick runs a CPU-bounded `PRAGMA analysis_limit=400;
3131+PRAGMA optimize` on a persisted daily cadence (stored in `_contrail_meta`, so it
3232+isn't defeated by recycled isolates — the same in-memory-state bug the feed
3333+prune had). `analysis_limit` bounds the work so it can't exceed D1's per-query
3434+CPU budget and reset the DO. Also exposed as `contrail.optimize(db)` for
3535+consumers that prefer to schedule it themselves. No-op on Postgres
3636+(autovacuum/autoanalyze handles planner stats).
+3-1
packages/contrail-appview/src/core/db/index.ts
···11-export { initSchema } from "./schema";
11+export { initSchema, CONTRAIL_SCHEMA_VERSION } from "./schema";
22+export { getMeta, setMeta, getMetaNumber } from "./meta";
33+export { optimizeDatabase } from "./optimize";
24export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records";
35export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records";
46export type { RecordSource } from "../types";
+47
packages/contrail-appview/src/core/db/meta.ts
···11+import type { Database } from "../types";
22+33+/**
44+ * Generic single-row-per-key store backed by `_contrail_meta(key, value)`.
55+ * Backs the schema-fingerprint gate (schema.ts) and the optimize cadence
66+ * timestamp (the ingest tick).
77+ *
88+ * Reads are tolerant: if the table doesn't exist yet — the first `initSchema`
99+ * before any DDL has run — the read resolves to null rather than throwing, so a
1010+ * caller treats "no table" the same as "no value". Any transient read error
1111+ * degrades the same way (callers fall back to doing the work), which is safe
1212+ * because the only callers gate idempotent work on the result.
1313+ */
1414+export async function getMeta(db: Database, key: string): Promise<string | null> {
1515+ try {
1616+ const row = await db
1717+ .prepare("SELECT value FROM _contrail_meta WHERE key = ?")
1818+ .bind(key)
1919+ .first<{ value: string }>();
2020+ return row?.value ?? null;
2121+ } catch {
2222+ return null;
2323+ }
2424+}
2525+2626+export async function setMeta(
2727+ db: Database,
2828+ key: string,
2929+ value: string
3030+): Promise<void> {
3131+ await db
3232+ .prepare(
3333+ "INSERT INTO _contrail_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
3434+ )
3535+ .bind(key, value)
3636+ .run();
3737+}
3838+3939+export async function getMetaNumber(
4040+ db: Database,
4141+ key: string
4242+): Promise<number | null> {
4343+ const v = await getMeta(db, key);
4444+ if (v === null) return null;
4545+ const n = Number(v);
4646+ return Number.isFinite(n) ? n : null;
4747+}
+36
packages/contrail-appview/src/core/db/optimize.ts
···11+import type { Database } from "../types";
22+import { getDialect, postgresDialect } from "../dialect";
33+44+/**
55+ * Refresh the query planner's statistics so multi-predicate queries pick the
66+ * selective index rather than the planner's default heuristic.
77+ *
88+ * SQLite/D1 only. Runs a CPU-bounded `PRAGMA optimize`: `analysis_limit` caps
99+ * the rows sampled per run so it can't exceed D1's per-query CPU budget and
1010+ * reset the shared Durable Object — the same guardrail the feed prune needs (a
1111+ * raw unbounded `ANALYZE` on a large table is exactly that failure mode).
1212+ * `PRAGMA optimize` only reanalyzes tables whose stats are stale, so it's a
1313+ * near-no-op once warmed; the first call on a never-analyzed DB does the bulk
1414+ * of the work, which `analysis_limit` bounds.
1515+ *
1616+ * No-op on Postgres, where autovacuum/autoanalyze maintains planner stats.
1717+ *
1818+ * Surfaces errors to the caller (e.g. an environment that rejects the pragmas);
1919+ * the auto-run in the ingest tick wraps this so maintenance can't break ingest.
2020+ */
2121+export async function optimizeDatabase(
2222+ db: Database,
2323+ analysisLimit = 400
2424+): Promise<void> {
2525+ if (getDialect(db) === postgresDialect) return;
2626+2727+ try {
2828+ await db
2929+ .prepare(`PRAGMA analysis_limit = ${Math.max(0, Math.floor(analysisLimit))}`)
3030+ .run();
3131+ } catch {
3232+ // Some environments reject analysis_limit; PRAGMA optimize is still safe to
3333+ // run (just potentially less bounded), so don't abort on this.
3434+ }
3535+ await db.prepare("PRAGMA optimize").run();
3636+}
+85
packages/contrail-appview/src/core/db/schema.ts
···1212import { getSearchableFields } from "../search";
1313import { buildSpacesBaseSchema } from "../spaces/schema";
1414import { buildLabelsSchema } from "../labels/schema";
1515+import { getMeta, setMeta } from "./meta";
1616+1717+/** Bump when contrail changes schema in a way the generated-DDL hash below
1818+ * can't see on its own — chiefly the spaces / community / labels internal
1919+ * table shapes (their DDL isn't all enumerated into the fingerprint). Pure
2020+ * config-driven changes (collections, feeds, indexes, migrations) bust the
2121+ * fingerprint automatically and don't need a bump. */
2222+export const CONTRAIL_SCHEMA_VERSION = 1;
2323+2424+const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint";
15251626function getResolved(config: ContrailConfig): ResolvedMaps {
1727 return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved;
···19292030function buildBaseSchema(dialect: SqlDialect): string {
2131 return `
3232+CREATE TABLE IF NOT EXISTS _contrail_meta (
3333+ key TEXT PRIMARY KEY,
3434+ value TEXT NOT NULL
3535+);
2236CREATE TABLE IF NOT EXISTS backfills (
2337 did TEXT NOT NULL,
2438 collection TEXT NOT NULL,
···474488 await applyCountColumns(target, config, { forSpaces: true });
475489}
476490491491+/** Stable, dependency-free 64-bit-ish hash (two seeded FNV-1a passes → hex).
492492+ * Sync and Workers-safe (no crypto). Collisions only matter if two *different*
493493+ * schemas hash identically AND a deploy transitions between them — negligible,
494494+ * and CONTRAIL_SCHEMA_VERSION is the explicit backstop. */
495495+function hashStrings(parts: string[]): string {
496496+ const joined = parts.join("�");
497497+ let h1 = 0x811c9dc5;
498498+ let h2 = 0x01000193;
499499+ for (let i = 0; i < joined.length; i++) {
500500+ const c = joined.charCodeAt(i);
501501+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
502502+ h2 = Math.imul(h2 ^ c, 0x811c9dc5) >>> 0;
503503+ }
504504+ return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
505505+}
506506+507507+/** Fingerprint of everything `initSchema` would apply, so an unchanged schema
508508+ * can skip the DDL entirely. Includes the generated core DDL (which already
509509+ * reflects collections/feeds/indexes), the count-column + migration set, the
510510+ * labels/spaces base DDL when enabled, feature flags, dialect, and the
511511+ * version constant. */
512512+function schemaFingerprint(
513513+ config: ContrailConfig,
514514+ dialect: SqlDialect,
515515+ ddl: {
516516+ base: string[];
517517+ collections: string[];
518518+ indexes: string[];
519519+ feeds: string[];
520520+ fts: string[];
521521+ }
522522+): string {
523523+ const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost);
524524+ return hashStrings([
525525+ `v${CONTRAIL_SCHEMA_VERSION}`,
526526+ dialect.bigintType,
527527+ config.community ? "community" : "",
528528+ config.spaces?.authority ? "spaces.authority" : "",
529529+ config.spaces?.recordHost ? "spaces.recordHost" : "",
530530+ config.labels ? "labels" : "",
531531+ ...ddl.base,
532532+ ...ddl.collections,
533533+ ...ddl.indexes,
534534+ ...ddl.feeds,
535535+ ...ddl.fts,
536536+ ...buildCountColumns(config),
537537+ ...(config.labels ? buildLabelsSchema(dialect) : []),
538538+ ...(hasSpaces ? buildSpacesBaseSchema(dialect) : []),
539539+ JSON.stringify(MIGRATIONS),
540540+ ]);
541541+}
542542+477543export async function initSchema(
478544 db: Database,
479545 config: ContrailConfig,
···487553 const indexStatements = buildDynamicIndexes(config, dialect);
488554 const ftsStatements = buildFtsTables(config, dialect);
489555 const feedStatements = buildFeedTables(config, dialect);
556556+557557+ // Steady-state fast path: if the schema already on disk matches what we'd
558558+ // apply, skip every DDL statement after one cheap read. Consumers call
559559+ // init() once per isolate, and Workers isolates recycle constantly, so
560560+ // otherwise the first request to each cold isolate pays ~40 sequential DDL
561561+ // round-trips to the D1 storage object before any real work. The read
562562+ // tolerates a missing `_contrail_meta` (true first init) and returns null.
563563+ const fingerprint = schemaFingerprint(config, dialect, {
564564+ base: baseStatements,
565565+ collections: collectionStatements,
566566+ indexes: indexStatements,
567567+ feeds: feedStatements,
568568+ fts: ftsStatements,
569569+ });
570570+ if ((await getMeta(db, SCHEMA_FINGERPRINT_KEY)) === fingerprint) return;
490571491572 const spacesDb = options.spacesDb;
492573 const spacesSharesMainDb = !spacesDb || spacesDb === db;
···541622 // Idempotent count-column ALTERs + their indexes. Routed through
542623 // `applyCountColumns` so non-duplicate-column errors propagate.
543624 await applyCountColumns(db, config);
625625+626626+ // Record the applied fingerprint so future cold starts skip all the DDL
627627+ // above after a single read. `_contrail_meta` was created by the base DDL.
628628+ await setMeta(db, SCHEMA_FINGERPRINT_KEY, fingerprint);
544629}
+31-1
packages/contrail-appview/src/core/jetstream.ts
···55 getDependentNsids,
66 shortNameForNsid,
77 buildFeedTargetCaps,
88+ optimizeEnabled,
99+ optimizeIntervalMs,
1010+ optimizeAnalysisLimit,
811} from "./types";
99-import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db";
1212+import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor, getMetaNumber, setMeta, optimizeDatabase } from "./db";
1013import { refreshStaleIdentities, applyIdentityEvent } from "./identity";
1114import { backfillFollowersFromConstellation } from "./constellation";
1215···1518 * actor costs a handful of index-backed O(cap) deletes, so this bounds the
1619 * prune's per-tick CPU regardless of how large feed_items grows. */
1720export const FEED_PRUNE_SWEEP_ACTORS = 500;
2121+2222+/** `_contrail_meta` key for the persisted optimize cadence (so recycled cron
2323+ * isolates don't re-run it every tick — the in-memory-state bug we hit with
2424+ * the feed prune). Shared by the persistent loop. */
2525+export const OPTIMIZE_LAST_MS_KEY = "optimize_last_ms";
2626+2727+/** Run the opt-in planner-stat maintenance if enabled and its persisted
2828+ * interval has elapsed. Bounded + no-op on Postgres (see optimizeDatabase).
2929+ * Wrapped by callers so a pragma-unsupported environment can't break ingest. */
3030+export async function maybeOptimize(db: Database, config: ContrailConfig, log: Logger): Promise<void> {
3131+ if (!optimizeEnabled(config)) return;
3232+ const last = await getMetaNumber(db, OPTIMIZE_LAST_MS_KEY);
3333+ if (Date.now() - (last ?? 0) <= optimizeIntervalMs(config)) return;
3434+ // Claim the interval up front so a failing/unsupported pragma can't re-run
3535+ // every tick — it retries only after the next interval elapses.
3636+ await setMeta(db, OPTIMIZE_LAST_MS_KEY, String(Date.now()));
3737+ try {
3838+ await optimizeDatabase(db, optimizeAnalysisLimit(config));
3939+ log.log("[maintenance] refreshed planner stats (PRAGMA optimize)");
4040+ } catch (err) {
4141+ log.warn(`[maintenance] optimize failed: ${err}`);
4242+ }
4343+}
18441945/** Mutable state that persists across ingest cycles within the same process. */
2046export interface IngestState {
···362388 if (pruned > 0) log.log(`Pruned ${pruned} feed items (sweep)`);
363389 }
364390 }
391391+392392+ // Opt-in planner-stat maintenance (gated + persisted cadence; no-op unless
393393+ // config.maintenance.optimize is set).
394394+ await maybeOptimize(db, config, log);
365395366396 log.log(`[ingest] cycle complete. stored=${events.length}`);
367397}
+4-1
packages/contrail-appview/src/core/persistent.ts
···1010import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db";
1111import { refreshStaleIdentities, applyIdentityEvent } from "./identity";
1212import { backfillFollowersFromConstellation } from "./constellation";
1313-import { createIngestState, FEED_PRUNE_SWEEP_ACTORS } from "./jetstream";
1313+import { createIngestState, FEED_PRUNE_SWEEP_ACTORS, maybeOptimize } from "./jetstream";
1414import type { IngestState } from "./jetstream";
15151616/** How often the long-lived persistent loop runs a bounded feed sweep. The
···197197 }
198198 state.lastFeedSweepMs = Date.now();
199199 }
200200+201201+ // Opt-in planner-stat maintenance (gated + persisted cadence).
202202+ await maybeOptimize(db, config, log);
200203201204 log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`);
202205 } finally {
+2
packages/contrail-appview/src/index.ts
···3838// DB
3939export * from "./core/db/schema";
4040export * from "./core/db/records";
4141+export * from "./core/db/meta";
4242+export * from "./core/db/optimize";
4143// note: ./core/db/index is implicitly covered by the wildcard if we export it
4244// — but we don't, since both schema and records may export overlapping names.
4345// Tests can import the specifics they need.
+43
packages/contrail-base/src/types.ts
···272272 * Example: ["pds.dev.svc.cluster.local"]. */
273273 additionalAllowedHosts?: string[];
274274 };
275275+ /** Optional background database maintenance. All off by default. */
276276+ maintenance?: MaintenanceConfig;
277277+}
278278+279279+export interface MaintenanceConfig {
280280+ /** Periodically refresh the SQLite query planner's statistics so
281281+ * multi-predicate queries pick the selective index instead of the planner's
282282+ * default heuristic (measured ~50x fewer rows read on a 2-predicate query).
283283+ * Off by default — it's a DB write + CPU and shouldn't change behavior for
284284+ * existing consumers unless enabled. `true` uses defaults; pass an object to
285285+ * tune. No-op on Postgres, where autovacuum/autoanalyze handles this. */
286286+ optimize?: boolean | MaintenanceOptimizeConfig;
287287+}
288288+289289+export interface MaintenanceOptimizeConfig {
290290+ /** Minimum gap between optimize runs (default: 24h). Planner stats change
291291+ * slowly, so daily is plenty. */
292292+ intervalMs?: number;
293293+ /** `PRAGMA analysis_limit` — bounds the work per run so it can't exceed
294294+ * D1's per-query CPU budget and reset the shared DO (default: 400). */
295295+ analysisLimit?: number;
296296+}
297297+298298+export const DEFAULT_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60 * 1000;
299299+export const DEFAULT_ANALYSIS_LIMIT = 400;
300300+301301+/** Whether the opt-in planner-stat maintenance is enabled. */
302302+export function optimizeEnabled(config: ContrailConfig): boolean {
303303+ return !!config.maintenance?.optimize;
304304+}
305305+306306+/** Resolved optimize interval (ms), falling back to the 24h default. */
307307+export function optimizeIntervalMs(config: ContrailConfig): number {
308308+ const o = config.maintenance?.optimize;
309309+ if (o && typeof o === "object" && o.intervalMs != null) return o.intervalMs;
310310+ return DEFAULT_OPTIMIZE_INTERVAL_MS;
311311+}
312312+313313+/** Resolved `analysis_limit` for optimize, falling back to the default. */
314314+export function optimizeAnalysisLimit(config: ContrailConfig): number {
315315+ const o = config.maintenance?.optimize;
316316+ if (o && typeof o === "object" && o.analysisLimit != null) return o.analysisLimit;
317317+ return DEFAULT_ANALYSIS_LIMIT;
275318}
276319277320export interface ConstellationConfig {
+11-1
packages/contrail/src/contrail.ts
···11import type { ContrailConfig, Database, ResolvedContrailConfig } from "./core/types";
22-import { resolveConfig, validateConfig } from "./core/types";
22+import { resolveConfig, validateConfig, optimizeAnalysisLimit } from "./core/types";
33import { initSchema } from "./core/db/schema";
44+import { optimizeDatabase } from "./core/db";
45import { queryRecords } from "./core/db/records";
56import type { QueryOptions, SortOption } from "./core/db/records";
67import { runIngestCycle, createIngestState } from "./core/jetstream";
···9091 const spaces = spacesDb ?? this._spacesDb;
9192 const extraSchemas = this._community ? [this._community.applySchema] : [];
9293 await initSchema(main, this.config, { spacesDb: spaces, extraSchemas });
9494+ }
9595+9696+ /** Refresh the SQLite query-planner statistics (bounded `PRAGMA optimize`) so
9797+ * multi-predicate queries pick the selective index. No-op on Postgres. Safe
9898+ * to call on a schedule; the ingest tick runs this automatically when
9999+ * `config.maintenance.optimize` is enabled, so most consumers don't need to
100100+ * call it directly. */
101101+ async optimize(db?: Database): Promise<void> {
102102+ await optimizeDatabase(this.getDb(db), optimizeAnalysisLimit(this.config));
93103 }
9410495105 /** Query records from a collection. */