chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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() {},
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user