Files
simstudioai--sim/apps/sim/scripts/backfill-trace-spans.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

189 lines
6.5 KiB
TypeScript

#!/usr/bin/env bun
/**
* One-shot, idempotent, resumable backfill that externalizes inline heavy
* `execution_data` (traceSpans, finalOutput, workflowInput, ...) into the
* execution-context large-value store, matching the completion path (cost-stripped
* spans, trace pointer + markers, owner/dependency + execution_log reference
* registration). Skips running rows and rows already carrying the pointer.
*
* Requires object storage to be configured; self-hosted deployments without it
* keep `execution_data` inline (reads resolve inline transparently) and can skip
* this script entirely.
*
* NOTE: the companion `cost_total` / `models_used` backfill is done in SQL by
* migration 0220 (batched, idempotent), so it runs for everyone — including
* self-hosted — and is intentionally NOT part of this script.
*
* Usage:
* DATABASE_URL=... bun apps/sim/scripts/backfill-trace-spans.ts [--max-batches=<n>]
*/
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { toError } from '@sim/utils/errors'
import { and, asc, eq, gt, sql } from 'drizzle-orm'
import {
collectLargeValueReferenceKeys,
replaceLargeValueReferenceKeysWithClient,
} from '@/lib/execution/payloads/large-value-metadata'
import {
externalizeExecutionData,
stripSpanCosts,
TRACE_STORE_REF_KEY,
} from '@/lib/logs/execution/trace-store'
const TRACE_BATCH_SIZE = 100
/**
* Recursively counts trace spans (matching the completion path). Legacy rows
* predate the inline hasTraceSpans/traceSpanCount markers, so we derive them
* before externalizing — otherwise a post-expiry degraded read can't report
* "trace data expired (N spans)".
*/
function countTraceSpans(spans: unknown): number {
if (!Array.isArray(spans)) return 0
return spans.reduce(
(count: number, span) =>
count + 1 + countTraceSpans((span as { children?: unknown } | null)?.children),
0
)
}
interface Options {
maxBatches: number
}
function parseArgs(argv: string[]): Options {
const maxBatchesArg = argv.find((a) => a.startsWith('--max-batches='))
const maxBatches = maxBatchesArg
? Number.parseInt(maxBatchesArg.slice('--max-batches='.length), 10)
: Number.POSITIVE_INFINITY
if (Number.isNaN(maxBatches) || maxBatches <= 0) {
throw new Error('--max-batches must be a positive integer')
}
return { maxBatches }
}
/** Externalize inline heavy execution_data into the large-value store. */
async function backfillTraceStorage(
maxBatches: number
): Promise<{ migrated: number; failed: number }> {
let migrated = 0
let failed = 0
// Keyset cursor by id: every row is visited at most once per run, so rows that
// can't be externalized (storage error, oversized) aren't re-selected into an
// infinite loop. A fresh re-run (cursor reset) retries any that failed.
let lastId = ''
for (let batch = 0; batch < maxBatches; batch++) {
const rows = await db
.select({
id: workflowExecutionLogs.id,
workspaceId: workflowExecutionLogs.workspaceId,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
executionData: workflowExecutionLogs.executionData,
})
.from(workflowExecutionLogs)
.where(
and(
sql`${workflowExecutionLogs.endedAt} IS NOT NULL`,
// Skip deleted-workflow rows: externalization requires a workflowId.
sql`${workflowExecutionLogs.workflowId} IS NOT NULL`,
sql`${workflowExecutionLogs.executionData} ? 'traceSpans'`,
sql`NOT (${workflowExecutionLogs.executionData} ? ${TRACE_STORE_REF_KEY})`,
lastId ? gt(workflowExecutionLogs.id, lastId) : undefined
)
)
.orderBy(asc(workflowExecutionLogs.id))
.limit(TRACE_BATCH_SIZE)
if (rows.length === 0) break
for (const row of rows) {
try {
const executionData = (row.executionData ?? {}) as Record<string, unknown>
// Derive the inline markers legacy rows lack so externalizeExecutionData
// carries them onto the slim row (they survive object expiry).
const traceSpanCount = countTraceSpans(executionData.traceSpans)
executionData.hasTraceSpans = traceSpanCount > 0
executionData.traceSpanCount = traceSpanCount
stripSpanCosts(executionData.traceSpans)
// workspace_files.user_id (NOT NULL) needs the execution owner; legacy
// rows carry it under executionData.environment.userId. Rows without an
// owner can't be externalized — count them as failed and skip.
const environment = executionData.environment as { userId?: string } | undefined
const ownerUserId = environment?.userId
if (!ownerUserId) {
failed++
continue
}
const slim = await externalizeExecutionData(executionData, {
workspaceId: row.workspaceId,
workflowId: row.workflowId,
executionId: row.executionId,
userId: ownerUserId,
})
if (!(TRACE_STORE_REF_KEY in slim)) {
failed++
continue
}
await db.transaction(async (tx) => {
await tx
.update(workflowExecutionLogs)
.set({ executionData: slim })
.where(eq(workflowExecutionLogs.id, row.id))
await replaceLargeValueReferenceKeysWithClient(
tx,
{
workspaceId: row.workspaceId,
workflowId: row.workflowId,
executionId: row.executionId,
source: 'execution_log',
},
collectLargeValueReferenceKeys(slim)
)
})
migrated++
} catch (error) {
failed++
console.error(` [trace] row ${row.id} failed: ${toError(error).message}`)
}
}
// Advance the cursor past this batch so failed rows aren't re-selected.
lastId = rows[rows.length - 1].id
console.log(` [trace] batch ${batch + 1}: migrated ${migrated}, failed ${failed}`)
}
return { migrated, failed }
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2))
const startedAt = Date.now()
console.log('Backfilling trace storage (externalizing execution_data)…')
const { migrated, failed } = await backfillTraceStorage(options.maxBatches)
console.log(`Trace storage done: ${migrated} migrated, ${failed} skipped/failed.`)
console.log(`Backfill complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s.`)
}
main()
.catch((err) => {
console.error('Backfill failed:', err)
process.exit(1)
})
.finally(() => {
process.exit(0)
})