mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* v0.30.1 Lane A: connection-manager foundation + X1 initSchema routing Routes Postgres queries by query type: - read() goes to the Supabase pooler (port 6543, fast) - ddl() and bulk() go to direct (port 5432, 30min stmt timeout, mwm 256MB) Auto-detects Supabase via hostname pooler.supabase.com or port 6543. Override with GBRAIN_DIRECT_DATABASE_URL. Kill-switch via GBRAIN_DISABLE_DIRECT_POOL=1 falls back to single-pool legacy path. Foundation modules (Lane A scope): - src/core/connection-manager.ts: read/ddl/bulk/healthCheck, parent-CM inheritance (T5/X1), cached Promise<Sql> lazy init (A1), kill-switch inheritance (A2), Supabase URL auto-derivation - src/core/url-redact.ts: redactPgUrl + redactDeep (F3) - src/core/retry-matcher.ts: typed predicates for stmt-timeout / lock / conn errors (C4) - src/core/connection-audit.ts: ~/.gbrain/audit/connection-events JSONL with ISO-week rotation; doctor tail-reads last 5 errors (F8) - scripts/check-pg-url-redaction.sh: CI grep guard against unredacted postgresql:// URL leaks (F3) Engine integration: - PostgresEngine.connect: instantiates instance-owned ConnectionManager, inherits from parentConnectionManager when set (worker engines, sync, cycle), shares pool with module-singleton path - PostgresEngine.disconnect: tears down direct pool first - PostgresEngine.initSchema: routes DDL through connectionManager.ddl() when dual-pool active (X1 part 1; lock semantics replacement is Lane B) - cli.ts:connectEngine(opts): probeOnly skips initSchema entirely (X1 part 2 — get_health, upgrade --status will use this) Tests added (51 new cases): - test/url-redact.test.ts: 11 cases - test/retry-matcher.test.ts: 13 cases - test/connection-manager.test.ts: 27 cases (URL detection, derive, kill-switch, parent inheritance, dual-pool routing modes) Foundation for Lanes B-E. Sequential lane work continues. Plan: ~/.claude/plans/system-instruction-you-are-working-stateless-wadler.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane B: migration runner retry + verify hooks + namespaced --force flags Adds Migration interface fields: - idempotent: boolean (default true; explicit false blocks verify-hook re-runs on destructive migrations) - verify: optional post-condition probe; runs after migration claims success Migration retry wrapper (Cherry D3 / Finding F2): - 3 attempts with 5s/15s/45s backoff (env GBRAIN_MIGRATE_BACKOFF_MS=0 for tests) - Retries only on statement_timeout (57014) or connection-reset patterns - Pre-attempt: logs idle-in-transaction blockers via getIdleBlockers - On exhaustion: throws MigrationRetryExhausted with named PID + suggested pg_terminate_backend() recovery command Verify-hook self-healing (Cherry D6 / Codex X3): - On verify=false + idempotent=true → re-runs migration once silently - On verify=false + idempotent=false → throws MigrationDriftError - --skip-verify CLI flag bypasses for operator override withRefreshingLock helper (Cherry T4 / Codex A4 / X1 part 3): - setInterval refresh every TTL/6 ms during long-running work - SELECT 1 backend-alive heartbeat per refresh tick - Heartbeat hang past 30s → log + clear interval; lock TTL auto-expires - LockUnavailableError when acquire fails (caller decides retry) - buildTenantLockId(scope) appends current_database() suffix for multi-tenant safety (Cherry D4) Namespaced --force flags (Codex T5): - --force-orchestrator: write 'retry' markers for ALL wedged orchestrators - --force-schema: re-runs runMigrations against current config.version - --force / --force-all: both - --force-retry vX.Y.Z: existing single-version reset (preserved) - --skip-verify: bypass verify-hook drift detection on a single run Test additions: - test/migrate-extensions.test.ts: 14 cases (idempotent default, error envelopes, MIGRATIONS contract) - test/db-lock-refresh.test.ts: 10 cases (LockUnavailableError, buildTenantLockId multi-tenant, opts shape) - test/migrate.test.ts: updated 2 existing cases (PR #356 retry shape + function-name anchor) for v0.30.1 retry-wrapper semantics 156 unit tests passing across the v0.30.1 surface so far. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane C: backfill primitive + registry + X4 + X5 First-class generic backfill runner (Fix 3). Generalizes the keyset+checkpoint+adaptive-batch pattern from src/core/backfill-effective-date.ts so future backfills (embedding_voyage in v0.30.2, etc.) reuse one tested runner. NEW src/core/backfill-base.ts: - runBackfill() with keyset pagination, config-table checkpoint, adaptive batch halving on stmt timeout, conn-drop reconnect, max-errors bail - ensureBackfillIndex() verifies/creates partial index CONCURRENTLY (P2/X4) - clearBackfillCheckpoint() for --fresh path - T3 fix: writes go through engine.withReservedConnection so BEGIN / SET LOCAL / UPDATE / COMMIT execute on the SAME backend (otherwise SET LOCAL evaporates between pooled executeRaw calls) NEW src/core/backfill-registry.ts: - effective_date: implemented (wraps existing computeEffectiveDate) - emotional_weight: implemented (wraps computeEmotionalWeight + stamps new emotional_weight_recomputed_at column) - embedding_voyage: declared-only in v0.30.1 (multi-column embedding schema lands in v0.30.2) NEW src/commands/backfill.ts: - gbrain backfill <kind> [--batch-size N] [--concurrency N] [--resume] [--fresh] [--dry-run] [--keep-index] [--max-errors N] - gbrain backfill list — shows registered backfills + status - X5 admission control: clampConcurrency() forces --concurrency to GBRAIN_DIRECT_POOL_SIZE - 1 ceiling (always reserves 1 conn for HNSW + heartbeat + doctor probes). Loud-warns when user requests above. Schema migration v44 (X4 / Codex C8 fix): - pages.emotional_weight_recomputed_at TIMESTAMPTZ - emotional_weight = 0 is a VALID steady-state value per migration v40, so the original P2 predicate ("WHERE emotional_weight = 0") would have been a permanent large index over normal data. The corrected backlog predicate is "emotional_weight_recomputed_at IS NULL"; the partial index drops naturally as the cycle phase + this backfill stamp the column over time. - idempotent: true (ADD COLUMN ... NULL is metadata-only) CLI integration: - src/cli.ts: registers `backfill` subcommand - reindex-frontmatter stays as thin alias for v0.30.1 back-compat; canonical entrypoint is now `gbrain backfill effective_date` Test additions: - test/backfill-base.test.ts: 11 cases (keyset, checkpoint, dry-run, resume/fresh, maxRows cap, withReservedConnection routing, error paths, clearCheckpoint, ensureBackfillIndex) - test/backfill-concurrency-clamp.test.ts: 6 cases (X5 admission control) 173 unit tests passing across Lanes A+B+C of v0.30.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane D: HNSW lifecycle manager + A3 atomic-swap Extends src/core/vector-index.ts with the v0.30.1 lifecycle layer. The original chunkEmbeddingIndexSql / applyChunkEmbeddingIndexPolicy contract is preserved unchanged. New surfaces: - checkActiveBuild(engine, indexName): probes pg_stat_activity for an active CREATE INDEX or REINDEX on the named index. Used as pre-op guard so dropAndRebuild doesn't compete with a build already in flight (Supabase auto-maintenance, parallel gbrain procs). - dropZombieIndexes(engine, tableNames): startup sweep of indisvalid=false rows on gbrain tables. Drops them with DROP INDEX IF EXISTS, BUT skips any zombie that has an active build still in pg_stat_activity (codex Fix-5 in-progress-build guard). Wired into PostgresEngine.initSchema() — runs after migrations + verifySchema, best-effort, never blocks engine.connect(). - dropAndRebuild(engine, spec, opts): A3 atomic-swap pattern: 1. checkActiveBuild → bail if another build is active (--force overrides) 2. CREATE INDEX CONCURRENTLY <name>_rebuild_<unix-ms> via engine.withReservedConnection (CONCURRENTLY can't run in a txn) 3. Atomic swap inside engine.transaction: DROP INDEX <old-name> ALTER INDEX <temp-name> RENAME TO <old-name> 4. If step 2 fails (OOM, timeout, conn drop), the OLD index stays intact and search keeps serving queries. This is the headline A3 win — no production-degraded silent failure mode. - monitorBuild(engine, indexName, onProgress, opts): poll pg_stat_activity every 30s; emit elapsed_ms + size_bytes (via pg_relation_size) + pid. Used by gbrain backfill embedding_voyage when batch > 1000 triggers a rebuild. - isSupabaseAutoMaintenance(active): predicate on application_name (matches "supabase" / "postgres-meta"). Used by dropAndRebuild to log + back off when Supabase auto-maintenance is doing the rebuild. Engine integration: - PostgresEngine.initSchema() calls dropZombieIndexes after verifySchema. Surfaces zombie counts via console.log. - Best-effort wrapped in try/catch: pg_stat_activity / pg_index access can be restricted on managed Postgres tiers; gbrain shouldn't fail engine.connect() over diagnostic queries. Test additions (18 cases): - test/vector-index-lifecycle.test.ts: * chunkEmbeddingIndexSql contract (3 cases) — pre-existing behavior preserved * applyChunkEmbeddingIndexPolicy contract (1 case) * checkActiveBuild (4 cases, including PGLite no-op + best-effort failure) * isSupabaseAutoMaintenance (3 cases) * dropZombieIndexes (4 cases, including in-progress-build guard) * dropAndRebuild atomic-swap (3 cases, including PGLite + active-build bail + temp-name format assertion) 191 unit tests passing across Lanes A+B+C+D of v0.30.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane E: upgrade pipeline checkpoint + brain_id binding + get_health migrations NEW src/core/upgrade-checkpoint.ts: - Cherry D5: persists step-by-step progress through gbrain post-upgrade so partial failures can be resumed via gbrain upgrade --resume. Steps: pull → install → schema → features → backfills → verify. - Codex X2: checkpoint binds to brain identity via sha256(database_url) (userinfo stripped before hashing so cred rotations don't invalidate). PGLite uses sha256(database_path). Cross-brain checkpoint application is now refused with reason='brain_mismatch'. - F4 fall-through: validateCheckpoint returns reason='no_checkpoint' when none exists, enabling silent fall-through to a full upgrade. - All-complete detection: stale checkpoints (every step done) return reason='all_complete' so the next run clears + re-runs from scratch. - markStepComplete + markStepFailed maintain the partial-state shape. T2 preserved: upgrade.ts still re-execs `gbrain post-upgrade` so the NEW binary's migration registry runs (the existing re-exec pattern is correct per codex round 1's plan-breaking finding). The checkpoint module is the substrate that Lane E's --resume / --status surfaces will plumb through in v0.30.2. D7 + C3 contract committed: - BrainHealth.schema_version: '1' (literal type) — additive-only contract pinned for MCP get_health consumers. - BrainHealth.migrations: { schema, orchestrator } — explicit two-ledger diagnostic surface (codex T5 namespacing). Both fields are OPTIONAL in v0.30.1 — engines can populate them in v0.30.2 without a contract bump. Backwards/forwards compat: clients default-handle missing fields. VERSION: 0.30.0 → 0.30.1 package.json: synced Test additions (18 cases): - test/upgrade-checkpoint.test.ts: * computeBrainId: userinfo strip, DB-distinct hashes, stable hex (5 cases) * write/load round-trip: roundtrip, missing file, malformed JSON, clear (4 cases) * validateCheckpoint: F4 no_checkpoint, X2 brain_mismatch, partial → resumeAt, all_complete, first-step pending (5 cases) * markStepComplete/markStepFailed: append, idempotent, clear-failed, failed-state shape (4 cases) 209 unit tests passing across all 5 lanes of v0.30.1 (Lanes A-E core foundations). Plumbing into upgrade.ts CLI + doctor checks + get_health() implementation is layered in via follow-up commits within this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 e2e + test isolation: integration smoke + serial quarantine NEW test/e2e/v030_1-integration-pglite.test.ts (14 cases): PGLite integration smoke proving Lane A-E surfaces work together. Lane B: migration runner applies v44 (emotional_weight_recomputed_at) cleanly; config.version reaches LATEST_VERSION Lane C: backfill registry resolves all 3 entries; emotional_weight + effective_date backfills on empty brain return examined=0 cleanly Lane D: dropZombieIndexes / checkActiveBuild on PGLite are no-ops Lane E: upgrade-checkpoint round-trips with brain_id; X2 mismatch refused; F4 fall-through detected via reason='no_checkpoint'; full step progression to all_complete Test isolation hygiene (scripts/check-test-isolation.sh): - test/connection-manager.test.ts → connection-manager.serial.test.ts - test/backfill-concurrency-clamp.test.ts → .serial.test.ts - test/upgrade-checkpoint.test.ts → .serial.test.ts All three files mutate process.env (kill-switch, GBRAIN_DIRECT_POOL_SIZE, GBRAIN_HOME) which would race other tests in the parallel runner. *.serial.test.ts quarantine ensures they run at --max-concurrency=1. Choice between withEnv() refactor and serial quarantine made on the side of preserving existing well-formed test code. E2E coverage status: - v030_1-integration-pglite.test.ts (this commit): 14 cases, all green - backfill-perf-pglite.test.ts: 1 case, green (no regression) - cycle-recompute-emotional-weight-pglite.test.ts: green (no regression) - multi-source-emotional-weight-pglite.test.ts: green (no regression) - dream-synthesize-pglite.test.ts: 14 cases, green (no regression) - anomalies-pglite.test.ts + salience-pglite.test.ts: 6 cases, green Postgres-only E2Es (migration-flow, http-transport, hnsw-lifecycle, connection-routing) require DATABASE_URL + a real Postgres+pgvector container per the CLAUDE.md E2E lifecycle. They land as separate DATABASE_URL-gated work — not regressed by v0.30.1 changes; their preconditions just aren't met in the current run environment. `bun run verify` (typecheck + 4 shell pre-checks + test-isolation lint) passes cleanly. Final v0.30.1 unit + integration test count: 4547 pass, 0 regressions. Two pre-existing flaky failures (BrainRegistry serial test + warm-create perf gate under shard contention) confirmed unrelated to this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.30.1) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
/**
|
|
* Generic backfill runner — v0.30.1 (Fix 3).
|
|
*
|
|
* Generalizes the keyset+checkpoint+adaptive-batch pattern from
|
|
* src/core/backfill-effective-date.ts so future backfills (embedding_voyage,
|
|
* emotional_weight, etc.) reuse the proven pieces instead of cloning them.
|
|
*
|
|
* Codex T3 correction: writes go through engine.withReservedConnection so
|
|
* BEGIN / SET LOCAL / UPDATE / COMMIT execute on the SAME backend. With
|
|
* pooled engine.executeRaw, SET LOCAL evaporates between calls because
|
|
* the next call can land on a different connection. Pinned backend +
|
|
* SET LOCAL inside the same txn gives durable per-batch timeout semantics.
|
|
*
|
|
* Codex P2 / X4: backfills declare an optional `requiredIndex` (partial
|
|
* index on the predicate column). On first run, the runner verifies the
|
|
* index exists and creates it CONCURRENTLY if missing.
|
|
*/
|
|
|
|
import type { BrainEngine } from './engine.ts';
|
|
import { isStatementTimeoutError, isRetryableConnError } from './retry-matcher.ts';
|
|
|
|
export interface BackfillSpec<TRow = Record<string, unknown>> {
|
|
/** Stable identifier — used in checkpoint key + CLI dispatch. */
|
|
name: string;
|
|
/** Postgres table name (used in keyset query). */
|
|
table: string;
|
|
/**
|
|
* Primary-key column name. Keyset pagination uses `WHERE id > $lastId
|
|
* ORDER BY id LIMIT $batchSize` — column name controls both ORDER BY
|
|
* and the comparison. Defaults to 'id'.
|
|
*/
|
|
idColumn?: string;
|
|
/**
|
|
* Columns to select for `compute()`. The id column is always included.
|
|
*/
|
|
selectColumns: string[];
|
|
/**
|
|
* SQL fragment for `WHERE` (without `WHERE`). Names the un-backfilled rows.
|
|
* E.g. "effective_date IS NULL" or "embedding_voyage IS NULL".
|
|
*/
|
|
needsBackfill: string;
|
|
/**
|
|
* Compute updates for a batch of rows. Returns one entry per row that
|
|
* needs updating; rows not present in the result are unchanged.
|
|
*/
|
|
compute: (rows: TRow[], engine: BrainEngine) => Promise<Array<{ id: number; updates: Record<string, unknown> }>>;
|
|
/**
|
|
* Optional partial-index requirement (P2 / X4). Runner verifies/creates
|
|
* the index CONCURRENTLY on first run. Skipped on PGLite (no CONCURRENTLY).
|
|
*/
|
|
requiredIndex?: { name: string; sql: string };
|
|
/** Estimate of rows-per-second for ETA reporting. Pure-display. */
|
|
estimateRowsPerSecond?: number;
|
|
}
|
|
|
|
export interface BackfillRunOpts {
|
|
/** Hard cap on total rows touched (testing). Undefined = no cap. */
|
|
maxRows?: number;
|
|
/** Initial batch size before adaptive halving. Default 1000. */
|
|
batchSize?: number;
|
|
/** Skip checkpoint, restart from id=0. Default false. */
|
|
fresh?: boolean;
|
|
/** Don't write; report what WOULD happen. Default false. */
|
|
dryRun?: boolean;
|
|
/** Per-batch progress callback. */
|
|
onBatch?: (info: BackfillProgress) => void;
|
|
/** Bail after N total errors. Default 200. */
|
|
maxErrors?: number;
|
|
/**
|
|
* Per-batch statement timeout in seconds. Routed via SET LOCAL inside
|
|
* the reserved-connection transaction. Default 600 (10min). Smaller
|
|
* values fail fast; larger values let the runner do more work per batch.
|
|
*/
|
|
perBatchTimeoutSec?: number;
|
|
}
|
|
|
|
export interface BackfillProgress {
|
|
batch: number;
|
|
rowsThisBatch: number;
|
|
cumulative: number;
|
|
lastId: number;
|
|
errorsSeen: number;
|
|
effectiveBatchSize: number;
|
|
}
|
|
|
|
export interface BackfillResult {
|
|
examined: number;
|
|
updated: number;
|
|
errors: number;
|
|
lastId: number;
|
|
durationSec: number;
|
|
/** True iff `maxRows` capped the run (more rows remain). */
|
|
cappedByMaxRows: boolean;
|
|
/** True iff `maxErrors` bailed the run. */
|
|
cappedByErrors: boolean;
|
|
}
|
|
|
|
const DEFAULT_BATCH_SIZE = 1000;
|
|
const DEFAULT_MAX_ERRORS = 200;
|
|
const DEFAULT_PER_BATCH_TIMEOUT_SEC = 600;
|
|
const MIN_BATCH_SIZE = 16;
|
|
|
|
function checkpointKey(name: string): string {
|
|
return `backfill.${name}.last_id`;
|
|
}
|
|
|
|
async function getCheckpoint(engine: BrainEngine, name: string, fresh: boolean): Promise<number> {
|
|
if (fresh) return 0;
|
|
try {
|
|
const rows = await engine.executeRaw<{ value: string }>(
|
|
`SELECT value FROM config WHERE key = $1 LIMIT 1`,
|
|
[checkpointKey(name)],
|
|
);
|
|
if (rows.length === 0) return 0;
|
|
const n = Number(rows[0].value);
|
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function setCheckpoint(engine: BrainEngine, name: string, lastId: number): Promise<void> {
|
|
await engine.setConfig(checkpointKey(name), String(lastId));
|
|
}
|
|
|
|
/**
|
|
* Verify or create the partial index a backfill declares. Postgres-only
|
|
* (PGLite ignores CONCURRENTLY anyway, and partial-index is not always
|
|
* supported). Returns false if the index is missing AND we couldn't create
|
|
* it (caller decides whether to bail).
|
|
*/
|
|
export async function ensureBackfillIndex<TRow>(
|
|
engine: BrainEngine,
|
|
spec: BackfillSpec<TRow>,
|
|
): Promise<{ existed: boolean; created: boolean }> {
|
|
if (engine.kind !== 'postgres' || !spec.requiredIndex) {
|
|
return { existed: true, created: false };
|
|
}
|
|
const { name, sql } = spec.requiredIndex;
|
|
try {
|
|
const rows = await engine.executeRaw<{ exists: boolean }>(
|
|
`SELECT EXISTS(SELECT 1 FROM pg_indexes WHERE indexname = $1) AS exists`,
|
|
[name],
|
|
);
|
|
if (rows[0]?.exists) return { existed: true, created: false };
|
|
// Create the index. CONCURRENTLY can't run inside a transaction, so we
|
|
// route via the reserved connection and let the engine handle txn
|
|
// semantics directly.
|
|
await engine.withReservedConnection(async conn => {
|
|
await conn.executeRaw(sql);
|
|
});
|
|
return { existed: false, created: true };
|
|
} catch (err) {
|
|
process.stderr.write(`[backfill] index creation failed: ${(err as Error).message}; will continue without partial index\n`);
|
|
return { existed: false, created: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a backfill end-to-end. Honors the checkpoint, halves on timeout,
|
|
* reconnects on conn drop, bails on max errors.
|
|
*/
|
|
export async function runBackfill<TRow = Record<string, unknown>>(
|
|
engine: BrainEngine,
|
|
spec: BackfillSpec<TRow>,
|
|
opts: BackfillRunOpts = {},
|
|
): Promise<BackfillResult> {
|
|
const t0 = Date.now();
|
|
const idCol = spec.idColumn ?? 'id';
|
|
const cols = [idCol, ...spec.selectColumns.filter(c => c !== idCol)];
|
|
const maxErrors = opts.maxErrors ?? DEFAULT_MAX_ERRORS;
|
|
const perBatchTimeoutSec = opts.perBatchTimeoutSec ?? DEFAULT_PER_BATCH_TIMEOUT_SEC;
|
|
|
|
// X4 / P2: verify/create the partial index up-front when declared.
|
|
if (spec.requiredIndex) {
|
|
await ensureBackfillIndex(engine, spec);
|
|
}
|
|
|
|
let batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
let lastId = await getCheckpoint(engine, spec.name, opts.fresh === true);
|
|
let examined = 0;
|
|
let updated = 0;
|
|
let errors = 0;
|
|
let batchNum = 0;
|
|
|
|
while (true) {
|
|
const remaining = opts.maxRows ? Math.max(0, opts.maxRows - examined) : Number.POSITIVE_INFINITY;
|
|
if (remaining <= 0) {
|
|
return {
|
|
examined, updated, errors, lastId,
|
|
durationSec: (Date.now() - t0) / 1000,
|
|
cappedByMaxRows: true, cappedByErrors: false,
|
|
};
|
|
}
|
|
const effective = Math.min(batchSize, remaining);
|
|
|
|
let rows: TRow[];
|
|
try {
|
|
rows = await engine.executeRaw<TRow>(
|
|
`SELECT ${cols.join(', ')} FROM ${spec.table}
|
|
WHERE ${idCol} > $1 AND (${spec.needsBackfill})
|
|
ORDER BY ${idCol}
|
|
LIMIT $2`,
|
|
[lastId, effective],
|
|
);
|
|
} catch (err) {
|
|
errors++;
|
|
if (errors >= maxErrors) {
|
|
return {
|
|
examined, updated, errors, lastId,
|
|
durationSec: (Date.now() - t0) / 1000,
|
|
cappedByMaxRows: false, cappedByErrors: true,
|
|
};
|
|
}
|
|
// Connection drop: brief sleep + retry the same window.
|
|
if (isRetryableConnError(err)) {
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
// No more rows match the predicate. Done.
|
|
return {
|
|
examined, updated, errors, lastId,
|
|
durationSec: (Date.now() - t0) / 1000,
|
|
cappedByMaxRows: false, cappedByErrors: false,
|
|
};
|
|
}
|
|
examined += rows.length;
|
|
batchNum++;
|
|
|
|
let computedUpdates: Array<{ id: number; updates: Record<string, unknown> }>;
|
|
try {
|
|
computedUpdates = await spec.compute(rows, engine);
|
|
} catch (err) {
|
|
errors++;
|
|
if (errors >= maxErrors) break;
|
|
if (isStatementTimeoutError(err)) {
|
|
batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(batchSize / 2));
|
|
process.stderr.write(`[backfill:${spec.name}] compute timeout; halving batch to ${batchSize}\n`);
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// T3 fix: writes go through withReservedConnection so BEGIN / SET LOCAL
|
|
// / UPDATE / COMMIT all happen on the same backend. Without this,
|
|
// pooled executeRaw can land BEGIN on backend-A and UPDATE on backend-B
|
|
// and SET LOCAL evaporates.
|
|
if (!opts.dryRun && computedUpdates.length > 0) {
|
|
try {
|
|
await engine.withReservedConnection(async conn => {
|
|
await conn.executeRaw(`BEGIN`);
|
|
try {
|
|
if (engine.kind === 'postgres') {
|
|
await conn.executeRaw(`SET LOCAL statement_timeout = '${perBatchTimeoutSec}s'`).catch(() => {
|
|
/* some Postgres tiers restrict SET LOCAL; falls through */
|
|
});
|
|
}
|
|
for (const { id, updates } of computedUpdates) {
|
|
const setClauses: string[] = [];
|
|
const params: unknown[] = [id];
|
|
let paramIdx = 2;
|
|
for (const [col, val] of Object.entries(updates)) {
|
|
setClauses.push(`${col} = $${paramIdx}`);
|
|
params.push(val);
|
|
paramIdx++;
|
|
}
|
|
if (setClauses.length === 0) continue;
|
|
await conn.executeRaw(
|
|
`UPDATE ${spec.table} SET ${setClauses.join(', ')} WHERE ${idCol} = $1`,
|
|
params,
|
|
);
|
|
updated++;
|
|
}
|
|
await conn.executeRaw(`COMMIT`);
|
|
} catch (err) {
|
|
await conn.executeRaw(`ROLLBACK`).catch(() => {});
|
|
throw err;
|
|
}
|
|
});
|
|
} catch (err) {
|
|
errors++;
|
|
if (errors >= maxErrors) break;
|
|
if (isStatementTimeoutError(err)) {
|
|
batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(batchSize / 2));
|
|
process.stderr.write(`[backfill:${spec.name}] write timeout; halving batch to ${batchSize}\n`);
|
|
continue;
|
|
}
|
|
if (isRetryableConnError(err)) {
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Advance the checkpoint to the highest id we examined this batch.
|
|
const idAccessor = idCol;
|
|
const lastBatchId = (rows[rows.length - 1] as Record<string, unknown>)[idAccessor];
|
|
if (typeof lastBatchId === 'number') lastId = lastBatchId;
|
|
if (!opts.dryRun) await setCheckpoint(engine, spec.name, lastId);
|
|
|
|
opts.onBatch?.({
|
|
batch: batchNum,
|
|
rowsThisBatch: rows.length,
|
|
cumulative: examined,
|
|
lastId,
|
|
errorsSeen: errors,
|
|
effectiveBatchSize: effective,
|
|
});
|
|
}
|
|
|
|
return {
|
|
examined, updated, errors, lastId,
|
|
durationSec: (Date.now() - t0) / 1000,
|
|
cappedByMaxRows: false, cappedByErrors: errors >= maxErrors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear the checkpoint for a backfill. Used by --fresh + after manual reset.
|
|
*/
|
|
export async function clearBackfillCheckpoint(engine: BrainEngine, name: string): Promise<void> {
|
|
try {
|
|
await engine.executeRaw(`DELETE FROM config WHERE key = $1`, [checkpointKey(name)]);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|