Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

172 lines
6.8 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { appendTableEvent } from '@/lib/table/events'
import {
formatCsvCell,
neutralizeCsvFormula,
sanitizeExportFilename,
toCsvRow,
} from '@/lib/table/export-format'
import {
markJobFailed,
markJobReady,
selectExportRowPage,
setJobResultKey,
updateJobProgress,
} from '@/lib/table/jobs/service'
import { getTableById } from '@/lib/table/service'
import {
createMultipartUpload,
deleteFile,
type MultipartUploadHandle,
} from '@/lib/uploads/core/storage-service'
const logger = createLogger('TableExportRunner')
/** Rows per page while building the file. Internal caller — not bound by MAX_QUERY_LIMIT; rows
* are fetched without executions, so even wide rows stay a few MB per batch. */
const EXPORT_BATCH_SIZE = 5000
/** Thrown when this worker loses the job (canceled / janitor-failed). */
class JobSupersededError extends Error {}
export interface TableExportPayload {
jobId: string
tableId: string
workspaceId: string
format: 'csv' | 'json'
}
/**
* Background worker for large table exports. Pages rows via `queryRows` (so the delete-job
* visibility mask applies — an export taken mid-delete excludes the doomed rows), accumulates the
* serialized file, uploads it to workspace storage, and stamps the storage key onto the job's
* payload (`resultKey`). The client downloads via a presigned URL from the download route; the
* janitor deletes the file when the terminal job is pruned. Ownership-gated per batch, so a
* cancel stops it within one page. Retry-safe: a retried attempt regenerates the file from
* scratch and overwrites nothing (fresh key per attempt; failures clean up their partial upload).
*/
export async function runTableExport(payload: TableExportPayload): Promise<void> {
const { jobId, tableId, workspaceId, format } = payload
const requestId = generateId().slice(0, 8)
let handle: MultipartUploadHandle | null = null
let uploadedKey: string | null = null
try {
const table = await getTableById(tableId, { includeArchived: true })
if (!table) throw new Error(`Export target table ${tableId} not found`)
const columns = table.schema.columns
// Stored row data is id-keyed and select cells hold option ids; JSON keys are display
// names and values are option names, so translate both on the way out (export is a
// name-friendly boundary). Hoisted: the mapper is reused across every streamed page.
const toNamedRow = namedRowMapper(columns)
const fileName = `${sanitizeExportFilename(table.name)}.${format}`
// The key is pinned up front so the streaming upload writes exactly where the download
// route presigns; the *returned* key (from `complete`) is recorded as the source of truth.
const key = `workspace/${workspaceId}/exports/${tableId}/${jobId}/${fileName}`
const contentType = format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json'
// Stream the serialized file straight into storage in bounded parts instead of buffering the
// whole thing in heap — a 1M-row export no longer holds hundreds of MB resident.
handle = await createMultipartUpload({
key,
context: 'workspace',
contentType,
completionPolicy: 'replace',
})
await handle.write(
format === 'csv' ? `${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n` : '['
)
let exported = 0
let firstJsonRow = true
// `order_key` is nullable (rows predating the backfill), and the page query
// seeks NULLs explicitly — so the cursor has to carry a null too.
let after: { orderKey: string | null; id: string } | null = null
while (true) {
// Ownership gate before every page: a canceled job stops within one batch.
const owns = await updateJobProgress(tableId, exported, jobId)
if (!owns) throw new JobSupersededError()
const page = await selectExportRowPage(table, after, EXPORT_BATCH_SIZE)
if (page.length === 0) break
const pageChunks: string[] = []
for (const row of page) {
if (format === 'csv') {
pageChunks.push(
`${toCsvRow(columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])))}\n`
)
} else {
const prefix = firstJsonRow ? '' : ','
firstJsonRow = false
pageChunks.push(prefix + JSON.stringify(toNamedRow(row.data)))
}
}
await handle.write(pageChunks.join(''))
exported += page.length
const last = page[page.length - 1]
after = { orderKey: last.orderKey, id: last.id }
if (page.length < EXPORT_BATCH_SIZE) break
}
if (format === 'json') await handle.write(']')
const ownsFinalize = await updateJobProgress(tableId, exported, jobId)
if (!ownsFinalize) throw new JobSupersededError()
const uploaded = await handle.complete()
uploadedKey = uploaded.key
await setJobResultKey(tableId, jobId, uploaded.key)
await updateJobProgress(tableId, exported, jobId)
// Only announce success if we still won the transition (not canceled at the wire).
const becameReady = await markJobReady(tableId, jobId)
if (becameReady) {
void appendTableEvent({
kind: 'job',
type: 'export',
tableId,
jobId,
status: 'ready',
progress: exported,
})
logger.info(`[${requestId}] Export complete`, { tableId, rows: exported, format })
} else {
// Canceled at the very end — the file is orphaned; remove it (janitor would otherwise
// only catch it via the pruned job's resultKey).
await deleteFile({ key: uploaded.key, context: 'workspace' }).catch(() => {})
logger.info(`[${requestId}] Export finished but no longer owns the run`, { tableId, jobId })
}
} catch (err) {
// A partial/orphaned upload from this attempt is useless — clean it up best-effort. An
// in-flight multipart upload (not yet completed) is aborted so no staged parts linger; a
// completed-but-unannounced upload is removed by key.
if (uploadedKey) {
await deleteFile({ key: uploadedKey, context: 'workspace' }).catch(() => {})
} else if (handle) {
await handle.abort().catch(() => {})
}
if (err instanceof JobSupersededError) {
logger.info(`[${requestId}] Export superseded/canceled; stopping`, { tableId, jobId })
} else {
const message = getErrorMessage(err, 'Export failed')
logger.error(`[${requestId}] Export failed for table ${tableId}:`, err)
await markJobFailed(tableId, jobId, message).catch(() => {})
void appendTableEvent({
kind: 'job',
type: 'export',
tableId,
jobId,
status: 'failed',
error: message,
})
}
}
}