faucet-sink-snowflake
Snowflake sink for the faucet-stream ecosystem. Writes JSON records to a Snowflake table over the Snowflake SQL REST API — no driver, no ODBC, no client library, just HTTPS.
Reach for it when you want to land records from any faucet-stream source — a REST API, a database, a file, a queue — into Snowflake with one declarative config. Each batch is parsed and inserted in a single PARSE_JSON + FLATTEN statement, and the JSON array travels as a bound TEXT parameter (PARSE_JSON(?)) rather than interpolated into SQL, so quote characters in your data are safe and cannot inject SQL.
Feature highlights
- Driverless SQL REST API — pure HTTPS against
https://{account}.snowflakecomputing.com/api/v2/statements. The HTTP client is built once innew()and reused for every request. - Two auth methods — JWT key-pair (RS256, minted locally from your RSA private key with the public-key SHA-256 fingerprint in the
issclaim) or OAuth bearer tokens from an external identity provider. - Shared auth providers — the
authfield also accepts{ ref: <name> }, pointing at a provider in the CLI's top-levelauth:catalog (OAuth/bearer tokens only; key-pair JWT is always inline). N matrix rows hitting one IdP then share a single token. - Set-based batch inserts — one
INSERT … SELECT … FROM TABLE(FLATTEN(input => PARSE_JSON(?)))per chunk parses and inserts the whole batch in a single statement. - Configurable batching —
batch_sizecontrols rows per request (default1000, the documented sweet spot);batch_size: 0forwards each upstream page untouched. - Async-execution aware — when Snowflake answers an
INSERTwith HTTP 202 (queued, not yet run), the sink polls the statement handle until it succeeds before counting rows as written, bounded bypoll_timeout. - Effectively-once delivery — with
delivery: exactly_once, each page's INSERT and a commit-tokenMERGEland in one multi-statement transaction, so a crash/resume never duplicates a page. - SQL-injection-safe — data is bound, never interpolated; all table/schema/database identifiers are quoted.
- Preflight probe —
faucet doctorruns a read-onlySELECT 1to confirm credentials and warehouse access without writing rows.
Installation
# As a library:
# Via the umbrella crate:
# In the CLI (opt-in connector feature):
Quick start
# pipeline.yaml — faucet run pipeline.yaml
version: 1
pipeline:
source:
type: s3
config:
bucket: my-data-lake
prefix: raw/events/
region: us-east-1
file_format: json_lines
sink:
type: snowflake
config:
account: xy12345.us-east-1
warehouse: LOAD_WH
database: ANALYTICS
schema: RAW
table: EVENTS
auth:
type: key_pair
config:
user: LOADER
private_key_pem: ${file:./snowflake_key.pem}
batch_size: 1000
Configuration reference
Core
| Field | Type | Default | Description |
|---|---|---|---|
account |
string | — (required) | Snowflake account identifier (e.g. "xy12345.us-east-1"). Used to build the API host https://{account}.snowflakecomputing.com. |
warehouse |
string | — (required) | Warehouse used for the session executing the inserts. |
database |
string | — (required) | Target database name. |
schema |
string | — (required) | Target schema name. |
table |
string | — (required) | Target table name. |
auth |
AuthSpec<SnowflakeAuth> |
— (required) | Authentication — inline { type, config } or { ref: <name> }. See Authentication. |
Batching & reliability
| Field | Type | Default | Description |
|---|---|---|---|
batch_size |
int | 1000 |
Maximum records per SQL REST API request. The sink re-chunks each incoming slice into batch_size slices and issues one INSERT per chunk. 0 = no batching: the whole slice is sent in one INSERT, no matter how large. Values above MAX_BATCH_SIZE (1,000,000) are rejected by faucet_core::validate_batch_size. |
poll_timeout |
int (seconds) | 300 |
Max wall-clock time to wait for an asynchronously-executed INSERT (HTTP 202) to finish before failing with FaucetError::Sink. 0 = poll forever. See Asynchronous execution. |
Authentication
auth uses the shared SnowflakeAuth enum (re-exported from faucet-common-snowflake, so it matches the Snowflake source byte-for-byte) in the project-wide { type, config } shape. The Debug impl masks private_key_pem and token as ***.
type |
config |
Use when |
|---|---|---|
key_pair |
{ user: <string>, private_key_pem: <PEM string> } |
You have an RSA key pair registered on the Snowflake user. The sink mints an RS256 JWT locally (1-hour expiry, public-key SHA-256 fingerprint in the iss claim). |
oauth |
{ token: <string> } |
You have an OAuth2 bearer token from an external IdP. Sent as Snowflake Token="…". |
# Key-pair JWT (PEM inlined from disk via the file: directive)
auth:
type: key_pair
config:
user: INGEST_USER
private_key_pem: ${file:./snowflake_key.pem}
# OAuth bearer token via env indirection
auth:
type: oauth
config:
token: ${env:SNOWFLAKE_TOKEN}
# Shared provider from the top-level auth: catalog (OAuth/bearer only)
auth:
ref: snowflake_idp
Note: a shared
auth: { ref }provider must yield aBearerorTokencredential, which maps ontoSnowflakeAuth::OAuth. Key-pair JWT is stateless (minted locally from the RSA key) and therefore must always be supplied inline.
Examples
Postgres → Snowflake with key-pair auth
version: 1
name: postgres_to_snowflake
pipeline:
source:
type: postgres
config:
connection_url: postgres://user:pass@localhost/app
query: SELECT id, email, created_at FROM users WHERE tenant_id = $1
params:
- acme
sink:
type: snowflake
config:
account: xy12345.us-east-1
warehouse: INGEST_WH
database: ANALYTICS
schema: RAW
table: USERS
auth:
type: key_pair
config:
user: INGEST_USER
private_key_pem: ${file:./snowflake_key.pem}
batch_size: 500
Latency-sensitive stream with small batches
sink:
type: snowflake
config:
account: xy12345.us-east-1
warehouse: COMPUTE_WH
database: MY_DB
schema: PUBLIC
table: realtime_events
auth:
type: oauth
config:
batch_size: 50 # smaller chunks → lower per-row latency
Large load with no re-chunking
sink:
type: snowflake
config:
account: xy12345.us-east-1
warehouse: LOAD_WH
database: ANALYTICS
schema: RAW
table: EVENTS
auth:
type: key_pair
config:
user: LOADER
private_key_pem: ${file:./snowflake_key.pem}
batch_size: 0 # forward each upstream page as one INSERT
poll_timeout: 900 # allow up to 15 min for a queued statement to run
Streaming & batching
SnowflakeSink::write_batch re-chunks the incoming records slice into batch_size slices and issues one SQL REST API INSERT per chunk. The default of 1000 matches Snowflake's documented sweet spot for the SQL API — enough rows to amortize the round-trip and statement-parse cost without bloating the request body. Tune up for narrow rows where round-trip latency dominates, down for wide rows where request-body size dominates.
batch_size = 0 is the "no batching" sentinel: write_batch forwards the entire slice as a single INSERT, so the upstream source's StreamPage framing flows through untouched. Use it when the source already emits pages sized for the target (e.g. one large page per write).
Asynchronous execution
Snowflake's SQL REST API may answer a submitted INSERT with HTTP 202 Accepted — the statement was queued but has not yet executed. The sink does not count those rows as written at that point (that would report success before the data is durable). Instead it polls GET /api/v2/statements/{handle} until the statement reports success (code 090001), then returns. The poll loop is bounded by poll_timeout (default 300 s): if the statement is still running after that budget, the write fails with FaucetError::Sink rather than hanging forever. Set poll_timeout: 0 to poll indefinitely.
Effectively-once delivery
SnowflakeSink implements Sink::supports_idempotent_writes (returns true) and the two companion hooks:
write_batch_idempotent(records, scope, token)— writes the page's records and MERGEs thetokeninto a_faucet_commit_token("scope" STRING PRIMARY KEY, "token" STRING, "updated_at" TIMESTAMP_NTZ)watermark table in the target database/schema, all inside one multi-statement transaction (BEGIN; INSERT; MERGE; COMMIT;withMULTI_STATEMENT_COUNTset on the request), so both either commit together or neither does. The watermark table is created (CREATE TABLE IF NOT EXISTS) as its own request once per sink instance — Snowflake DDL auto-commits, so it can never ride inside the transaction.last_committed_token(scope)— reads the current watermark so the pipeline skips already-committed pages on resume.
The whole page is one atomic unit on this path — batch_size re-chunking does not apply to write_batch_idempotent (core issues exactly one token per page; splitting the page across transactions would break the rows-plus-token atomicity). Size the source's batch_size down if a page's JSON payload grows too large for a single SQL REST API request. An empty page still advances the watermark via a commit-only BEGIN; MERGE; COMMIT; transaction.
To use effectively-once delivery, set delivery: exactly_once and pair this sink with a CDC source (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.
version: 1
pipeline:
source:
type: postgres-cdc
config:
connection_url: postgres://faucet:faucet@localhost:5432/appdb
slot_name: faucet_slot
publication_name: faucet_pub
sink:
type: snowflake
config:
account: xy12345.us-east-1
warehouse: COMPUTE_WH
database: ANALYTICS_DB
schema: PUBLIC
table: change_events
auth:
type: oauth
config:
token: ${env:SNOWFLAKE_TOKEN}
state:
type: file
config:
path: ./state
delivery: exactly_once
See the effectively-once delivery cookbook.
Config loading & schema
Load from YAML/JSON or environment, and inspect the full JSON Schema:
use ;
use SnowflakeSinkConfig;
// From a JSON file
let config: SnowflakeSinkConfig = load_json?;
// From an .env file with a prefix
let config: SnowflakeSinkConfig = load_env_file?;
SNOWFLAKE_ACCOUNT=xy12345.us-east-1
SNOWFLAKE_WAREHOUSE=COMPUTE_WH
SNOWFLAKE_DATABASE=ANALYTICS_DB
SNOWFLAKE_SCHEMA=PUBLIC
SNOWFLAKE_TABLE=events
SNOWFLAKE_AUTH='{"type":"oauth","config":{"token":"eyJhbGciOiJSUzI1NiIs..."}}'
SNOWFLAKE_BATCH_SIZE=1000
Library usage
use ;
use ;
use json;
# async
To drive it end-to-end, pair it with any source via Pipeline::new(source, sink).run().await?.
How it works
SnowflakeSink::new()builds an HTTP client (reused across all requests). No network call happens at construction.write_batch()splits records intobatch_sizechunks (or one chunk whenbatch_size = 0). For each chunk it builds anINSERTusingPARSE_JSON(?)+FLATTENand sends the chunk's JSON array as a boundTEXTparameter, parsing and inserting every row in one statement without interpolating data into the SQL text.- Field-to-column mapping: each record's top-level keys project into matching table columns —
INSERT INTO "db"."schema"."tbl" ("col1","col2") SELECT value:"col1"::string, value:"col2"::string FROM TABLE(FLATTEN(input => PARSE_JSON(?))). The::stringcast strips the VARIANT's JSON quotes so Snowflake coerces each scalar into the destination column's type on insert (text → number / boolean / timestamp, etc.). The column set comes from the first record; a key absent from a later record is inserted asNULL. Target columns should be scalar — a key targeting aVARIANT/OBJECT/ARRAYcolumn is stringified, not stored as structured JSON. Both column identifiers and JSON path keys are quote-escaped, so record keys cannot inject SQL. - The statement targets the fully-qualified
"database"."schema"."table"with quoted identifiers. - Auth headers are generated per request: an RS256 JWT (1-hour expiry, public-key fingerprint in
iss) forkey_pair, orSnowflake Token="…"foroauth. - Success is confirmed by the
090001response code; an HTTP 202 enters the async-execution poll loop.
Lineage dataset URI
snowflake://<account>/<database>/<schema>?table=<table> — e.g. snowflake://xy12345.us-east-1/ANALYTICS_DB/PUBLIC?table=events.
Feature flags
This crate has no optional features of its own; enable it in the CLI/umbrella via the sink-snowflake feature.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
Auth error / 401 |
Credentials rejected. For key_pair, confirm the public key is registered on the Snowflake user (ALTER USER … SET RSA_PUBLIC_KEY=…) and the PEM is a valid RSA private key (PKCS#8 BEGIN PRIVATE KEY or PKCS#1 BEGIN RSA PRIVATE KEY). For oauth, check the token isn't expired and matches the account's configured OAuth integration. Run faucet doctor for a one-shot SELECT 1 probe. |
invalid RSA private key |
The private_key_pem body isn't a parseable RSA key — check the ${file:…} path resolved and the PEM wasn't truncated/escaped. |
Snowflake auth provider must yield a bearer/token credential |
You pointed auth: { ref } at a non-bearer shared provider, or tried key-pair via a provider. Key-pair JWT must be inline; shared providers only supply OAuth/bearer tokens. |
Rows silently land as NULL |
A record key doesn't match a table column name (case-sensitive — Snowflake folds unquoted identifiers to uppercase). Ensure record keys match the quoted column names exactly. |
| Structured field stored as a JSON string | The target column isn't scalar. The ::string cast stringifies VARIANT/OBJECT/ARRAY targets — land those into a VARIANT column with a transform, or split the field. |
Write fails with FaucetError::Sink: … timed out |
The queued statement didn't finish within poll_timeout. Raise poll_timeout (or set 0), and make sure the warehouse is not suspended / can resume. |
batch_size rejected |
Values above MAX_BATCH_SIZE (1,000,000) are invalid. Use 0 for "no batching", or a value ≤ 1,000,000. |
| First record's columns don't cover all rows | The column set is taken from the first record. Put a record with the full key set first, or normalize keys with an upstream transform so every row shares the same shape. |
See also
- Snowflake source — query Snowflake via the same SQL REST API.
- faucet-common-snowflake — the shared
SnowflakeAuthenum and auth helpers. - Sinks reference — capability matrix across all connectors.
- Authentication cookbook — the shared
auth:provider catalog. - Secrets cookbook — injecting the PEM / token from a secrets manager.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Arrow columnar bulk load (opt-in, arrow feature)
With a binary built --features arrow, add a bulk_load block to switch the
sink onto the Arrow columnar fast path: incoming Arrow RecordBatches are
written to Parquet, uploaded to an external stage's backing cloud storage,
and loaded with COPY INTO <table> FROM @stage FILE_FORMAT=(TYPE=PARQUET). This
runs end-to-end without per-row JSON when the source is also columnar (e.g.
parquet → snowflake). It is append-only and independent of the row-path
INSERT/exactly-once code, which is unchanged.
sink:
type: snowflake
config:
account: ${env:SNOWFLAKE_ACCOUNT}
warehouse: LOAD_WH
database: ANALYTICS
schema: PUBLIC
table: EVENTS
auth:
bulk_load:
stage: ANALYTICS.PUBLIC.EVENTS_STAGE # existing external stage
url: s3://my-bucket/snowflake-stage/ # the stage's backing location
storage_options: # object_store keys for the upload
aws_access_key_id: ${env:AWS_ACCESS_KEY_ID}
aws_secret_access_key: ${env:AWS_SECRET_ACCESS_KEY}
aws_region: us-east-1
match_by_column_name: CASE_INSENSITIVE # default
purge: false # default; PURGE=TRUE removes staged files
Only external stages are supported — internal-stage PUT is a driver command
the SQL REST API cannot run. The Snowflake source has no Arrow path (the v2
SQL API is jsonv2-only). bulk_load on an arrow-off binary is rejected at
construction.