d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
189 lines
7.6 KiB
TypeScript
189 lines
7.6 KiB
TypeScript
/**
|
|
* Versioned CSV snapshot cache for table mounts.
|
|
*
|
|
* Materializes a table's CSV into object storage once per `rows_version` and reuses it across
|
|
* executions until the table mutates (the `bump_user_table_rows_version` trigger invalidates the
|
|
* key). This replaces draining the whole table into web-process heap on every mount.
|
|
*
|
|
* Tenant isolation: callers must pass a table they have already authorized (the
|
|
* `function-execute` mount path enforces `table.workspaceId === context.workspaceId`); the key is
|
|
* namespaced by `workspaceId` and the row reads are workspace-filtered, so a snapshot can only ever
|
|
* contain — and be addressed by — its owning tenant.
|
|
*/
|
|
|
|
import { createHash } from 'crypto'
|
|
import { db } from '@sim/db'
|
|
import { userTableDefinitions } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { eq } from 'drizzle-orm'
|
|
import { getColumnId } from '@/lib/table/column-keys'
|
|
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
|
|
import { selectExportRowPage } from '@/lib/table/jobs/service'
|
|
import type { TableDefinition } from '@/lib/table/types'
|
|
import { createMultipartUpload, deleteFile, headObject } from '@/lib/uploads/core/storage-service'
|
|
|
|
const logger = createLogger('TableSnapshotCache')
|
|
|
|
const SNAPSHOT_STORAGE_CONTEXT = 'execution' as const
|
|
const SNAPSHOT_CONTENT_TYPE = 'text/csv; charset=utf-8'
|
|
const SNAPSHOT_BATCH_SIZE = 5000
|
|
|
|
/**
|
|
* Upper bound on a materialized snapshot. The sandbox now fetches snapshots by presigned URL (bytes
|
|
* never pass through web heap), so this is no longer a RAM limit — it bounds the worst-case inline
|
|
* materialization on a cache miss (a synchronous full-table scan in the copilot request). 500MB
|
|
* covers most large tables at ~tens of seconds; truly unbounded sizes want a background materializer.
|
|
*/
|
|
export const SNAPSHOT_MAX_BYTES = 500 * 1024 * 1024
|
|
|
|
export interface TableSnapshotRef {
|
|
key: string
|
|
size: number
|
|
version: number
|
|
}
|
|
|
|
/** Thrown when a table's CSV would exceed {@link SNAPSHOT_MAX_BYTES}; surfaced as a mount error. */
|
|
export class TableSnapshotTooLargeError extends Error {
|
|
constructor(tableId: string) {
|
|
super(
|
|
`Table ${tableId} is too large to mount (CSV exceeds ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB). Filter or split it before mounting.`
|
|
)
|
|
this.name = 'TableSnapshotTooLargeError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fingerprint of the table's column shape (id + display name + order). `rows_version` only advances
|
|
* on row mutations (the trigger fires on `user_table_rows`), so without this a schema edit — rename,
|
|
* add, remove, or reorder a column — would change the CSV header/columns but keep the same key and
|
|
* serve a stale snapshot. Folding it into the key invalidates the cache on any schema change. This
|
|
* is also the seam for a future column-subset / filtered projection (mix it into the same hash).
|
|
*/
|
|
function schemaFingerprint(table: TableDefinition): string {
|
|
const shape = table.schema.columns.map((c) => [getColumnId(c), c.name])
|
|
return createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 12)
|
|
}
|
|
|
|
/** Storage key for a table's snapshot at a given row version + column shape. */
|
|
function snapshotKey(
|
|
workspaceId: string,
|
|
tableId: string,
|
|
version: number,
|
|
shapeHash: string
|
|
): string {
|
|
return `table-snapshots/${workspaceId}/${tableId}/v${version}-${shapeHash}.csv`
|
|
}
|
|
|
|
async function readRowsVersion(tableId: string): Promise<number> {
|
|
const [row] = await db
|
|
.select({ rowsVersion: userTableDefinitions.rowsVersion })
|
|
.from(userTableDefinitions)
|
|
.where(eq(userTableDefinitions.id, tableId))
|
|
.limit(1)
|
|
if (!row) throw new Error(`Table ${tableId} not found while reading rows_version`)
|
|
return row.rowsVersion
|
|
}
|
|
|
|
/**
|
|
* Streams the table CSV (keyset-paginated, like the export worker) into storage under `key`,
|
|
* aborting if it crosses {@link SNAPSHOT_MAX_BYTES}. Returns the stored byte size. Bytes match the
|
|
* canonical export format (id-keyed reads, display-name headers).
|
|
*/
|
|
async function materialize(table: TableDefinition, key: string): Promise<number> {
|
|
const columns = table.schema.columns
|
|
const handle = await createMultipartUpload({
|
|
key,
|
|
context: SNAPSHOT_STORAGE_CONTEXT,
|
|
contentType: SNAPSHOT_CONTENT_TYPE,
|
|
})
|
|
|
|
try {
|
|
let bytes = 0
|
|
const header = `${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`
|
|
bytes += Buffer.byteLength(header)
|
|
await handle.write(header)
|
|
|
|
let after: { orderKey: string; id: string } | null = null
|
|
while (true) {
|
|
const page = await selectExportRowPage(table, after, SNAPSHOT_BATCH_SIZE)
|
|
if (page.length === 0) break
|
|
|
|
const chunk = page
|
|
.map((row) => `${toCsvRow(columns.map((c) => formatCsvValue(row.data[getColumnId(c)])))}\n`)
|
|
.join('')
|
|
bytes += Buffer.byteLength(chunk)
|
|
if (bytes > SNAPSHOT_MAX_BYTES) throw new TableSnapshotTooLargeError(table.id)
|
|
await handle.write(chunk)
|
|
|
|
const last = page[page.length - 1]
|
|
after = { orderKey: last.orderKey, id: last.id }
|
|
if (page.length < SNAPSHOT_BATCH_SIZE) break
|
|
}
|
|
|
|
const { size } = await handle.complete()
|
|
return size
|
|
} catch (err) {
|
|
await handle.abort().catch(() => {})
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/** Best-effort removal of the immediately-prior version (the common single-mutation case). */
|
|
async function deletePreviousVersion(
|
|
table: TableDefinition,
|
|
version: number,
|
|
shapeHash: string
|
|
): Promise<void> {
|
|
if (version <= 0) return
|
|
await deleteFile({
|
|
key: snapshotKey(table.workspaceId, table.id, version - 1, shapeHash),
|
|
context: SNAPSHOT_STORAGE_CONTEXT,
|
|
}).catch(() => {})
|
|
}
|
|
|
|
/**
|
|
* Returns the storage key + size of the table's snapshot at its current `rows_version`,
|
|
* materializing and storing it on a miss. The caller mounts by reference (head/download the key).
|
|
*
|
|
* Best-effort consistency: the version is read, the CSV materialized, then the version re-read. A
|
|
* mutation mid-scan (rare) re-keys to the new version and rebuilds once — no DB transaction is held
|
|
* across the upload. Concurrent misses write the same version-pinned key (idempotent).
|
|
*/
|
|
export async function getOrCreateTableSnapshot(
|
|
table: TableDefinition,
|
|
requestId: string
|
|
): Promise<TableSnapshotRef> {
|
|
const shapeHash = schemaFingerprint(table)
|
|
const version = await readRowsVersion(table.id)
|
|
const key = snapshotKey(table.workspaceId, table.id, version, shapeHash)
|
|
|
|
const head = await headObject(key, SNAPSHOT_STORAGE_CONTEXT)
|
|
if (head) {
|
|
logger.info(`[${requestId}] Snapshot hit`, { tableId: table.id, version, size: head.size })
|
|
return { key, size: head.size, version }
|
|
}
|
|
|
|
logger.info(`[${requestId}] Snapshot miss; materializing`, { tableId: table.id, version })
|
|
const size = await materialize(table, key)
|
|
|
|
const after = await readRowsVersion(table.id)
|
|
if (after !== version) {
|
|
// The table mutated mid-scan: the bytes under `key` may be torn. Re-key to the new version and
|
|
// rebuild once (or reuse if a concurrent writer already stored it); drop the stale object.
|
|
logger.info(`[${requestId}] rows_version advanced during materialize; re-keying`, {
|
|
tableId: table.id,
|
|
from: version,
|
|
to: after,
|
|
})
|
|
const newKey = snapshotKey(table.workspaceId, table.id, after, shapeHash)
|
|
const newHead = await headObject(newKey, SNAPSHOT_STORAGE_CONTEXT)
|
|
const newSize = newHead ? newHead.size : await materialize(table, newKey)
|
|
await deleteFile({ key, context: SNAPSHOT_STORAGE_CONTEXT }).catch(() => {})
|
|
void deletePreviousVersion(table, after, shapeHash)
|
|
return { key: newKey, size: newSize, version: after }
|
|
}
|
|
|
|
void deletePreviousVersion(table, version, shapeHash)
|
|
return { key, size, version }
|
|
}
|