Files
simstudioai--sim/apps/sim/lib/table/import-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

394 lines
15 KiB
TypeScript

import { type Readable, Transform } from 'node:stream'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { captureServerEvent } from '@/lib/posthog/server'
import {
buildAutoMapping,
CSV_MAX_BATCH_SIZE,
CSV_SCHEMA_SAMPLE_SIZE,
type CsvHeaderMapping,
coerceRowsForTable,
createCsvParser,
inferColumnType,
inferSchemaFromCsv,
sanitizeName,
type TableSchema,
validateMapping,
} from '@/lib/table'
import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing'
import { withGeneratedColumnIds } from '@/lib/table/column-keys'
import { appendTableEvent } from '@/lib/table/events'
import {
addImportColumns,
bulkInsertImportBatch,
deleteAllTableRows,
setTableSchemaForImport,
} from '@/lib/table/import-data'
import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service'
import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering'
import { getTableById } from '@/lib/table/service'
import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service'
import { normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableImportRunner')
/** Emit a progress event / DB update at most every this many rows. */
const PROGRESS_INTERVAL_ROWS = 5000
/**
* Thrown when this worker discovers it no longer owns the table's import (the stale-job janitor
* marked its run failed and a newer import took over). The worker stops inserting rather than
* writing into a table a second worker now owns.
*/
class ImportSupersededError extends Error {}
/** `create` infers a schema for a new table; `append`/`replace` map onto an existing one. */
export type TableImportMode = 'create' | 'append' | 'replace'
export interface TableImportPayload {
importId: string
tableId: string
workspaceId: string
userId: string
/** Storage key of the already-uploaded CSV/TSV file. */
fileKey: string
fileName: string
delimiter: ',' | '\t'
mode: TableImportMode
/** (append/replace) Explicit CSV-header → column mapping; auto-mapped when omitted. */
mapping?: CsvHeaderMapping
/** (append/replace) CSV headers to auto-create as new columns (types inferred from the sample). */
createColumns?: string[]
/**
* Whether the source object is deleted once the import is terminal. Defaults
* to true (the UI routes upload a single-use temp object per import); pass
* false when importing a persistent workspace file (Mothership) that must
* survive the import.
*/
deleteSourceFile?: boolean
/**
* IANA zone used to interpret naive datetime strings in the file. The
* kickoff routes resolve it (request → user setting → UTC) so the detached
* worker never needs a settings lookup.
*/
timezone?: string
}
/**
* Background worker for large CSV/TSV imports. Runs detached on the web container
* (see the kickoff routes). Streams the stored file through `createCsvParser`, resolves
* the target schema + header→column mapping from the first sample (inferring a new schema
* for `create`, mapping onto the existing schema for `append`/`replace`), then bulk-inserts
* in committed batches — **no rollback**: committed batches persist even if a later batch
* fails. Progress and the terminal state are surfaced via the table-events SSE stream.
*/
export async function runTableImport(payload: TableImportPayload): Promise<void> {
const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload
const requestId = generateId().slice(0, 8)
// Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks
// open until it times out.
let source: Readable | undefined
try {
const loaded = await getTableById(tableId, { includeArchived: true })
if (!loaded) throw new Error(`Import target table ${tableId} not found`)
const table = loaded
// Total byte size for the progress estimate — a cheap HEAD, no download. May be null on
// the local dev provider, in which case the bar stays indeterminate (rows still show).
const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0
// Stream the file rather than buffering it — a ~1M-row import must never be held in memory.
source = await downloadFileStream({ key: fileKey, context: 'workspace' })
// Append must continue after the existing rows; create/replace start empty. Read once up
// front (the import is the table's sole writer) and assign contiguous positions / threaded
// order keys from it.
const basePosition = mode === 'append' ? await nextImportStartPosition(tableId) : 0
let lastOrderKey = mode === 'append' ? await nextImportStartOrderKey(tableId) : null
// Append keeps the existing rows; create/replace start from empty (replace deletes
// existing rows in resolveSetup). Per-batch capacity is checked against this base + the
// running total, so a stream that crosses the plan limit fails within one batch.
const existingRowCount = mode === 'append' ? table.rowCount : 0
// Count bytes as they flow so the row total can be extrapolated from byte progress.
let bytesRead = 0
const byteCounter = new Transform({
transform(chunk: Buffer, _enc, cb) {
bytesRead += chunk.length
cb(null, chunk)
},
})
const parser = createCsvParser(delimiter)
// `.pipe` doesn't forward source errors; forward so the iterator throws.
source.on('error', (err) => parser.destroy(err))
byteCounter.on('error', (err) => parser.destroy(err))
source.pipe(byteCounter).pipe(parser)
let schema: TableSchema | null = null
let headerToColumn: Map<string, string> | null = null
let inserted = 0
let lastReported = 0
const sample: Record<string, unknown>[] = []
let batch: Record<string, unknown>[] = []
/**
* Resolve the schema + header→column mapping from the buffered sample (runs once).
* `create` infers a fresh schema and overwrites the placeholder; `append`/`replace`
* map onto the existing schema, optionally auto-creating `createColumns` first.
*/
const resolveSetup = async () => {
const headers = Object.keys(sample[0])
if (mode === 'create') {
const inferred = inferSchemaFromCsv(headers, sample)
// Stamp ids so the imported table is id-native (rows coerce + persist by
// the same ids).
schema = withGeneratedColumnIds({ columns: inferred.columns.map(normalizeColumn) })
headerToColumn = inferred.headerToColumn
await setTableSchemaForImport(tableId, schema)
return
}
// append / replace into an existing table.
let targetSchema = table.schema
let effectiveMapping: CsvHeaderMapping =
payload.mapping ?? buildAutoMapping(headers, table.schema)
if (payload.createColumns && payload.createColumns.length > 0) {
const unknown = payload.createColumns.filter((h) => !headers.includes(h))
if (unknown.length > 0) {
throw new Error(`Columns to create are not in the CSV: ${unknown.join(', ')}`)
}
const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase()))
const additions: { name: string; type: string }[] = []
const updatedMapping: CsvHeaderMapping = { ...effectiveMapping }
for (const header of payload.createColumns) {
const base = sanitizeName(header)
let columnName = base
let suffix = 2
while (usedNames.has(columnName.toLowerCase())) {
columnName = `${base}_${suffix}`
suffix++
}
usedNames.add(columnName.toLowerCase())
additions.push({ name: columnName, type: inferColumnType(sample.map((r) => r[header])) })
updatedMapping[header] = columnName
}
const updated = await addImportColumns(table, additions, requestId, userId)
targetSchema = updated.schema
effectiveMapping = updatedMapping
}
const validation = validateMapping({
csvHeaders: headers,
mapping: effectiveMapping,
tableSchema: targetSchema,
})
schema = targetSchema
headerToColumn = validation.effectiveMap
// Replace deletes existing rows only after schema/mapping validation passes, so an
// invalid or empty file fails the import with the old rows still intact (a mid-stream
// insert failure after this point leaves a partial replace — replace is destructive).
if (mode === 'replace') await deleteAllTableRows(tableId)
}
const flush = async (rows: Record<string, unknown>[]) => {
if (rows.length === 0 || !schema || !headerToColumn) return
// Ownership gate before every insert: once this run loses the table (cancel/supersede),
// updateJobProgress returns false and we stop before writing into a table a newer import
// may own. Runs per batch (not just at the emit cadence) so we stop within one batch.
const owns = await updateJobProgress(tableId, inserted, importId)
if (!owns) throw new ImportSupersededError()
const coerced = coerceRowsForTable(rows, schema, headerToColumn, {
timezone: payload.timezone,
})
const rowLimit = await assertRowCapacity({
workspaceId,
currentRowCount: existingRowCount + inserted,
addedRows: coerced.length,
})
const result = await bulkInsertImportBatch(
{
tableId,
workspaceId,
userId,
rows: coerced,
startPosition: basePosition + inserted,
afterOrderKey: lastOrderKey,
},
{ ...table, schema },
requestId
)
notifyTableRowUsage({
workspaceId,
currentRowCount: existingRowCount + inserted,
addedRows: result.inserted,
limit: rowLimit,
})
inserted += result.inserted
lastOrderKey = result.lastOrderKey
// Emit after the first batch, then every interval, so the bar appears early without flooding.
if (
inserted - lastReported >= PROGRESS_INTERVAL_ROWS ||
(lastReported === 0 && inserted > 0)
) {
lastReported = inserted
// Exact, monotonic completion from bytes consumed — no wobbly row estimate.
const percent =
totalBytes > 0 ? Math.min(99, Math.round((bytesRead / totalBytes) * 100)) : undefined
void appendTableEvent({
kind: 'job',
type: 'import',
tableId,
jobId: importId,
status: 'running',
progress: inserted,
percent,
})
}
}
let ready = false
for await (const record of parser as AsyncIterable<Record<string, unknown>>) {
if (!ready) {
sample.push(record)
if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
await resolveSetup()
await flush(sample)
ready = true
}
continue
}
batch.push(record)
if (batch.length >= CSV_MAX_BATCH_SIZE) {
await flush(batch)
batch = []
}
}
if (!ready) {
// Fewer than CSV_SCHEMA_SAMPLE_SIZE rows total (or zero).
if (sample.length === 0) {
// No data rows — fail rather than report a successful empty import (matches the sync route).
const message = 'CSV file has no data rows'
await markJobFailed(tableId, importId, message)
void appendTableEvent({
kind: 'job',
type: 'import',
tableId,
jobId: importId,
status: 'failed',
error: message,
})
captureServerEvent(
userId,
'table_import_completed',
{
table_id: tableId,
workspace_id: workspaceId,
import_id: importId,
status: 'failed',
row_count: null,
error_message: truncate(message, 200),
},
{ groups: { workspace: workspaceId } }
)
logger.warn(`[${requestId}] Import has no data rows`, { tableId, fileName })
return
}
await resolveSetup()
await flush(sample)
} else {
await flush(batch)
}
await updateJobProgress(tableId, inserted, importId)
// Only announce success if we actually won the transition — a cancel/supersede that landed
// right at the end makes this a no-op, and we must not emit a false `ready`.
const becameReady = await markJobReady(tableId, importId)
if (becameReady) {
void appendTableEvent({
kind: 'job',
type: 'import',
tableId,
jobId: importId,
status: 'ready',
progress: inserted,
percent: 100,
})
captureServerEvent(
userId,
'table_import_completed',
{
table_id: tableId,
workspace_id: workspaceId,
import_id: importId,
status: 'completed',
row_count: inserted,
},
{ groups: { workspace: workspaceId } }
)
logger.info(`[${requestId}] Import complete`, { tableId, fileName, mode, rows: inserted })
} else {
logger.info(
`[${requestId}] Import finished but no longer owns the run (canceled/superseded)`,
{
tableId,
importId,
}
)
}
} catch (err) {
if (err instanceof ImportSupersededError) {
// A newer import owns the table now — leave its status alone and just stop.
logger.info(`[${requestId}] Import superseded by a newer run; stopping`, {
tableId,
importId,
})
} else {
const message = getErrorMessage(err, 'Import failed')
logger.error(`[${requestId}] Import failed for table ${tableId}:`, err)
// Scoped to importId — a no-op if a newer import has taken over.
await markJobFailed(tableId, importId, message).catch(() => {})
void appendTableEvent({
kind: 'job',
type: 'import',
tableId,
jobId: importId,
status: 'failed',
error: message,
})
captureServerEvent(
userId,
'table_import_completed',
{
table_id: tableId,
workspace_id: workspaceId,
import_id: importId,
status: 'failed',
row_count: null,
error_message: truncate(message, 200),
},
{ groups: { workspace: workspaceId } }
)
}
} finally {
// Release the storage stream so its HTTP connection doesn't leak on failure.
source?.destroy()
// The uploaded source file is single-use (a fresh upload per import) — delete it once the
// import is terminal so the workspace bucket doesn't accumulate. Best-effort. Skipped for
// persistent workspace files (deleteSourceFile: false).
if (payload.deleteSourceFile !== false) {
await deleteFile({ key: fileKey, context: 'workspace' }).catch((err) => {
logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, err })
})
}
}
}