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
163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
|
|
import { appendTableEvent } from '@/lib/table/events'
|
|
import {
|
|
formatCsvValue,
|
|
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; CSV headers and JSON keys are display names, so translate
|
|
// id → name on the way out (export is a name-friendly boundary).
|
|
const nameById = buildNameById(table.schema)
|
|
|
|
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 })
|
|
await handle.write(
|
|
format === 'csv' ? `${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n` : '['
|
|
)
|
|
|
|
let exported = 0
|
|
let firstJsonRow = true
|
|
let after: { orderKey: string; 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) => formatCsvValue(row.data[getColumnId(c)])))}\n`
|
|
)
|
|
} else {
|
|
const prefix = firstJsonRow ? '' : ','
|
|
firstJsonRow = false
|
|
pageChunks.push(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
}
|