faucet-sink-bigquery
Google BigQuery streaming-insert sink for the faucet-stream ecosystem. Writes JSON records to a BigQuery table via the tabledata.insertAll streaming API, automatically re-chunking each batch to stay within BigQuery's per-request limits.
Reach for it when you want to land events, CDC streams, or query results into a BigQuery table from any faucet-stream source with one declarative config — and, when you need it, full upsert/delete mirroring and effectively-once delivery on top of the same connector.
Feature highlights
- Streaming inserts — high-throughput
tabledata.insertAll; each batch is re-chunked to BigQuery's ~10 MB / ~500-row sweet spot. - Three credential modes — Application Default Credentials, a service-account key file, or inline service-account JSON. The shared
BigQueryCredentialsenum is re-exported fromfaucet-common-bigqueryso it matches the BigQuery source byte-for-byte. - Write modes —
append(default),upsert, anddeletevia an in-placeMERGEover the target table; no staging table required. - Effectively-once delivery — pair with a CDC source for a multi-statement
MERGE/INSERTtransaction that commits records and a_faucet_commit_tokenwatermark atomically. - Retry de-duplication — set
insert_id_fieldto a stable per-row key so BigQuery best-effort de-dupes the at-least-once streaming path. - Dead-letter queue — surfaces per-row
insertErrorsso only the rows BigQuery actually rejected are routed to the DLQ; committed rows are never duplicated. - Client built once — the authenticated BigQuery client is constructed (and credentials validated) in
new()and reused for every write.
Installation
# As a library:
# Or via the umbrella crate:
# In the CLI (opt-in connector feature):
Quick start
# pipeline.yaml — faucet run pipeline.yaml
version: 1
pipeline:
source:
type: rest
config:
base_url: https://api.example.com
path: /v1/events
sink:
type: bigquery
config:
project_id: my-gcp-project
dataset_id: analytics
table_id: events
auth:
type: service_account_key_path
config:
path: /etc/secrets/bigquery-sa.json
batch_size: 500
Configuration reference
Core
| Field | Type | Default | Description |
|---|---|---|---|
project_id |
string | — (required) | GCP project ID that owns the dataset and is billed for the inserts. |
dataset_id |
string | — (required) | BigQuery dataset ID. |
table_id |
string | — (required) | BigQuery table ID. The table must already exist with a defined schema. |
auth |
BigQueryCredentials |
— (required) | Authentication — see Authentication. |
Reliability & batching
| Field | Type | Default | Description |
|---|---|---|---|
batch_size |
int | 1000 |
Maximum rows per insertAll request. 0 = no batching: the whole upstream page is sent in one call (use when the source already chunks to BigQuery's preferred size). See Streaming & batching. |
insert_id_field |
string | (unset) | Record field whose value is sent as the per-row streaming insertId for best-effort de-duplication on retry. See Retry de-duplication. |
Write mode
These fields are flattened, so they appear at the sink config top level.
| Field | Type | Default | Description |
|---|---|---|---|
write_mode |
enum | append |
append (streaming insertAll), upsert, or delete (in-place MERGE / keyed DELETE). |
key |
array | [] |
Key column(s). Required and non-empty for upsert/delete; must be real columns of the target table. |
delete_marker |
object | (none) | upsert only — { field: <name>, values: [<str>, …] }; rows whose field matches one of values become deletes instead of upserts. |
Authentication
auth uses the shared BigQueryCredentials enum (the project-wide { type, config } shape). Its Debug impl masks inline key JSON as *** to prevent accidental credential leakage in logs.
type |
config |
Use when |
|---|---|---|
application_default |
(none) | Running on GCP (workload identity / metadata server) or after gcloud auth application-default login. |
service_account_key_path |
{ path: <file> } |
You have a service-account key file on disk. |
service_account_key |
{ json: <string> } |
You want to inject the key JSON inline, typically via ${env:VAR} / ${secret:…} indirection (e.g. Kubernetes secrets, CI/CD). |
# Application Default Credentials (workload identity, gcloud)
auth:
type: application_default
# Service-account key file
auth:
type: service_account_key_path
config:
path: /etc/secrets/bigquery-sa.json
# Inline service-account JSON via env indirection
auth:
type: service_account_key
config:
json: ${env:GCP_SERVICE_ACCOUNT_JSON}
Examples
Streaming inserts with retry de-duplication
sink:
type: bigquery
config:
project_id: production-project
dataset_id: warehouse
table_id: user_events
auth:
type: service_account_key_path
config:
batch_size: 500
insert_id_field: event_id # stable per-row key → best-effort dedup
Local development with Application Default Credentials
sink:
type: bigquery
config:
project_id: dev-project
dataset_id: scratch
table_id: test_table
auth:
type: application_default
batch_size: 100
Postgres CDC → BigQuery upsert mirror (effectively-once)
version: 1
name: pg_cdc_to_bq_mirror
delivery: exactly_once
pipeline:
source:
type: postgres-cdc
config:
connection_url: ${env:SOURCE_PG_URL}
slot_name: faucet_bq_mirror
publication_name: faucet_pub
create_slot_if_missing: true
idle_timeout: 30
transforms:
- type: cdc_unwrap
sink:
type: bigquery
config:
project_id: myproject
dataset_id: warehouse
table_id: users_mirror
auth:
type: application_default
write_mode: upsert
key:
delete_marker:
state:
type: file
config:
path: ./state
See the full runnable config at cli/examples/postgres_cdc_to_bigquery_upsert.yaml.
Streaming & batching
The BigQuery sink re-chunks each incoming StreamPage to keep individual tabledata.insertAll calls under BigQuery's limits. write_batch accepts whatever slice the pipeline hands it:
batch_size > 0(default1000) — the sink slices the incoming slice intobatch_size-row chunks and issues oneinsertAllHTTP call per chunk. Recommended value is500: the documented sweet spot for BigQuery streaming inserts — small enough to stay well under the ~10 MB request limit even for wide rows, large enough to amortise per-call overhead. Bump it higher when rows are narrow; drop it when rows are wide enough to push individual chunks past 10 MB.batch_size = 0— the "no batching" sentinel. The entire upstreamStreamPageis forwarded in a singleinsertAllcall. Use this when the source already emits page sizes tuned for BigQuery (e.g. a Postgres source configured withbatch_size: 500). Larger pages risk HTTP-413 from BigQuery's ~10 MB body limit.
batch_size is purely a chunk-size knob — BigQuery's per-row error reporting and the sink's "first row that failed" error message are unchanged. Per-call retry is delegated to gcp_bigquery_client's built-in retry middleware.
Retry de-duplication
BigQuery streaming inserts are at-least-once — a transport retry can insert the same row twice. Setting insert_id_field to a stable per-row key (e.g. a primary key or event id) makes the sink send that value as the row's insertId, which BigQuery uses for best-effort de-duplication over a short window. When insert_id_field is unset, or a given row lacks the field, that row is inserted without an insertId (no dedup for it).
Write modes (upsert / delete)
In addition to the default append mode (streaming insertAll), the BigQuery sink supports write_mode: upsert and write_mode: delete. These use an in-place MERGE … USING (SELECT … FROM UNNEST(JSON_QUERY_ARRAY(@payload))) over the target table — no staging table required.
upsert— insert-or-update bykeyvia an in-placeMERGE. Ifdelete_markeris set (the standard CDC pattern), rows whose marker field matches are routed to a keyedDELETEinstead, and the marker field is stripped from upserted rows.delete— delete bykeyfor every record in the batch (direct keyedDELETE, no MERGE).
The key columns must be real columns of the target BigQuery table (validated against the table schema via tables.get at write time). The target table must already exist with a defined schema.
Page-size limit. The whole page is sent as one jobs.query request — BigQuery's ~10 MB body limit applies. The default batch_size: 1000 suits most schemas; lower it for very wide rows.
Effectively-once composition. delivery: exactly_once composes with write_mode: upsert: the data MERGE and the watermark MERGE (into _faucet_commit_token) commit inside a single BigQuery transaction. See Effectively-once delivery below.
Effectively-once delivery
BigQuerySink implements Sink::supports_idempotent_writes (returns true) and the two companion hooks:
write_batch_idempotent(records, scope, token)— writes the page and records thetokenforscopein one BigQuery multi-statement transaction: a typedINSERT INTO <target> SELECT … FROM UNNEST(JSON_QUERY_ARRAY(@payload))(the page ships as a single bound JSON parameter; each column is cast per the target table's schema, fetched once viatables.get) followed by aMERGEinto the_faucet_commit_tokenwatermark table in the target dataset. Either both commit or neither does. Success is verified authoritatively via the job'serrorResult, so a runtime failure (a badCAST, aNULLinto aREQUIREDcolumn) aborts the page rather than advancing the bookmark over data that never landed.last_committed_token(scope)— reads the watermark row forscopeso the pipeline can skip already-committed pages on resume.
On crash/resume the pipeline reads last_committed_token and skips any page whose token is already committed, so the target table never sees duplicates. This is append-with-idempotency, distinct from the default streaming insertAll path and from key-based upsert (write_mode).
Requirements & limits. The target table must already exist with a defined schema (the sink reads it to build the typed INSERT — a schemaless table is rejected with a clear error). Because each page commits as one atomic transaction (one token per page, no batch_size re-chunking on this path), the page must serialize within BigQuery's ~10 MB jobs.query request limit — keep the CDC source's per-page size modest. A scalar column whose JSON value is an object/array is coerced to NULL (or, for a REQUIRED column, fails the INSERT). This path uses BigQuery DML, which has concurrency limits; it is intended for the opt-in effectively-once mode, not high-rate streaming (use the default streaming insertAll path for that).
To use effectively-once delivery, set delivery: exactly_once in your pipeline config and pair this sink with one of the CDC sources (postgres-cdc, mysql-cdc, mongodb-cdc) plus a state: block. A DLQ is not permitted in effectively-once mode. All four requirements are validated at config-load time (faucet validate) before any run starts.
pipeline:
source:
type: mongodb-cdc
config:
connection_uri: "mongodb://localhost:27017/?replicaSet=rs0"
scope:
type: collection
database: app
collection: orders
start_from:
type: now
sink:
type: bigquery
config:
project_id: my-gcp-project
dataset_id: analytics
table_id: orders
auth:
type: application_default
state:
type: file
config:
path: ./state
delivery: exactly_once
See the Effectively-once delivery cookbook for full rationale and the supported source/sink set.
Schema evolution
BigQuerySink reports its live destination schema via current_schema() (a schema-only tables.get; a missing table → None, so every page column reads as new), so the pipeline-level schema: policy can detect drift between an incoming page's top-level shape and the real table. All five on_drift modes (warn / ignore / quarantine / fail / evolve) work against this sink.
Under on_drift: evolve, BigQuerySink::evolve_schema() applies ALTER TABLE DDL, each statement run as its own jobs.query job and verified to completion:
- New columns →
ADD COLUMN IF NOT EXISTS <col> <type>(idempotent). - Lossless widenings (e.g. integer → number) →
ALTER COLUMN <col> SET DATA TYPE <type>— gated onallow_type_widening. - Nullability relaxations →
ALTER COLUMN <col> DROP NOT NULL.
The cached schema is invalidated afterwards so the next page (and the next effectively-once / upsert page) reads the evolved table. Incompatible changes (narrowing / type swaps) are never auto-applied — they are routed by on_incompatible (fail or quarantine). See the schema-drift cookbook.
Dead-letter queue
This sink overrides Sink::write_batch_partial to surface per-row failures from BigQuery tabledata.insertAll's insertErrors response. Configure a DLQ at the pipeline level (see cli/README.md — dlq:) and only the rows BigQuery actually rejected are routed there — already-committed rows stay in the main sink with no duplicates. (DLQ is not permitted in effectively-once mode.)
Config loading & schema introspection
Load from YAML/JSON files or environment variables via the faucet_core::config helpers, and inspect the full JSON Schema with faucet schema sink bigquery.
use ;
use BigQuerySinkConfig;
// From a JSON file
let config: BigQuerySinkConfig = load_json?;
// From an .env file with a prefix
let config: BigQuerySinkConfig = load_env_file?;
Example JSON config
Example .env file
BIGQUERY_PROJECT_ID=my-gcp-project
BIGQUERY_DATASET_ID=analytics
BIGQUERY_TABLE_ID=events
BIGQUERY_AUTH='{"type":"service_account_key_path","config":{"path":"/etc/secrets/bigquery-sa.json"}}'
BIGQUERY_BATCH_SIZE=1000
Library usage
use ;
use ;
use json;
# async
Drive it from a full pipeline with any source:
use Pipeline;
use ;
use ;
# async
How it works
new()resolvesBigQueryCredentialsand builds an authenticated client once, validating credentials eagerly so failures surface immediately.write_batch()slices the input intobatch_size-row chunks (or forwards the whole slice whenbatch_size = 0) and sends each chunk as a separateinsertAllrequest.- Per-row errors in the BigQuery response are detected and reported. If any rows fail, the batch returns an error with details about the first failure;
write_batch_partialexposes them per-row for DLQ routing. write_mode: upsert/deleteand the effectively-once path build pure-SQLMERGE/INSERTstatements and execute them viajobs.query, verifying success against the job'serrorResult.- The client is reused across all calls — no re-authentication per request.
Lineage dataset URI
bigquery://<project_id>.<dataset_id>.<table_id> — e.g. bigquery://my-project.warehouse.orders.
Feature flags
This crate has no optional features of its own; enable it in the CLI/umbrella via the sink-bigquery feature.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
Auth error / 401 / 403 |
Credentials invalid or missing scope. Confirm the service account has BigQuery Data Editor + BigQuery Job User on the project, or that ADC is initialized (gcloud auth application-default login). |
403 ... billing |
The project_id has no billing enabled, or the SA lacks bigquery.jobs.create. Use a project with billing and the Job User role. |
| HTTP 413 / request too large | A chunk exceeded BigQuery's ~10 MB body limit. Lower batch_size (or set a non-zero batch_size if you were using 0 with wide rows). |
| Duplicate rows after a retry | Streaming inserts are at-least-once. Set insert_id_field to a stable per-row key for best-effort de-dup, or use delivery: exactly_once. |
write_mode: upsert / key rejected |
key must be non-empty and name real columns of the target table; the table must already exist with a defined schema. |
| Effectively-once run rejected at validate | Effectively-once requires a CDC source (postgres-cdc/mysql-cdc/mongodb-cdc), this sink, a state: block, and no dlq:. faucet validate reports the missing requirement. |
Idempotent INSERT fails on a REQUIRED column |
A scalar column received a JSON object/array (coerced to NULL) or a missing value. Shape the records (e.g. cdc_unwrap + a transform) so every REQUIRED column is populated with a scalar. |
Inline key JSON appears as *** in logs |
Intentional — BigQueryCredentials's Debug masks inline JSON to prevent credential leakage. |
See also
- Connector reference — capability matrix.
- Effectively-once delivery cookbook.
- Upsert / write-modes cookbook.
faucet-source-bigquery— the matching BigQuery query source.faucet-common-bigquery— sharedBigQueryCredentials+ client builder.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.