Files
simstudioai--sim/apps/sim/lib/table/update-runner.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

197 lines
7.4 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import type { Filter, RowData } from '@/lib/table'
import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { appendTableEvent } from '@/lib/table/events'
import {
getJobProgress,
markJobFailed,
markJobReady,
updateJobProgress,
} from '@/lib/table/jobs/service'
import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering'
import { getTableById } from '@/lib/table/service'
import { buildFilterClause } from '@/lib/table/sql'
import { coerceRowToSchema, coerceRowValues, validateRowSize } from '@/lib/table/validation'
const logger = createLogger('TableUpdateRunner')
/** Emit a progress event / heartbeat at most every this many rows. */
const PROGRESS_INTERVAL_ROWS = 5000
/**
* Thrown when this worker discovers it no longer owns the table's job (canceled, or the
* stale-job janitor marked it failed and a newer job took over). The worker stops updating.
*/
class JobSupersededError extends Error {}
export interface TableUpdatePayload {
jobId: string
tableId: string
workspaceId: string
/** Rows matching this filter get the patch. */
filter: Filter
/** Column-id-keyed partial patch merged into every matched row. */
data: RowData
/** Only rows created at/before this instant are patched, so mid-job inserts are spared. */
cutoff: Date
/** Stop after updating this many rows (an explicit caller-supplied limit). Omitted = every match. */
maxRows?: number
}
/**
* Background worker for large filtered row updates (trigger.dev task, or detached on the web
* container when trigger.dev is disabled — see the update dispatch in the user_table tool).
* Applies the same `data` patch (JSONB merge) to every row matching `filter` with
* `created_at <= cutoff`, in keyset-paginated pages. Each page validates the merged result per
* row, then commits in batches — **best-effort, not atomic**: committed pages persist even if a
* later page fails validation (unlike the inline `updateRowsByFilter`, which pre-validates all
* rows in one transaction). Reads are not masked: updated rows still exist, so mid-job reads are
* eventually consistent. Ownership-gated per page so a cancel/supersede stops within one page.
*
* Unlike the inline path, the worker does NOT fire per-row table triggers or auto-recompute
* workflow/enrichment columns — that would be a runaway cascade across thousands of rows. Run
* the affected columns explicitly afterward if downstream recompute is needed.
*
* Unexpected errors are rethrown for the caller's retry machinery; the caller marks the job
* failed via `markTableUpdateFailed`. A superseded run returns quietly.
*/
export async function runTableUpdate(payload: TableUpdatePayload): Promise<void> {
const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = payload
const requestId = generateId().slice(0, 8)
const budget = maxRows ?? Number.POSITIVE_INFINITY
try {
const table = await getTableById(tableId, { includeArchived: true })
if (!table) throw new Error(`Update target table ${tableId} not found`)
const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns)
if (!filterClause) throw new Error('Filter is required for bulk update')
// Coerce the patch once to the schema's types — the merged validation below and the persisted
// JSONB merge both use this normalized copy.
coerceRowValues(data, table.schema)
const patchJson = JSON.stringify(data)
// Resume the persisted count: a retried attempt's earlier pages are already committed, so
// starting at zero would overwrite cumulative progress. Doubles as the initial ownership gate.
const resumed = await getJobProgress(tableId, jobId)
if (resumed === null) throw new JobSupersededError()
let processed = resumed
let lastReported = resumed
let afterId: string | undefined
while (processed < budget) {
const owns = await updateJobProgress(tableId, processed, jobId)
if (!owns) throw new JobSupersededError()
const page = await selectRowDataPage({
tableId,
workspaceId,
cutoff,
filterClause,
afterId,
limit: Math.min(TABLE_LIMITS.DELETE_PAGE_SIZE, budget - processed),
// Skip rows already carrying the patch so a retried run resumes without re-walking /
// double-counting the rows an earlier attempt updated (updated rows still exist and may
// still match the filter, unlike deletes).
excludeIfPatched: patchJson,
})
if (page.length === 0) break
afterId = page[page.length - 1].id
// Validate each merged result before writing the page — a row that would overflow the size
// cap or violate the schema fails the job (earlier pages stay applied; best-effort).
for (const row of page) {
const merged = { ...row.data, ...data }
const sizeValidation = validateRowSize(merged)
if (!sizeValidation.valid) {
throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`)
}
const schemaValidation = coerceRowToSchema(merged, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`)
}
}
processed += await updatePageByIds(
tableId,
workspaceId,
page.map((r) => r.id),
patchJson
)
if (
processed - lastReported >= PROGRESS_INTERVAL_ROWS ||
(lastReported === 0 && processed > 0)
) {
lastReported = processed
void appendTableEvent({
kind: 'job',
type: 'update',
tableId,
jobId,
status: 'running',
progress: processed,
})
}
}
await updateJobProgress(tableId, processed, jobId)
const becameReady = await markJobReady(tableId, jobId)
if (becameReady) {
void appendTableEvent({
kind: 'job',
type: 'update',
tableId,
jobId,
status: 'ready',
progress: processed,
})
logger.info(`[${requestId}] Update complete`, { tableId, rows: processed })
} else {
logger.info(
`[${requestId}] Update finished but no longer owns the run (canceled/superseded)`,
{
tableId,
jobId,
}
)
}
} catch (err) {
if (err instanceof JobSupersededError) {
logger.info(`[${requestId}] Update superseded by a newer run; stopping`, { tableId, jobId })
return
}
const cause = toError(err).cause
const error = cause ? toError(cause) : toError(err)
logger.error(`[${requestId}] Update failed for table ${tableId}:`, error)
throw error
}
}
/**
* Marks the update job failed and emits the failed SSE event. Called once the caller gives up on
* the run (trigger.dev `onFailure` after retries, or the detached fallback). Scoped to jobId — a
* no-op if a newer job has taken over.
*/
export async function markTableUpdateFailed(
tableId: string,
jobId: string,
error: unknown
): Promise<void> {
const message = truncate(getErrorMessage(toError(error).cause ?? error, 'Update failed'), 500)
await markJobFailed(tableId, jobId, message).catch(() => {})
void appendTableEvent({
kind: 'job',
type: 'update',
tableId,
jobId,
status: 'failed',
error: message,
})
}