Files
simstudioai--sim/apps/sim/lib/data-drains/destinations/bigquery.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

334 lines
13 KiB
TypeScript

import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { JWT } from 'google-auth-library'
import { z } from 'zod'
import {
type ParsedServiceAccount,
parseNdjsonObjects,
parseServiceAccount,
refineServiceAccountJson,
sleepUntilAborted,
} from '@/lib/data-drains/destinations/utils'
import type { DeliveryMetadata, DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainBigQueryDestination')
/**
* Uses the legacy `tabledata.insertAll` streaming endpoint. The Storage Write
* API offers exactly-once semantics and lower pricing but requires gRPC; we
* stay on insertAll for simplicity and direct HTTP support.
*/
/** `insertdata` for streaming inserts; `readonly` for the `tables.get` probe in `test()`. */
const SCOPES = [
'https://www.googleapis.com/auth/bigquery.insertdata',
'https://www.googleapis.com/auth/bigquery.readonly',
]
/** Standard project IDs are 6-30 chars; the optional `domain.tld:` prefix supports legacy domain-scoped projects. */
const PROJECT_ID_RE = /^([a-z][a-z0-9.-]{0,61}[a-z0-9]:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const DATASET_RE = /^[A-Za-z0-9_]{1,1024}$/
const TABLE_RE = /^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd} ]{1,1024}$/u
const USER_AGENT = 'sim-data-drain/1.0'
/** Per-request streaming limits: 10 MB body, 50,000 rows, 1 MB per row. */
const MAX_REQUEST_BYTES = 10 * 1024 * 1024
const MAX_ROWS_PER_REQUEST = 50_000
const MAX_ROW_BYTES = 1024 * 1024
/** `insertId` is capped at 128 characters (encoded length). */
const MAX_INSERT_ID_LENGTH = 128
const PER_ATTEMPT_TIMEOUT_MS = 60_000
const bigqueryConfigSchema = z.object({
projectId: z
.string()
.min(6, 'projectId is required')
.refine((v) => PROJECT_ID_RE.test(v), {
message: 'projectId must match Google Cloud project ID rules',
}),
datasetId: z
.string()
.min(1, 'datasetId is required')
.refine((v) => DATASET_RE.test(v), {
message: 'datasetId may only contain ASCII letters, digits, and underscores (max 1024 chars)',
}),
tableId: z
.string()
.min(1, 'tableId is required')
.refine((v) => TABLE_RE.test(v), {
message:
'tableId may only contain Unicode letters/marks/numbers, connectors, dashes, and spaces (max 1024 chars)',
})
.refine((v) => Buffer.byteLength(v, 'utf8') <= 1024, {
message: 'tableId must be at most 1024 bytes when UTF-8 encoded',
}),
})
const bigqueryCredentialsSchema = z
.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
.superRefine(refineServiceAccountJson)
export type BigQueryDestinationConfig = z.infer<typeof bigqueryConfigSchema>
export type BigQueryDestinationCredentials = z.infer<typeof bigqueryCredentialsSchema>
function buildJwt(account: ParsedServiceAccount): JWT {
return new JWT({ email: account.clientEmail, key: account.privateKey, scopes: SCOPES })
}
async function getAccessToken(jwt: JWT, forceRefresh = false): Promise<string> {
if (forceRefresh) {
/** Clearing `credentials` forces `getAccessToken` to mint a new token instead of returning the cached one. */
jwt.credentials = {}
}
const { token } = await jwt.getAccessToken()
if (!token) throw new Error('Failed to obtain BigQuery access token')
return token
}
interface InsertAllInput {
config: BigQueryDestinationConfig
rows: Record<string, unknown>[]
metadata: DeliveryMetadata
jwt: JWT
signal: AbortSignal
}
interface InsertAllError {
index: number
errors: Array<{ reason?: string; message?: string; location?: string }>
}
async function postInsertAll(
input: InsertAllInput,
url: string,
body: string,
forceRefresh = false
): Promise<Response> {
const token = await getAccessToken(input.jwt, forceRefresh)
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
try {
return await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
body,
signal: perAttempt,
})
} catch (error) {
logger.warn('BigQuery request failed', {
table: `${input.config.projectId}.${input.config.datasetId}.${input.config.tableId}`,
error: toError(error).message,
})
throw error
}
}
/**
* Builds a stable `insertId` for best-effort dedup (~60s window). Prefixed
* with `drainId` so (runId, sequence) collisions across drains do not
* accidentally dedupe each other's rows. With UUID drain/run IDs the raw
* form fits well under 128 chars; if anything pushes it over (e.g. a future
* non-UUID id), hash the prefix and keep the row-distinguishing `index`
* suffix intact so BigQuery does not silently dedupe distinct rows.
*/
function buildInsertId(metadata: DeliveryMetadata, index: number): string {
const raw = `${metadata.drainId}-${metadata.runId}-${metadata.sequence}-${index}`
if (raw.length <= MAX_INSERT_ID_LENGTH) return raw
const prefixHash = createHash('sha1')
.update(`${metadata.drainId}-${metadata.runId}-${metadata.sequence}`)
.digest('hex')
return `${prefixHash}-${index}`
}
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504])
const MAX_RETRY_ATTEMPTS = 3
const BASE_RETRY_DELAY_MS = 250
/**
* Streams a chunk of rows to `tabledata.insertAll`.
*
* Partial-success caveat: BigQuery may return HTTP 200 with a non-empty
* `insertErrors` array. Rows not listed there are inserted and dedup-keyed by
* `insertId` for ~60s. We throw on any `insertErrors`; retries within the
* dedup window are safe, but retries after it may duplicate succeeded rows.
*/
async function insertAll(input: InsertAllInput): Promise<void> {
if (input.rows.length > MAX_ROWS_PER_REQUEST) {
throw new Error(
`BigQuery insertAll chunk has ${input.rows.length} rows, exceeds the ${MAX_ROWS_PER_REQUEST} per-request limit`
)
}
const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(input.config.projectId)}/datasets/${encodeURIComponent(input.config.datasetId)}/tables/${encodeURIComponent(input.config.tableId)}/insertAll`
/**
* `skipInvalidRows: false` and `ignoreUnknownValues: false` surface schema
* mismatches as `insertErrors` instead of silently dropping data — drains
* should fail loudly so operators notice the schema drift.
*/
const payload = {
skipInvalidRows: false,
ignoreUnknownValues: false,
rows: input.rows.map((row, index) => {
const rowBytes = Buffer.byteLength(JSON.stringify(row), 'utf8')
if (rowBytes > MAX_ROW_BYTES) {
throw new Error(
`BigQuery row at index ${index} is ${rowBytes} bytes, exceeds the ${MAX_ROW_BYTES}-byte per-row limit`
)
}
return {
insertId: buildInsertId(input.metadata, index),
json: row,
}
}),
}
const body = JSON.stringify(payload)
const byteLength = Buffer.byteLength(body, 'utf8')
if (byteLength > MAX_REQUEST_BYTES) {
throw new Error(
`BigQuery insertAll body is ${byteLength} bytes, exceeds the ${MAX_REQUEST_BYTES}-byte per-request limit`
)
}
let attempt = 0
let response: Response | undefined
let refreshedOnce = false
while (true) {
attempt++
try {
response = await postInsertAll(input, url, body)
/** A 401 retry doesn't count against the 5xx/429 budget — token refresh is a one-shot recovery. */
if (response.status === 401 && !refreshedOnce) {
refreshedOnce = true
logger.debug('BigQuery returned 401; refreshing access token and retrying once')
/** Drain the 401 body before discarding so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
response = await postInsertAll(input, url, body, true)
}
if (!RETRYABLE_STATUSES.has(response.status)) break
if (attempt >= MAX_RETRY_ATTEMPTS) break
const retryAfterHeaderMs = parseRetryAfter(response.headers.get('retry-after'))
const retryAfterMs = backoffWithJitter(attempt, retryAfterHeaderMs, {
baseMs: BASE_RETRY_DELAY_MS,
})
logger.warn('BigQuery insertAll transient error; retrying', {
status: response.status,
attempt,
retryAfterMs,
})
/** Drain the body so the keep-alive connection can be reused. */
await response.text().catch(() => '')
await sleepUntilAborted(retryAfterMs, input.signal)
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
} catch (error) {
/**
* Connection-level failures (DNS, socket reset, timeout) never produce
* a Response — treat them like 5xx and retry with backoff. Re-throw
* aborts unwrapped so callers see the cancellation reason.
*/
if (input.signal.aborted) throw input.signal.reason ?? error
if (attempt >= MAX_RETRY_ATTEMPTS) throw error
const retryAfterMs = backoffWithJitter(attempt, null, { baseMs: BASE_RETRY_DELAY_MS })
logger.warn('BigQuery insertAll network error; retrying', {
attempt,
retryAfterMs,
error: toError(error).message,
})
await sleepUntilAborted(retryAfterMs, input.signal)
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
}
}
if (!response) throw new Error('BigQuery insertAll failed: no response')
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`BigQuery insertAll failed (HTTP ${response.status}): ${text}`)
}
const result = (await response.json().catch(() => ({}))) as {
insertErrors?: InsertAllError[]
}
if (result.insertErrors && result.insertErrors.length > 0) {
const failedIndices = result.insertErrors.map((e) => e.index)
const total = input.rows.length
const failed = result.insertErrors.length
const succeeded = total - failed
logger.warn('BigQuery insertAll returned partial failure', {
partialFailure: true,
table: `${input.config.projectId}.${input.config.datasetId}.${input.config.tableId}`,
succeededRows: succeeded,
failedRows: failed,
failedIndices: failedIndices.slice(0, 20),
})
const summary = result.insertErrors
.slice(0, 3)
.map(
(e) =>
`row ${e.index}: ${e.errors.map((er) => er.message ?? er.reason ?? 'unknown').join('; ')}`
)
.join(' | ')
throw new Error(
`BigQuery insertAll partial failure: ${failed} of ${total} rows failed (indices: ${failedIndices.slice(0, 20).join(',')}${failedIndices.length > 20 ? ',...' : ''}); ${succeeded} rows were inserted and are dedup-keyed by insertId for ~60s — retries within that window are safe, but retries after the window may duplicate the succeeded rows. First errors: ${summary}`
)
}
}
export const bigqueryDestination: DrainDestination<
BigQueryDestinationConfig,
BigQueryDestinationCredentials
> = {
type: 'bigquery',
displayName: 'Google BigQuery',
configSchema: bigqueryConfigSchema,
credentialsSchema: bigqueryCredentialsSchema,
/**
* Probes table existence, IAM access, and credential validity in a single
* `tables.get` call. `fields=id` minimises response size — we only care
* whether the call succeeds, not the payload.
*/
async test({ config, credentials, signal }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
const token = await getAccessToken(jwt)
const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(config.projectId)}/datasets/${encodeURIComponent(config.datasetId)}/tables/${encodeURIComponent(config.tableId)}?fields=id`
const perAttempt = AbortSignal.any([signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: perAttempt,
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`BigQuery probe failed (HTTP ${response.status}): ${text}`)
}
/** Drain the success body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
},
openSession({ config, credentials }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
return {
async deliver({ body, metadata, signal }) {
const rows = parseNdjsonObjects(body, { requireObject: true }) as Record<string, unknown>[]
if (rows.length === 0) {
return {
locator: `bigquery://${config.projectId}/${config.datasetId}/${config.tableId}#${metadata.runId}-${metadata.sequence}`,
}
}
await insertAll({ config, rows, metadata, jwt, signal })
logger.debug('BigQuery chunk delivered', {
table: `${config.projectId}.${config.datasetId}.${config.tableId}`,
rows: rows.length,
})
return {
locator: `bigquery://${config.projectId}/${config.datasetId}/${config.tableId}#${metadata.runId}-${metadata.sequence}`,
}
},
async close() {},
}
},
}