Configuration file format
A faucet config is a YAML or JSON document with this top-level shape:
version: 1 # required, must be 1requires_faucet: ">=1.13" # optional; oldest faucet this config needsname: my_pipeline # optional; used in state keys and metricsvars: {} # optional; reusable values referenced as ${vars.X}auth: {} # optional; named shared auth providers (see below)schedule: {} # optional; cron schedule for faucet schedule (see below)pipeline: # required source: { type: …, config: { … } } transforms: [] # optional list sink: { type: …, config: { … } } state: { type: …, config: { … } } # optional dlq: { … } # optional dead-letter queue (see below)matrix: [] # optional per-row overrides / DAGexecution: # optional max_concurrent: 4 on_error: continue # continue | stopselection: # optional; row-selection policy (see Row selection) include_parents: off # off | eligible | allrequires_faucet
Section titled “requires_faucet”An optional semver requirement (Cargo syntax: ">=1.13", "^1.13.2",
">=1.13, <2") naming the faucet versions that can run this config.
validate, run, schedule, a serve submit and template register refuse
a config whose requirement the running binary does not meet, with
this pipeline requires faucet >=1.13; this binary is 1.12.0. Hub source and
sink templates take the same key. faucet init writes it for the scaffolding
binary’s minor version. A binary that predates the key rejects it as an
unknown field. See Pinning the faucet version.
pipeline
Section titled “pipeline”source and sink each take a type (the connector name) and a config
object whose fields are that connector’s schema — see faucet schema source <name>. transforms is an ordered list applied to every record. state
attaches a state store; dlq attaches a
dead-letter queue.
Transforms layering
Section titled “Transforms layering”Transforms can be declared at three layers and are resolved additively per matrix row in lifecycle order:
final = T_pipeline ++ T_source ++ T_rowpipeline.transforms— cross-cutting policy, runs first on every row.pipeline.sources.<name>.transforms— bound to a source template; runs for every row that resolves to this source.matrix[i].transforms— row-specific extras, runs last.
Each declaring layer (source template, matrix row) carries an
inherit_transforms: bool (default true); setting it false drops every
upstream layer for that scope.
A sink may carry free-form attributes: (string pairs such as residency: eu,
environment: prod) that a data-flow policy reasons about; a
matrix row’s sink.attributes adds or overrides the template’s.
Sinks reject both transforms: and inherit_transforms: at expand time —
destination shaping belongs at the pipeline or row layer. See the
transforms cookbook for the full model and
worked examples.
Available transforms
Section titled “Available transforms”The full catalogue (with shapes and worked examples) lives in the
transforms cookbook; faucet list prints the
same set, and faucet schema transform <name> returns the JSON schema for
each. Highlights:
filter— keep records where a JSONPath predicate is true. See the cookbook for the operator set and path syntax.explode— expand an array field into one record per element. See the cookbook for the merge rule andon_missingsemantics.zip_columns— zip a positional report into one object per row: one header list (columns_path) or several header + cell-arraygroups(from,header,header_label?,value?— arunReportresponse’s dimensions and metrics). A width mismatch or a column two groups both name fails the page.
Source-side incremental replication (replication_method, replication_key,
replication_bind) is configured per connector: rest binds the bookmark into
a query param / header / body field / path, graphql into a GraphQL variable —
see state & incremental.
Config composition
Section titled “Config composition”Three top-level mechanisms let a config be assembled from reusable pieces.
They are resolved when the file is read, before any ${...} interpolation.
| Mechanism | Form | Effect |
|---|---|---|
extends: |
extends: ./base.yaml or a list |
Inherit one or more base files; the child deep-merges on top. |
profiles: |
profiles: { dev: {…}, prod: {…} } |
Named overlays, selected at run time with --profile NAME / FAUCET_PROFILE. |
!include |
key: !include ./frag.yaml |
Substitute a YAML fragment at any node (YAML only). |
# app.yaml — inherits a base and pulls in a transform fragment.extends: ./base.yaml # single path, or a list (merged left-to-right)pipeline: transforms: !include ./transforms.yaml# base.yaml — shared connection + sink, with named per-environment overlays.version: 1name: composed-pipelinepipeline: source: { type: file, config: { path: ./data/input.csv } } sink: { type: file, config: { path: ./out/dev.jsonl } }profiles: dev: { pipeline: { sink: { config: { path: ./out/dev.jsonl } } } } prod: { pipeline: { sink: { config: { path: ./out/prod.jsonl } } } }extends— relative paths resolve against the directory of the file that declares them. A list of bases merges left-to-right; the child document overrides them all. Bases may themselvesextends:further files (depth-capped, cycle-detected).profiles— nothing is applied unless a profile is selected. Select with--profile prodorFAUCET_PROFILE=prod; the flag overrides the env var. An undeclared name is a load-time error.!include— a YAML tag (no JSON equivalent) that replaces the tagged node with the parsed contents of another YAML file (sequence, mapping, or scalar). Paths resolve against the including file’s directory.
Merge rule and precedence. Everything composes with the same deep-merge used
by matrix rows (objects merge recursively, arrays replace wholesale, scalars
replace). Lowest-to-highest priority (last wins):
extended base(s) → child document → selected profile → matrix rowLoad-time ordering. Composition runs first, then interpolation:
- Composition —
extends/!includestitched, then the selectedprofileoverlaid; theextends:/profiles:metadata keys are stripped. ${env:…}/${file:…}/${secret:…}, then${vars.X}and${sources.X}/${sinks.X}(see Interpolation).- Secrets-manager directives (
${vault:…}etc.). matrixexpansion.
Inspect the result with faucet validate --show-composed — it prints the
fully composed document (bases merged, profile applied, fragments substituted,
metadata stripped) before interpolation.
Interpolation
Section titled “Interpolation”Three stages resolve placeholders:
- Load time:
${env:VAR},${file:PATH},${secret:VAR}are resolved when the file is read.${vars.X}resolves against the top-levelvars:block;${sources.NAME.PATH}/${sinks.NAME.PATH}resolve against named templates. A substituted value is data: it can never combine with the text around it (or another substituted value) into a new${…}directive — such a${stays literal. A directive written whole inside avars:value still resolves where the value is used. Secret-manager directives (see below) run as the final load-time stage. - Trigger time:
${param.NAME}resolves against the top-levelparams:block, bound from--param/ an HTTPparamsobject before the config is parsed.${tenant.id}/${tenant.name}/${tenant.labels.KEY}bind in a runfaucet servestarts for a tenant (see below). - Runtime:
${row_id.dotted.path}tokens are resolved per parent record in DAG runs.${now.*}tokens are resolved per invocation at run time (see below). A value lands in the field shaped for it: anxmlsource’sbody/soap.body_innergets it XML-escaped, arest/webhook/xmlpathand every URL field (base_url, thehttpsink’surl, a connection URL) get an embedded token percent-encoded into one segment, a file or object path (a file sink’spath, a SQLitedatabase_url, aprefix) refuses a value holding/,\, NUL or..(a field that is exactly one token takes the value whole as a relative path — it may not be absolute, start with~or climb out with..), and a SQL source’s query gets it as a bind parameter. A field whose value is code the connector runs (a GraphQLquery, a DynamoDBfilter_expression/key_condition_expression/condition_expression, asqltransform’squery; markedx-faucet-codein the schema) may not hold a row token at all: the config fails to load. Pass the value as data instead (GraphQLvariables, DynamoDBexpression_attribute_values).${now.*},${backfill.*},${partition.*},${param.*}and${vars.*}stay allowed there.
Reference cycles surface as a clear InterpolationCycle error.
Write $${ for a literal ${. The escape holds through every stage — a
$${param.x}, $${now.date} or $${vault:…} is never resolved — and becomes
${ only in the value the connector or transform finally receives — in a
matrix row, a topology node, a vars: value, or a block such as masking:,
notifications: or observability: alike.
${now.*} — run-clock interpolation
Section titled “${now.*} — run-clock interpolation”${now.*} tokens inject the current wall time into source and sink config
values — and into a set transform’s values, so a run can stamp a column
with the run date (set: { values: { run_date: "${now.date}" } }, #568). Each
invocation evaluates them once at run time:
| Token | Example output | Notes |
|---|---|---|
${now.date} |
2026-03-08 |
YYYY-MM-DD |
${now.datetime} |
2026-03-08T14:05:09+00:00 |
RFC 3339; alias: ${now.iso} |
${now.iso} |
2026-03-08T14:05:09+00:00 |
Alias for ${now.datetime} |
${now.year} |
2026 |
Zero-padded 4-digit year |
${now.month} |
03 |
Zero-padded month (01–12) |
${now.day} |
08 |
Zero-padded day (01–31) |
${now.hour} |
14 |
Zero-padded hour (00–23) |
${now.minute} |
05 |
Zero-padded minute (00–59) |
${now.second} |
09 |
Zero-padded second (00–59) |
${now.unix} |
1741442709 |
Unix epoch seconds |
${now.strftime.<fmt>} |
2026/03/08/14 |
Arbitrary chrono strftime — e.g. ${now.strftime.%Y/%m/%d/%H} |
An unknown token (e.g. ${now.foo}) is a config error at run time. An invalid
strftime format produces a clean config error rather than a panic.
Clock source:
faucet run— the process start time in UTC. Override with--clock <value>for backfills: an RFC 3339 timestamp (2026-03-01T00:00:00Z) or a bare date (2026-03-01, treated as midnight UTC). See theruncommand reference.faucet schedule— the tick’s scheduled time, rendered in the schedule’stimezone.${now.date}therefore reflects the date in the timezone the cron fires in (e.g.America/Los_Angeles), not UTC. Queued runs use their original scheduled time;--onceuses the current wall clock.
Scope: ${now.*} tokens (and ${row_id.path} parent-record references) are
resolved only in source and sink config values. Using one in a state:,
dlq:, or transforms: config is a config error at validate/expand time —
it is rejected rather than silently passed to the connector as a literal
${…} string. (${env:…} / ${vars.X} / ${sources.X} still resolve
everywhere.)
Reserved id: now is a reserved matrix row id — a matrix row cannot be
named now.
SQL queries: in the query of a SQL source (postgres, mysql, sqlite,
duckdb, redshift, mssql, clickhouse, spanner, snowflake, bigquery, oracle;
sql for databricks), a ${row_id.path} token is bound as a query
parameter, never spliced into the SQL text, because its value comes from
another system. A token may stand alone (WHERE id = ${p.id}) or be a whole
quoted literal (WHERE name = '${p.name}'); a token embedded in a longer
literal ('x-${p.id}'), inside a quoted identifier or inside a comment cannot
be bound and is refused when the config loads. The value binds with the type
its text had in the SQL: a quoted literal binds as text (a number becomes its
digits), and a bare token holding an integer or a decimal as a string ("42",
the usual shape of an API id, or "12.50") binds as that number — a decimal
only up to 15 significant digits, which a double holds exactly; a longer one,
like anything else, keeps its JSON type, so compare it with a numeric column
through a CAST. A token that fills a table,
table_name, table_id, collection, index, schema or dataset field
must resolve to a plain identifier (letters, digits, _, ., -, $).
${now.*} is the run clock, not upstream data, and still substitutes as text.
${tenant.*} — tenant values
Section titled “${tenant.*} — tenant values”In a run started for a tenant (POST /v1/tenants/{tenant}/runs, a tenant
template trigger, a fan-out, or a trigger with tenants:), faucet serve
binds these anywhere in the document before it is loaded:
| Token | Value |
|---|---|
${tenant.id} |
The tenant id |
${tenant.name} |
The tenant’s display name (its id when it has none) |
${tenant.labels.<key>} |
One of the tenant’s labels; a missing label is an error |
Anywhere else — faucet run, a plain POST /v1/runs — a ${tenant.*} token
is refused with an error naming why, never passed to a connector as literal
text. tenant is a reserved matrix row id.
Secrets-manager directives
Section titled “Secrets-manager directives”Four additional load-time schemes pull values from external secrets managers.
Each requires the matching build feature (--features secrets-vault, etc.;
--features secrets enables all four). Values are fetched concurrently and
de-duplicated; they are never written to disk.
| Directive | Backend | Auth |
|---|---|---|
${vault:<path>[#field]} |
HashiCorp Vault KV v2 | VAULT_ADDR + VAULT_TOKEN (+ optional VAULT_NAMESPACE) |
${aws-sm:<name-or-ARN>[#field]} |
AWS Secrets Manager | aws-config default chain (env / profile / instance / web-identity) |
${gcp-sm:projects/<p>/secrets/<s>/versions/<v>} |
GCP Secret Manager (versions/latest ok) |
Application Default Credentials |
${azure-kv:<vault>/<secret>[/<version>]} |
Azure Key Vault | AZURE_* env / managed identity / az login |
The #field selector (Vault and AWS only) parses the secret body as a JSON
object and extracts a single key. Use faucet schema secrets for the machine-readable
grammar reference and faucet validate --no-secrets to check grammar offline.
See the secrets cookbook for full examples, the
redaction guarantee, and the known limitation around the auth: catalog.
params
Section titled “params”Declares the config’s trigger-time override surface: the values that change
per run, each typed. Referenced anywhere in the config as ${param.NAME} and
bound before the config is parsed, so a param can never alter the document’s
structure and never reaches a connector unresolved.
params: tenant_id: { type: string, required: true, description: "Tenant to sync" } since: { default: "1970-01-01" } page_size: { type: int, default: 500 } api_token: { required: true, secret: true } region: { default: us, values: [us, eu, apac] }| Field | Default | Meaning |
|---|---|---|
type |
string |
string · int · float · bool · list (see List params below) |
required |
false |
The caller must supply a value. Mutually exclusive with default. |
default |
— | Value when none is supplied. An ordinary config scalar, so default: "${env:SINCE}" resolves. On a typed param it may be text that resolves to the type (type: int, default: "${env:PORT}"). |
secret |
false |
Registered for redaction the instant it is bound — never reaches a log, error, API response, audit record, or the template registry. |
description |
— | Surfaced by faucet template list/show, GET /v1/templates, and the MCP get_template tool. |
computed |
— | A derived value (see below). Mutually exclusive with required, default, and secret; excluded from the trigger surface. |
values |
— | Closed set of acceptable values. Anything else is rejected at bind time, naming the allowed set. Mutually exclusive with computed; a default must be one of them. |
items |
— | (list) The spec every element is held to: a scalar type (with optional values and secret), or type: object with properties. |
properties |
— | (object items) Each field’s spec: a scalar type, required, default, values, secret. An element may carry no other field. |
min_items |
1 |
(list) The fewest elements. An empty list is refused by default rather than running with no rows. |
max_items |
— | (list) The most elements. |
trusted |
false |
On faucet serve, a caller’s value of this param reaches path, URL and command fields without the caller reference policy (#989). Without it such a value is checked like a submitted config — see template params a caller supplies. Not allowed on computed params or on a list’s items / fields. |
List params. type: list (#952) takes a JSON array over HTTP
("tables": ["orders", "customers"]), --param tables='["orders","customers"]'
on the CLI, or — for a list of strings — comma-separated text
(--param tables=orders,customers). Every element is coerced against items
before the config binds; a wrong-typed element, one outside values:, an
object missing a required field or carrying an unknown one, and a count
outside min_items..max_items are refused naming the element
(param 'shards[1]': expected an integer, got "abc"). int elements stay
exact past 2^53; float elements are read as exact decimals. A list of lists
and an object param outside items are refused. A list is used whole
(columns: ${param.cols} lands as an array) or by a source template’s
for_each
stream; it cannot be spliced into a longer string. secret: true (on the
list or its items) masks the whole list as ***; a secret field of an
object item masks that field in each element.
Closed value sets. values: turns a param into an enumerable axis. Without
it a typo’d value — region: ue — binds happily and surfaces as a 404 mid-run;
with it the bind fails up front naming the three allowed values. It also makes
the axis machine-enumerable, which is what a
template test suite’s
auto.enum_coverage sweeps. Each listed value must match the declared type,
and a default must be one of them.
Computed params & ${map:…}. A param can be derived from other params
instead of supplied, via computed: — resolved after the ordinary params bind
and excluded from the trigger surface (the console form, --param, and the HTTP
trigger body); supplying a value for one is an error. A computed expression may
reference other params (${param.NAME}, including other computed params) and the
${map:NAME|case=value|*=default} lookup — a small, non-Turing-complete switch on
another param’s value. Cycles and an unmatched map with no * default are
load-time errors. A computed param that references a secret: true param
(directly or through another computed param) is secret too: its value is
redacted from logs and shown as *** in trigger responses.
params: region: { default: com } accounts_domain: { computed: "${map:region|ca=examplecloud|*=example}" }# region=ca → accounts.examplecloud.ca ; anything else → accounts.example.<region>${map:NAME|…} also works inline in a source/sink config value, not only in a
computed param.
Supplying values. faucet run --param name=value (repeatable),
faucet template run <id> --param …, or POST /v1/templates/{id}/runs with a
params object. --param-env NAME[=VALUE] overrides an environment variable for
that run’s ${env:VAR} resolution only, without mutating the process
environment.
Typing. When ${param.NAME} is a scalar’s entire text the declared type
survives (an int param lands as a JSON number); embedded in a longer string it
is stringified, like every other namespace. Values are accepted as JSON (500)
or as strings ("500") and coerced to the declared type, so CLI and HTTP behave
identically. A type mismatch, a missing required param, an undeclared
--param, or an undeclared ${param.x} reference is an error naming the param.
Validation. faucet validate with no --param binds required params to
type-shaped placeholders (the first entry of a values: set, and a
${map:} switching on a placeholder takes its first case), so a parameterized
config validates in CI without inventing values; passing any --param switches to strict binding. faucet schema params prints the JSON Schema for one entry.
Persisting a parameterized config for register-once / trigger-by-id use is the pipeline template registry.
matrix
Section titled “matrix”Each row is deep-merged onto pipeline (scalars replace, objects merge, arrays
replace). A row with parent: runs once per parent record. See the
matrix DAG tutorial. For DRY configs with many
rows, define named templates under pipeline.sources / pipeline.sinks and
select them per row with ref:.
A row id may contain only letters, digits, _ and - (it is part of the
row’s state key and of ${id.field} tokens). A row may reference another row’s
record with ${id.field} only when id is its parent: (or, for a
for_each: row, one of its dimensions) — any other row’s token would reach the
connector unresolved, so it is refused at load time.
depends_on — completion ordering between rows
Section titled “depends_on — completion ordering between rows”A row with depends_on: [row_id, …] starts only after every listed row’s
invocations finish successfully. Unlike parent:, no records are consumed
and there is no per-record fan-out — it is pure run ordering (“load
dimensions, then facts”), typically paired with a downstream row whose source
reads what the upstream row’s sink wrote.
matrix: - id: dims source: { config: { query: "SELECT * FROM src_dims" } } sink: { config: { table_name: dims } } - id: facts depends_on: [dims] # starts only after `dims` succeeds source: { config: { query: "SELECT * FROM src_facts" } } sink: { config: { table_name: facts } }Semantics:
- Rows whose dependencies are all satisfied run concurrently under the usual
execution.max_concurrentbudget. - A failed or skipped dependency skips the dependent row (and its own children and dependents in turn); the run’s exit code reflects the original failure.
- Waiting on a row waits for that row’s own invocations only. To also wait for its per-record children, list them explicitly.
parent:anddepends_on:compose on the same row (the parent edge is an implicit dependency).- Unknown ids, self-dependencies, and cycles through any mix of
parent:/depends_on:edges are rejected at load time byfaucet validate. - Ordering works identically under
faucet run,schedule, andserve— they all execute the same expanded plan.
legacy_state_ids — carry state across a row-id change
Section titled “legacy_state_ids — carry state across a row-id change”A row’s id is part of its state key, so renaming a row would start it from
scratch. legacy_state_ids: [old_id, …] lists ids the row’s state was stored
under before; while the row has no state of its own, its first run moves the
bookmark from the first of them that holds one (a partition chunk looks under
old_id::partition::<chunk>). faucet discover and runtime fan-out write it
when a dataset’s id changed (see Discover).
fan_out: / for_each: — discovery-driven fan-out (#501)
Section titled “fan_out: / for_each: — discovery-driven fan-out (#501)”A fan_out: row enumerates a value-set at run time from a live endpoint,
and a for_each: row fans a stream out over the cartesian product of those
value-sets — “sync this report once per {subsidiary} × {custom-field}
returned by a discovery call”. This is the first-class version of the
“enumerate then fan out” shape common to report-style APIs.
pipeline: sources: api: # one complete template both roles reference type: rest config: { method: GET, base_url: "https://api.example.com", path: "/", auth: { type: none }, query_params: {}, pagination: { type: None }, replication_method: { type: FullTable } } sink: { type: file, config: { path: "./out/${subs.subsidiary_id}-${flds.field_id}.jsonl" } }
matrix: - id: subs # discovery dimension fan_out: source: { ref: api, config: { path: "/subsidiaries", records_path: "$.subsidiaries[*]" } } select: "$.id" # JSONPath projecting the value from each record as: subsidiary_id # exposed as ${subs.subsidiary_id} - id: flds fan_out: source: { ref: api, config: { path: "/fields", records_path: "$.fields[*]" } } select: "$.id" as: field_id - id: report # runs once per (subsidiary × field) tuple for_each: [subs, flds] source: ref: api config: path: "/reports/trial-balance" records_path: "$.rows[*]" query_params: { subsidiary_id: "${subs.subsidiary_id}", field_id: "${flds.field_id}" }Semantics:
- A
fan_out:row runs its source once, projectsselect(a JSONPath such as$.items[*].id; a bare path is read as$.<path>, a dot path whose keys JSONPath shorthand cannot spell —account-id,meta.2fa_id,Account Name— looks each key up verbatim, and$= whole record) from each record, and dedups (first-seen order; null / missing values skipped). It has no sink and writes nothing. It runs once per pipeline run, cached across all dependents. - The discovery
sourceis either a{ ref: <name> }to apipeline.sourcestemplate (recommended — reuses a complete connector config) or a standalone{ type, config }. It is not merged over thedefaulttemplate. - A
for_each: [dims]row runs once per tuple of the cartesian product of the listed dimensions, with${<dim>.<alias>}substituted into its source and sink config. Time windows (${now.*},faucet backfill) compose per-invocation without multiplying the matrix. - Each dimension is folded into the row’s
depends_on, so readiness, the skip cascade, and cycle detection reuse the ordering machinery. Per-tuple state keys ({name}::{row}::alias:value/…; a value outside[A-Za-z0-9_.-]or longer than 64 characters is replaced by a readable prefix plus a hash) let every cell resume independently. - Guards (all at load time via
faucet validate):for_eachmust namefan_out:rows; afan_out:row can’t carry a sink orparent:, and no row can name one inparent:(usefor_each:);for_eachcan’t combine withparent:(v1). The product is bounded byMAX_MATRIX_PRODUCT(10 000) — a larger cross-product fails rather than spawning an unbounded fleet.
See cli/examples/discovery_matrix.yaml.
Chained / two-level discovery + collected lists (#531)
Section titled “Chained / two-level discovery + collected lists (#531)”Some APIs need a second discovery step per discovered value, then the whole result injected into one request — the classic case being “discover the object types, then discover each type’s fields, then read each type asking for all its fields” (CRM custom objects, object-describe endpoints, spreadsheet-style bases, …). Two additions cover it:
- Chained discovery — a
fan_out:row may itself carryfor_each: [dims], so it runs once per upstream tuple with${<dim>.<alias>}resolved in its own source config. It must setcollect: true. - Collected (list-valued) dimensions —
collect: truepublishes the whole deduped value-set as one list per upstream tuple (not a cartesian axis). A consuming row references it as${<id>.<alias>}, which renders the list comma-joined (e.g.?properties=a,b,c). Referencing a collected row adds it to the consuming row’sdepends_onautomatically.
matrix: - id: types # 1. object types fan_out: { source: { ref: type_schemas }, select: "$.name", as: name } - id: props # 2. per type, collect its property names for_each: [types] fan_out: source: { ref: type_properties, config: { path: "/crm/v3/properties/${types.name}" } } select: "$.name" as: name collect: true - id: records # 3. per type, read with the whole list at once for_each: [types] source: ref: type_objects config: path: "/crm/v3/objects/${types.name}" query_params: { properties: "${props.name}" }Guards: a chained fan_out: row (for_each: present) must set collect: true;
collect: true requires for_each:; chained-discovery cycles are rejected at
load time. See cli/examples/discovered_custom_objects.yaml.
pipeline.nodes / pipeline.edges (topology mode)
Section titled “pipeline.nodes / pipeline.edges (topology mode)”An alternative to matrix: for pipelines that need fan-out, fan-in, or joins:
declare an explicit graph of typed nodes (source / transform / tee /
merge / join / sink) under pipeline.nodes, connected by
pipeline.edges ({ from, to, as? }). Topology mode is mutually exclusive
with matrix: — both non-empty is a load-time error. faucet run / validate
/ preview all understand it. See
Topology mode for the full grammar, the join:
node, state semantics, and runnable examples. A node entry accepts only its
kind’s keys — a misspelt key (confg:) is a load-time error — and ${vars.*} /
${sources.*} / ${sinks.*} resolve inside node overrides and transform nodes
as they do elsewhere.
What applies in topology mode. Each block is scoped to the node where it
makes sense. The per-page governance passes — pipeline.masking,
pipeline.quality, pipeline.contract, schema: — are enforced per sink node,
and resilience: applies to its writes. sla: keeps per-sink-node history under
{pipeline}::{node_id} (staleness and volume checks, like profiling:, need a
durable state: block, as in matrix mode); notifications: reports per sink node; lineage: emits
one job per sink node ({pipeline}.{node_id}) whose inputs are every source that
reaches it; catalog: records a dataset per source and per sink plus an edge for
each pair the graph connects. budget:, metadata_columns: and reconcile:
apply per sink node too. So do --dry-run / --limit, ${now.*} / --clock,
state:, and dlq: (a --dry-run counts would-be dead letters without writing them,
and a preview runs at-least-once). verify: and rollback: are refused (a graph sink has no
single source to verify against, and runs are undone per matrix row), and
usage: is accepted but not applied. See the
applies-per-node table for the detail.
faucet run, faucet schedule and faucet serve run a topology config as its
graph; commands that work on matrix rows (doctor, plan, backfill,
verify, rollback, mirror, change requests) refuse it with an explicit error.
Column lineage is the one thing deliberately withheld: with several inputs feeding a sink, the per-column derivation is not knowable from the graph, so the facet is omitted for a multi-input sink rather than guessed. Single-input sinks emit it as usual.
delivery: exactly_once is supported, with five requirements checked at load
time: exactly one source node, a replayable source, every sink idempotent, a
durable state:, and no dlq:. Each sink node carries its own commit watermark
and a restart resumes from the position furthest behind; every sink ahead of it
skips the replayed pages it already committed, so no sink is resumed past its own
progress or writes a page twice. See
Exactly-once delivery.
At-least-once resume is deliberately conservative. Each sink node owns a
bookmark under {pipeline}::{node_id}. Without exactly-once the source resumes
from a stored position only when the graph has exactly one source node and
every sink’s bookmark is identical; otherwise it replays in full (with a
warning). Bookmarks are compared for equality, never ordered — a resume position
is often structured (a CDC LSN map, a Kafka offset map), and an ordered “minimum”
over those can sit ahead of the true minimum and skip the lagging sink’s
records. Replaying costs duplicates on a non-idempotent sink; skipping would lose
data, so the trade is made in that direction. Use write_mode: upsert on the
sinks — or delivery: exactly_once — when a graph is resumed routinely.
Row selection
Section titled “Row selection”Register many rows, run a few. Selection resolves after expansion and never
changes a row’s state key ({name}::{row_id}), so bookmarks are identical across a full
run and any subset. It runs on run, validate, and preview via the flags in the
CLI reference. Four axes compose through one formula:
1. eligible = status gate ({mandatory, active} ∪ --status)2. narrowed = (eligible ∩ --tag) ∪ (--select / --only by id)3. parents = apply include_parents policy to narrowed4. run set = parents − (--skip)status: (readiness ladder) — a field on the row’s source (template or
source: override; deep-merges as a scalar). Default active, so existing configs are
unchanged. mandatory always runs; active runs by default; available/draft/
archived run only when their tier is added with --status. mandatory is removable
only by an explicit --skip <id>.
tags: — free-form ^[a-z0-9][a-z0-9_-]*$ labels on a row, union-merged with the
source template’s tags: (the one deliberate exception to array-replace). Tags narrow
within the eligible set (--tag); they never resurrect a parked row — raise --status
for that.
selection.include_parents: — the single policy deciding whether a selected row’s
parent: / depends_on: ancestor (not independently selected) is pulled in. off
(default, strict) errors naming each missing pair; eligible auto-includes
status-eligible ancestors (errors on a parked one); all includes any (warns on parked).
--select <id> by name always satisfies a dependency. Overridable with
--include-parents; env FAUCET_INCLUDE_PARENTS; precedence flag > env > config >
default.
matrix: - id: people source: { ref: hibob, status: active, config: { path: /v1/people } } tags: [core, daily] - id: payroll source: { ref: hibob, status: mandatory, config: { path: /v1/payroll } } tags: [finance] - id: audit source: { ref: hibob, status: available, config: { path: /v1/audit } } tags: [finance]
selection: include_parents: off # off (default) | eligible | all| Command | Runs |
|---|---|
faucet run cfg.yaml |
people, payroll |
faucet run cfg.yaml --tag finance |
payroll (audit is finance but available) |
faucet run cfg.yaml --status available --tag finance |
payroll, audit |
faucet run cfg.yaml --select audit |
audit (opt-in, forced by name) |
Unknown tokens, an empty run set, and a missing required ancestor are hard, fail-fast errors (no partial run).
A map of named auth providers, each { type, config } (type ∈ static /
oauth2 / oauth2_refresh / token_endpoint / google_service_account /
flow / oauth1). A connector references one with
auth: { ref: <name> } instead of inline auth; faucet builds each provider once
and shares it across every connector that references it (one token, single-flight
refresh). See the authentication cookbook.
auth: api: type: oauth2_refresh config: token_url: ${env:API_TOKEN_URL} client_id: ${secret:API_CLIENT_ID} client_secret: ${secret:API_CLIENT_SECRET} refresh_token: ${secret:API_REFRESH_TOKEN} persist: { path: "./state/auth" } # persist a rotated refresh_token across runsoauth2_refresh accepts persist: { path } to durably store a rotated
refresh token so later runs survive rotation. token_endpoint accepts
encoding: form (urlencoded body) and apply_as: { header, template } (place
the token in a custom header, e.g. a session cookie). See the
authentication cookbook.
google_service_account authenticates with a Google service-account key
(RFC 7523 JWT-bearer): key_json (the key file’s contents, e.g.
${secret:GOOGLE_SA_KEY}) or key_file (a path), scopes (required),
optional subject (domain-wide delegation), token_uri (defaults to the key’s)
and expiry_ratio. A malformed key fails at load time.
auth: google: type: google_service_account config: key_json: ${secret:GOOGLE_SA_KEY} scopes: ["https://www.googleapis.com/auth/analytics.readonly"]delivery
Section titled “delivery”Controls the delivery guarantee for every pipeline row.
delivery: at_least_once # default — no behaviour change# or:delivery: exactly_once| Value | Behaviour |
|---|---|
at_least_once |
Default. A crash between the sink write and the bookmark persist causes the page to be re-delivered on the next run. Downstream must tolerate duplicates. |
exactly_once |
Require at least effectively-once. Two mechanisms qualify: the atomic watermark (the sink durably records a per-page commit token — which embeds the page’s resume bookmark — atomically with the data; on resume the pipeline recovers the exact stream position from the sink’s watermark, or skips already-committed pages for legacy tokens), and keyed upsert (write_mode: upsert + key on an upsert-capable sink, any source). faucet validate prints which mechanism each row derives. |
Per-row override: set delivery: directly on a matrix row to override the top-level value for that row.
delivery: at_least_once # top-level default
matrix: - id: critical_row delivery: exactly_once # this row uses effectively-once - id: best_effort_row # inherits top-level at_least_onceRequirements for exactly_once
Section titled “Requirements for exactly_once”The config is accepted when either effectively-once mechanism is achievable and
rejected otherwise, at config-load time (faucet validate and faucet run). A
violation is a hard config error naming the limiting side — no run is started.
Keyed-upsert path (any source): the sink must be upsert-capable
(postgres, sqlite, mysql, mssql, mongodb, elasticsearch,
bigquery) and configured with write_mode: upsert (or delete) and a
non-empty key. No other requirement — no watermark is used.
Atomic-watermark path, all four conditions:
- Positional-replay source — the source must be one of:
postgres-cdc,mysql-cdc,mongodb-cdc,kafka. These emit a complete resume position on every page over an immutable log. Query-based sources are rejected because different data on replay would cause the pipeline to silently skip records it never wrote. - Idempotent sink — the sink must be one of:
sqlite,postgres,mysql,mssql,iceberg,bigquery,kafka,snowflake,redis,mongodb(MongoDB requires a replica set at run time). These sinks atomically commit both the data and a watermark token inside the same transaction or snapshot. - Durable state store — a
state:block is required, and it must be a durable backend (file,redis, orpostgres) —memoryis rejected. The pipeline stores the per-page sequence number alongside the bookmark; the watermark must survive a restart, so an in-memory store (lost on process exit) would silently re-deliver an already-committed page on resume. - No DLQ — a
dlq:block is incompatible with the atomic-watermark path in this version. (The keyed-upsert path permits a DLQ.)
See the Effectively-once delivery cookbook for a worked example and the full rationale.
schema
Section titled “schema”Optional pipeline-level block (a sibling of source / sink / transforms
/ state inside pipeline:) that declares one uniform policy for schema
drift — when an incoming page’s top-level shape diverges from the sink’s live
destination schema. Fully opt-in: with no block, sinks keep their existing
per-connector behaviour. See the Schema drift cookbook
for the full model, sink-support matrix, and per-sink nuances.
pipeline: schema: on_drift: warn # warn | evolve | ignore | quarantine | fail allow_type_widening: true # default true; only consulted by `evolve` on_incompatible: fail # fail | quarantine — `evolve` only (default fail) relax_nullability_on_missing: false # default false; `evolve` only source: { ... } sink: { ... }| Field | Default | Purpose |
|---|---|---|
on_drift |
warn |
Policy applied when drift is detected: warn (metric + log, write unchanged), ignore (drop unknown fields), fail (abort with a SchemaDrift error), quarantine (route drift-exhibiting rows to the DLQ, write the rest), evolve (apply additive/widening DDL, then write). |
allow_type_widening |
true |
Whether a lossless widening (integer → number, gaining nullability) counts as evolvable rather than incompatible. Only consulted by evolve. |
on_incompatible |
fail |
evolve only — action for an incompatible residue (narrowing / type swap): fail aborts, quarantine routes the offending rows to the DLQ. |
relax_nullability_on_missing |
false |
evolve only — whether a NOT NULL destination column absent from a page may have its NOT NULL constraint dropped. Default false: an omitted column is not evidence of optionality, so the constraint is left untouched (a genuinely-missing required value then fails at write time). Set true only to deliberately let omission relax nullability. Relaxation from an observed null in a present column (a widening) is unaffected. |
Detection is top-level only — a nested object is one column, so changes inside it are invisible.
Gates (validated at config-load time)
Section titled “Gates (validated at config-load time)”A violation is a hard config error naming the offending row; no run is started.
evolveneeds an evolution-capable sink — one ofpostgres,mysql,mssql,sqlite,bigquery,elasticsearch.icebergsupports detection but notevolve(blocked on upstreamiceberg-rust, #255); schemaless sinks have nothing to evolve. Both are rejected foron_drift: evolve.quarantineneeds adlq:block —on_drift: quarantine, orevolvewithon_incompatible: quarantine.quarantineis incompatible withdelivery: exactly_once(effectively-once forbids a DLQ).evolve/ignore/fail/warnall compose with effectively-once and withwrite_mode: upsert.
Against a schemaless sink (jsonl, csv, stdout, mongodb, redis, http, kafka,
s3, gcs, snowflake, parquet) any non-evolve policy is inert — the sink reports
no schema to diverge from.
contract
Section titled “contract”Optional pipeline-level block (a sibling of source / sink /
transforms inside pipeline:; no matrix-row override in v1) declaring a
data contract: a versioned promise about the pipeline’s output shape,
enforced per page after transforms and quality checks and before the sink
write. Requires the contract Cargo feature (in the default build). See the
Data contracts cookbook for the full model and
faucet schema contract for the block’s JSON Schema.
pipeline: contract: version: "1.0.0" # required, non-empty description: Orders feed. # optional metadata owner: data-platform # optional metadata on_breach: fail # fail (default) | quarantine | warn allow_extra_fields: true # default true fields: # required, non-empty; names unique - name: order_id type: string # string | integer | number | boolean | object | array required: true # default true nullable: false # default false min_length: 1 # string-only (with max_length) - name: status type: string enum: [open, shipped, cancelled] - name: amount type: number min: 0 # numeric-only (with max)| Field | Default | Purpose |
|---|---|---|
version |
— | Carried into breach errors, DLQ envelopes, and exports. Semver recommended (major = breaking, minor = additive). |
on_breach |
fail |
fail aborts on the first breach (nothing from the page is written); quarantine routes breaching records to the DLQ and writes the rest (requires a dlq: block — validated at load time); warn logs + counts but writes everything. |
allow_extra_fields |
true |
When false, an undeclared top-level key is a breach (extra_field). |
fields[] |
— | Per-field type + constraints: required, nullable, enum, pattern (string), min/max (numeric, inclusive), min_length/max_length (string, inclusive), description. |
A malformed contract (empty version, duplicate fields, invalid regex, empty or
type-mismatched enum, constraints on the wrong type, min > max) is a
config-load error — faucet validate catches it. fail/warn compose with
delivery: exactly_once; quarantine does not (effectively-once forbids a DLQ).
Inspect or export the contract with faucet contract.
masking
Section titled “masking”Optional pipeline-level block (a sibling of source / sink /
transforms inside pipeline:) declaring a PII detection + column-masking
policy. The masking pass runs first — before the quality, contract, and
schema-drift passes and before every sink write, the DLQ, and lineage sampling
— so PII never reaches a sink (including the DLQ) or an OpenLineage facet
unmasked. Masking is value-only and key-preserving: it never fails a run or
quarantines (no dlq: required). Requires the masking Cargo feature (in the
default build). See the masking cookbook for the full
model and faucet schema masking for the block’s JSON Schema.
pipeline: masking: description: Mask customer PII. # optional metadata key: ${vault:secret/faucet#mask_key} # optional — keyed HMAC-SHA256 for hash/tokenize rules: # required, non-empty; first match per field wins - name: emails # optional label (logs + metric); default rule_<n> match: # at least one of the three must be set value_detector: email # email | credit_card | ssn | phone | phone_loose | ipv4 action: { type: redact } # replace with `mask` (default "***") - match: { field_pattern: '(?i)^ssn$' } # regex over the field dot-path action: { type: hash } # HMAC-SHA256 (keyed) / SHA-256 (unkeyed) hex - match: { fields: [card] } # explicit dot-paths action: { type: partial, keep_last: 4 } # reveal only the last N chars applies_to: [warehouse] # scope to sink template name(s) / connector kind(s)| Field | Default | Purpose |
|---|---|---|
description |
— | Documentation metadata. |
key |
— | Secret for keyed HMAC-SHA256 hash/tokenize (deterministic + irreversible). Absent → unkeyed SHA-256 (deterministic but recomputable). Resolved after secrets, so ${vault:...} etc. work. |
rules[] |
— | Required, non-empty. Each rule = name (optional label) + match + action + optional applies_to. Evaluated in order; the first rule that matches a field wins. |
rules[].match |
— | At least one of field_pattern (regex over the dot-path), value_detector (email/credit_card/ssn/phone/phone_loose/ipv4, run over string values), fields (explicit dot-paths). A match on a container masks the whole subtree (redact replaces it; hash/tokenize/partial rewrite every non-null scalar inside it). |
rules[].action |
— | Tagged by type: redact (mask, default "***"; mask: null nulls the field), hash, tokenize (prefix), partial (keep_last default 4, mask_char default *; keep_last >= len masks everything). |
rules[].applies_to |
[] (all sinks) |
Scope the rule to specific sinks by template name (under pipeline.sinks:) or connector kind (e.g. bigquery). |
Detectors are conservative (fully anchored; credit_card requires a valid
Luhn checksum; ssn excludes never-issued ranges) so false positives stay
rare. A credit_card or phone_loose rule can match a JSON number, so with one
in the policy every number is written as a string, keeping each column one type
across pages (see the masking cookbook). hash/tokenize are deterministic → masked values stay joinable across
pipelines that share a key. A malformed policy (empty rules, an empty
match, an invalid regex, an empty tokenize prefix) is a config-load error —
faucet validate and faucet masking catch it.
faucet_masking_fields_total{pipeline,row,rule,action,detector}— one increment per masked field (detectorempty for name-based matches).
policy
Section titled “policy”Optional top-level block declaring a data-flow policy:
classifications that label columns and rules about which sinks a labelled
column may reach. Decided before any data moves (validate / policy /
plan / doctor report; run and the serve submit path refuse) and backed
at run time by value detectors on the real records. A --policy <file> merges
on top of this block; a deployment overlay’s policy: lands here. Requires
the policy Cargo feature (in the default build; implies masking).
faucet schema policy prints the JSON Schema.
policy: version: 1 description: PII stays in the EU. classifications: - label: pii fields: [email, phone] # full dot-path or leaf key field_pattern: "(?i)_?name$" # regex over the dot-path value_detector: email # email | credit_card | ssn | phone | phone_loose | ipv4 rules: - name: pii-eu when: { label: pii } # + optional sink_kind: [..], sink: { attr: [..] } require: { residency: [eu] } # sink attribute must be one of these mask: [hash, tokenize] # or the column reaches the sink masked on_runtime: fail # fail | quarantine (needs a dlq: block) - name: finance-no-prod-files when: { label: finance, sink_kind: [jsonl, csv], sink: { environment: [prod] } } deny: true| Field | Default | Meaning |
|---|---|---|
version |
1 |
Policy document version. |
classifications[].label |
— | The label a matching column earns. Several classifications may share one. |
classifications[].fields |
[] |
Column names — the full dot-path or its leaf key (ssn covers user.ssn). |
classifications[].field_pattern |
— | Regex over the column’s dot-path. |
classifications[].value_detector |
— | A masking detector run over string values at run time. |
rules[].when.label |
— | The label the rule governs. |
rules[].when.sink_kind |
[] (all) |
Only sinks of these connector kinds. |
rules[].when.sink |
{} |
Only sinks whose attribute has one of the listed values (a sink without the attribute is out of scope). |
rules[].require |
{} |
Sink attributes that must be present with a listed value. |
rules[].mask |
[] |
Masking actions (redact / hash / tokenize / partial) that satisfy the rule when the column reaches the sink masked. |
rules[].deny |
false |
Never satisfied. |
rules[].on_runtime |
fail |
What the runtime backstop does: fail the page, or quarantine the rows to the DLQ. |
Every classification needs at least one of fields / field_pattern /
value_detector; every rule needs a when.label and one of require /
mask / deny; two rules with one name are refused. Metric:
faucet_policy_violations_total{pipeline,row,rule,phase,action}.
execution
Section titled “execution”max_concurrent— one shared concurrency budget across roots and child fan-outs.on_error—continue(siblings finish; failed subtree skipped) orstop(abort pending and in-flight work on first failure).schedule— the order ready sibling rows queue for a permit:declared(default, matrix order) orlpt.
Dispatch order (schedule: lpt)
Section titled “Dispatch order (schedule: lpt)”With rows of very different sizes, total wall-clock depends on the order you
happened to list them in: a large object listed late becomes an idle tail while
the other slots sit empty. schedule: lpt (longest-processing-time-first)
starts the heaviest rows first, which is provably within 4/3 of the optimal
makespan — whereas largest-last, which declaration order can produce by
accident, is the worst case.
execution: max_concurrent: 8 schedule: lptmatrix: - id: contact weight: 15500000 # optional; `faucet discover` fills this inRows are ranked by weight, highest first. Ties and rows without a weight
keep declaration order, so dispatch stays deterministic and predictable from
the config alone; unweighted rows sort after weighted ones.
faucet discover writes a weight per generated row automatically, estimated
as rows × row width from each dataset’s row estimate and column types —
bytes moved, not row count, because a 970k-row × 3-column table is lighter
than a 122k-row × 100-column one. Set weight by hand when you know better.
The win depends on duration correlating with weight. For sources whose runtime is dominated by a server-side queue (a bulk export API, say), the estimate is a proxy rather than a prediction — LPT still cannot do worse than declared order, but it may not help as much.
Only the enqueue order changes: the same permit budget, the same on_error
behaviour, and children / depends_on rows keep their completion-gated
ordering.
Adaptive batch sizing
Section titled “Adaptive batch sizing”The optional adaptive_batch_size: sub-block enables the AIMD controller that
auto-tunes the effective write batch size from observed sink latency and error
rate. Default enabled: false (opt-in).
execution: adaptive_batch_size: enabled: true # master switch controller: aimd # only "aimd" is supported in v1 min: 100 # lower bound (rows) max: 50000 # upper bound; inert above the source page size increase_step: 250 # additive growth per clean batch decrease_factor: 0.5 # multiplicative shrink on error/high latency (0, 1) cooldown_batches: 5 # batches to skip after a shrink target_latency_ms: null # optional write-latency target (ms) latency_window: 10 # rolling window size for p50 latency error_threshold: 0.01 # per-batch error rate that triggers a shrink respect_source_max: true # cap at source page size (see Caveats) log_every: 50 # tracing::info every N adjustmentsKey caveats:
- Error-driven shrink requires a
dlq:block. Without one the controller sees no per-row errors; onlytarget_latency_mscan drive shrinks. - Effective ceiling = source page size. In v1 the controller reslices pages
in-memory — it cannot buffer across pages. Setting
maxhigher than the sourcebatch_sizeis harmless but inert. Raise the sourcebatch_sizeto allow bigger write batches. - No-op for per-record sinks.
jsonl,csv, andstdoutwrite one record at a time; the controller adjusts normally but the write granularity is unchanged.
See the Adaptive batching cookbook for a
full worked example, the AIMD trajectory, and the four Prometheus metrics
(faucet_pipeline_adaptive_batch_*).
resilience
Section titled “resilience”Optional top-level block giving the pipeline one declarative place to configure
retry, a circuit breaker, and per-row poison-pill handling. Fully opt-in: with
no resilience: block, sink writes are not retried and source connectors keep
their built-in retry defaults. See the
Resilience cookbook for the full model, composition
notes, and metrics.
resilience: retry: max_attempts: 5 # total tries including the first (1 = no retry) backoff: exponential # none | fixed | exponential base_ms: 200 max_ms: 30000 # per-sleep cap, before jitter jitter: true retry_on: [http_5xx, rate_limited, connection, timeout] circuit_breaker: consecutive_failures: 5 cooldown_secs: 60 poison: max_row_attempts: 3 action: dlq # dlq | drop | failretry—max_attempts(default5;1disables retry),backoff(none/fixed/exponential, defaultexponential),base_ms(default200),max_ms(per-sleep cap, default30000),jitter(defaulttrue, applies[0.5, 1.5)decorrelated jitter).retry_on— the transient error classes that are retried:http_5xx(HTTP 5xx),rate_limited(HTTP 429 / rate-limit signals),connection(DNS / refused / reset),timeout(request timeouts). Omit for all four; an empty list is rejected at config load.circuit_breaker—consecutive_failuresconsecutive fully-failed pages open the breaker and fail the run with aCircuitOpenerror;cooldown_secsis advisory forfaucet schedule(delays the next cron tick).poison— per-row DLQ-path handling:max_row_attemptsre-submits a still-failing retriable row before the terminalaction—dlq(requires adlq:block),drop, orfail.
The rest source’s legacy max_retries / retry_backoff fields win when set
explicitly; otherwise the injected policy’s max_attempts + base apply (its
retry_on / max / jitter are inert on REST, honored on xml / graphql
and on every sink-side write).
For APIs that signal a rate limit with a 4xx other than 429 plus a code in the
body, inside a successful response, or with a reset time in a header, the
rest and graphql sources’ retry_on_response list turns matching responses
into throttling (counted in faucet_source_throttled_total and retried up to
max_retries times in a row):
| Field | Default | Meaning |
|---|---|---|
status |
[] |
Statuses the rule applies to (empty = any non-2xx, or any status with match_success). |
match_success |
false |
Also match 2xx responses (GraphQL THROTTLED errors, x-ratelimit-remaining: 0). Requires body_path or header. |
body_path |
— | JSONPath into the JSON body; any match equal to one of values satisfies the rule. |
values |
[] |
Matching values (numbers and strings compare by text). Required with body_path. |
header |
— | A header that must be present (compared with values when there is no body_path). |
backoff_from |
— | Where the response states the wait: header (name, unit), header_json (name, path, unit), body (path, unit) or cost_bucket (requested, available, restore_rate paths). unit: seconds, ms, minutes, epoch_s, epoch_ms, rfc3339; absolute instants are measured from the response Date header. Missing or unreadable → the next source below. |
backoff_secs |
— | Fixed wait; otherwise Retry-After, otherwise the exponential retry_backoff. |
max_wait_secs |
3600 |
Longest wait backoff_from may ask for; a longer one fails the run with the reset named. |
On rest, rules are checked before tolerated_http_errors and apply to data
pages, async_job requests and discovery requests; on graphql they are
checked before the response’s errors[], and a match retries the whole request.
See the
resilience cookbook.
Source fields: incremental keys, body targets, CSV nulls
Section titled “Source fields: incremental keys, body targets, CSV nulls”A few connector-level fields change how records are selected and decoded. They
live in the source’s config, not at the top level; faucet schema source rest
prints the full list.
rest — incremental replication
| Field | Default | Meaning |
|---|---|---|
replication_key |
— | A top-level field (updated_at), a dot path (fields.updated, items.0.date; a literal top-level field of that name wins), or a JSON Pointer (/fields/updated) for names containing dots. With async_job it is injected into the submit query, so a pointer is rejected there. |
on_missing_key |
keep |
A record whose key is missing or null: keep (counted in faucet_source_replication_key_missing_total, warned once per run), drop (counted and warned), or fail. |
replication_bind.path |
— | into: body only: a JSON Pointer into body (/filterGroups/0/filters/0/value) instead of a top-level name. Set exactly one of name / path; the pointer must resolve to an existing value or a new key of an existing object. |
replication_bind.value_type |
string |
number writes the rendered bookmark as a JSON number. |
window.lower / window.upper .path, .value_type |
— | Same as the two above, for window bounds (e.g. /dateRanges/0/startDate). |
window.*.template placeholders |
${window} |
${window} is the bind’s own boundary; ${window.start} / ${window.end} render the window’s start and granularity-adjusted end in any bind, so one bind can carry both (a report query’s segments.date BETWEEN '${window.start}' AND '${window.end}'). |
window.upper |
— | Optional when lower.template contains ${window.end}; only lower is then applied. Omitting it otherwise is rejected (unbounded above). |
rest — pagination and CSV
| Field | Default | Meaning |
|---|---|---|
pagination.body_cursor_field (CursorInBody) |
— | A top-level body field, or a JSON Pointer such as /variables/after. |
pagination.offset_field / limit_field (OffsetInBody) |
— | Likewise a field name or a JSON Pointer. |
csv_null_values |
[] |
CSV fields equal to a listed string decode as null ([""] for bulk-export results that write null as an empty field). Applies to response_format: csv and parse: { format: csv } decode steps. |
With LinkHeader / NextLinkInBody pagination, next-page links are resolved
against the request URL (relative links work), and query binds are sent on the
first request only — the link already carries them.
csv source
| Field | Default | Meaning |
|---|---|---|
null_values |
[] |
Fields equal to a listed string decode as null. Headers are never mapped. |
Optional dead-letter queue under pipeline: (or per matrix row). See the
DLQ cookbook.
| Field | Type | Default | Description |
|---|---|---|---|
sink |
connector | — | Where failed and quarantined records go (usually jsonl). |
on_batch_error |
propagate | dlq_all |
propagate |
When a whole sink write fails: fail the run, or send every row of it to the DLQ and continue. dlq_all is refused on a sink whose failed write can land part of a batch — see batch atomicity. |
allow_duplicates_on_dlq_all |
bool | false |
Accept dlq_all on such a sink anyway: rows that already landed also go to the DLQ, so a replay writes them twice. |
max_failures_per_page |
int | — | Fail the run once one page sends more records than this to the DLQ. |
max_failures_total |
int | — | Fail the run once more records than this have gone to the DLQ in total. |
include_original_payload |
bool | true |
Keep each failed record’s payload in its envelope. |
Optional top-level block declaring a freshness/volume SLA for the pipeline
(evaluated after every root invocation by faucet run / schedule / serve /
mirror). Fully opt-in and never fails a run: violations emit the
faucet_pipeline_sla_violations_total{pipeline,row,kind} counter and a
structured warning, and faucet doctor reports staleness / baseline health.
See the SLA monitoring cookbook.
sla: max_staleness_secs: 7200 # stale when a run ends 2h+ after the last success min_rows_per_run: 1 # a successful run writing fewer records violates volume_anomaly: # learned-baseline anomaly detection method: zscore # zscore | iqr sensitivity: 3.0 # zscore default 3.0; iqr default 1.5 min_history: 5 # successful runs before detection starts window: 20 # rolling baseline size max_lag_bytes: 1073741824 # the source may be at most 1 GiB behind its head| Field | Type | Default | Description |
|---|---|---|---|
max_staleness_secs |
int | — | Maximum seconds since the last successful run. Evaluated when a run ends (a failure, or a success arriving later than this after the previous one) and by faucet doctor. A pipeline that stops running emits nothing — alert on faucet_pipeline_sla_last_success_unix_seconds. Requires a state: block. |
min_rows_per_run |
int | — | Static volume floor for a successful run (catches a source silently returning nothing). Stateless — works without a state: block. |
volume_anomaly.method |
zscore | iqr |
zscore |
How a successful run’s volume is compared against the rolling baseline of recent successful runs. |
volume_anomaly.sensitivity |
float | 3.0 / 1.5 |
zscore: max |x − mean| / std. iqr: Tukey fence multiplier. Defaults per method. |
volume_anomaly.min_history |
int | 5 |
Cold-start guard: successful runs of history required before detection fires (min 2). |
volume_anomaly.window |
int | 20 |
Rolling window of successful-run volumes kept as the baseline (≥ min_history). |
max_lag_bytes |
int | — | Maximum source lag in bytes — unread Postgres WAL or MySQL binlog (#733). |
max_lag_events |
int | — | Maximum source lag in events — unconsumed Kafka messages, SQL Server change transactions. |
max_lag_seconds |
int | — | Maximum age of the oldest change the source has not delivered — MongoDB, SQL Server, Oracle, Kinesis, DynamoDB Streams. |
The max_lag_* thresholds are checked against the lag the source reports when
each run ends (kind="lag" on the violations counter), by faucet status
(--probe queries the source now) and by faucet doctor’s lag probe. A unit
the source does not report is not checked, and a source with no notion of a
head (a query source) never breaches them. They need no state: block.
At least one check must be set. max_staleness_secs /
volume_anomaly require a state: block (enforced at config load); the
history is persisted next to the pipeline’s bookmarks under
{name}::{row}::__sla__. With a memory state store the history only
persists within a single faucet schedule / serve process. Schema:
faucet schema sla.
Per-row override (#679). A matrix row may carry its own sla:, which
replaces the top-level block for that row’s invocations (the state-store gate
applies to it the same way). This is also where a
deployment overlay’s
per-stream sla lands.
sla: { min_rows_per_run: 1 } # every row …matrix: - id: invoices sla: { max_staleness_secs: 3600 } # … except this one - id: customersprofiling
Section titled “profiling”Optional top-level block declaring learned column profiles with drift
detection (#708): every root invocation profiles the records it wrote (null
rate, type mix, distinct estimate, numeric / string summaries, top values)
into a bounded-memory sketch, compares the result with a rolling baseline of
earlier runs kept in the state: store, and reports a statistically
significant change per column — no thresholds to write. See the
column profiling cookbook.
profiling: min_history: 5 # baseline runs before detection starts window: 20 # rolling baseline size method: zscore # zscore | iqr (numeric metrics) new_value_min_share: 0.05 # share a new value / type must reach to be reported psi_threshold: 0.2 # value-distribution shift threshold categorical_max_distinct: 100 exclude: ["_faucet_*"] on_drift: warn # warn | notify | fail| Field | Type | Default | Description |
|---|---|---|---|
columns |
list | all | Only these top-level columns. |
exclude |
list | ["_faucet_*"] |
Exact names or prefix* globs to skip. |
max_columns |
int | 200 |
Cap on profiled columns per run. |
top_values |
int | 10 |
Frequent values kept per categorical column; 0 disables. |
categorical_max_distinct |
int | 100 |
Distinct estimate above which a column publishes no example values. |
window |
int | 20 |
Rolling baseline size (≥ min_history). |
min_history |
int | 5 |
Baseline runs before detection starts (≥ 2). |
method |
zscore | iqr |
zscore |
Test for the numeric metrics (null rate, distinct, mean, min, max, string length). |
sensitivity |
float | 3.0 / 1.5 |
z-score threshold, or the IQR fence multiplier. |
new_value_min_share |
float | 0.05 |
Share a previously unseen value or JSON type must reach. |
psi_threshold |
float | 0.2 |
Population stability index above which the value distribution counts as drifted. |
on_drift |
warn | notify | fail |
warn |
Log + metric; plus a profile_drift notification; plus the run is reported failed (data already written). |
Requires a state: block (the baseline lives under
{name}::{row}::__profiling__); a memory store only baselines within one
process. Profiling runs after transforms and masking, for real root
invocations only. A matrix row’s own profiling: replaces the top-level
block for that row. Inspect and re-baseline with
faucet profiling. Schema: faucet schema profiling.
reconcile
Section titled “reconcile”Opt-in completeness reconciliation (#502): after a successful root run,
fetch an authoritative row count and fail the run on a shortfall beyond
tolerance — a guard against silent truncation quietly replacing good data with
less (especially under write_mode: overwrite).
reconcile: count: # a source that returns the authoritative count type: postgres config: connection_url: ${secret:PG_URL} query: "SELECT count(*) AS n FROM orders WHERE updated_at >= '${now.date}'" count_field: n # required when the probe row has more than one numeric field tolerance_pct: 0.0 # allow this % shortfall before failing (default 0) timeout_secs: 300 # time the probe may take to return its count (default 300)The count probe is any faucet source (a SQL count(*), an OData $count
endpoint via rest, …); its first returned record supplies the count. A probe
that has not returned within timeout_secs fails the run, so raise it for a
count that is slow to compute. The run
fails when rows_written < authoritative × (1 − tolerance_pct/100). Compares
rows written to the destination, so it is most meaningful for straight
loads / full-refreshes. The count describes one dataset, so reconcile: is
refused on a config with more than one root row or a partitioned row (each
invocation would be compared with the whole dataset’s count). Schema: faucet schema (the reconcile block).
verify
Section titled “verify”Opt-in content verification (#701): after every successful root run,
compare the destination to the source by key — key ranges by digest
(server-side where both backends share an algorithm, so matching ranges ship
no rows), differing ranges bisected down to the keys — and fail the run on a
mismatch. faucet verify runs the same comparison on demand.
verify: key: [id] # default: the sink's upsert key (required otherwise) columns: [name, amount] # default: every column on either side exclude: ["_faucet_*"] # names or `prefix*` globs (default) destination: # default: the sink's own read-back (SQL sinks) type: postgres config: { connection_url: ${secret:PG_URL}, query: "SELECT * FROM orders" } ranges: 16 # first-pass key ranges leaf_rows: 1000 # bisect a differing range down to this many rows max_differences: 1000 # report cap (the count keeps going) max_rows_scanned: 5000000 # stop (truncated report) after this many rows normalize: { float_tolerance: 0.0, timestamps: true, numeric_strings: false } after_run: true # verify after every successful root run fail_on_difference: true # a mismatch fails the run repair: false # re-sync differing keys through the sink first allow_delete: false # let a repair delete destination-only rowsThe source side runs through the row’s transforms and masking, so a
deterministic mask matches on both sides. See the
verification cookbook. Schema: faucet schema verify.
rollback
Section titled “rollback”Opt-in undoable runs (#706): stamp the _faucet_run_id column, journal
the before-image of every key an upsert/delete run touches (in the write’s own
transaction), keep the table an overwrite replaces as <table>__faucet_prev,
and record the pre-run bookmark, so faucet rollback --run <id> can undo the
run and rewind the sync.
rollback: enabled: true journal: true # before-images for upsert / delete runs keep_previous: true # keep the replaced table of an overwrite retain: 10 # undoable runs kept per rowRequires a durable state: block (not memory) and a rollback-capable sink
(postgres / sqlite / mysql in column mode); refused at load time
otherwise, or when metadata_columns.enabled is false. faucet run prints
each row’s run id. See the rollback cookbook.
Schema: faucet schema rollback.
Cost & usage accounting (#704) — the pricing table estimates are computed from. Accounting itself is always on: every invocation reports records, estimated bytes, round trips and connector-reported cost signals, priced against these rates. Every rate has a shipped default (public list prices, USD).
usage: pricing_file: ./pricing.yaml # optional; merged under the inline table; relative to the config file pricing: currency: USD egress_per_gb: 0 # bytes read when source + sink are not both local files object_storage: { read_per_1k_requests: 0.0004, write_per_1k_requests: 0.005 } warehouse: bigquery_per_tib_scanned: 6.25 bigquery_streaming_per_gib: 0.05 snowflake_per_credit: 3.0 hosted_elt_per_million_rows: 15 # the `hosted_equivalent` comparison rateRecording across runs (for faucet usage / GET /v1/usage) rides the
catalog store or a faucet serve history backend. A config
submitted to faucet serve may not set pricing_file. See the
usage cookbook. Schema: faucet schema usage.
budget
Section titled “budget”Run budgets (#703): hard ceilings on what one invocation may move.
budget: max_records: 1000000 # the page that would cross it is refused whole max_bytes: 5368709120 # estimated bytes written, same rule max_duration_secs: 1800 # cancels cooperatively at the next page boundary allowed_sinks: [warehouse] # sink template names / connector kinds; checked before anything runsA refused page never lands and the bookmark stays put; the run fails with
budget_exceeded. faucet run --max-records / --max-bytes / --max-duration-secs / --allowed-sink merge with the block (the stricter of
each ceiling, the intersection of the sink lists). Applies to run,
schedule and serve; a backfill is bounded by its window instead. See the
usage cookbook. Schema: faucet schema budget.
The execution pool faucet serve runs the config in (#957):
pool: private # lowercase letters, digits, `-`, `_`; at most 63 charactersOnly a replica started with --pool private claims the run, its shards and
its re-claims. A request’s own pool (POST /v1/runs, a template run, a
trigger’s run.pool, …) wins; with neither, the run goes to default. A
source template may carry pool: too, and the composed config inherits it.
A pool no live replica serves is refused at submit time with 422. Ignored
by faucet run, schedule, mirror and backfill. See
Execution pools.
pool_override says whether a run of the config as a registered template may
ask for another pool: false (the default) refuses one, true allows any, a
list allows those pools. It limits template runs a caller or a tenant starts;
the server’s own triggers, schedules and fan-outs are not limited by it. A
source template may carry pool_override: too.
pool: privatepool_override: [private, gpu]notifications
Section titled “notifications”(requires the notify build feature)
A list of rules that fan pipeline lifecycle / health events out to Slack,
PagerDuty, or a signed webhook. Events: run_failure, run_success,
sla_breach, circuit_open, contract_abort, dlq_threshold,
scheduler_stuck, profile_drift, change_requested, budget_exceeded. Fires from every runtime; delivery never
fails a run.
notifications: - name: oncall on: [run_failure, circuit_open, contract_abort] dedupe_window_secs: 300 # optional leading-edge coalesce min_severity: error # optional floor: info|warning|error|critical channel: type: pagerduty # slack | pagerduty | webhook — {type, config} config: routing_key: "${env:PAGERDUTY_ROUTING_KEY}"Per-rule fields: name (unique), on (event kinds; empty = all),
min_severity, dedupe_window_secs, dlq_threshold (min DLQ rows for the
dlq_threshold event), and channel ({ type, config }). Channel secrets
should come from ${env:...} / ${secret:...} so they are log-redacted. See
the Notifications cookbook for channel details,
metrics, and faucet notify test. Schema: faucet schema notifications.
The webhook channel additionally takes headers, hmac_secret /
signature_header, and extra_fields (static values merged into the emitted
body; a key colliding with a faucet-emitted field is rejected at load time). Its
payload carries run_id / invocation_id / started_at / finished_at /
duration_secs alongside the event, so it can drive an external job-status
callback — under faucet serve, run_id is the id returned by POST /v1/runs.
See the payload table.
partition
Section titled “partition”Split a row into N independent invocations over a chunked range (#479), each
scoped by ${partition.*} tokens substituted into the connector configs. Set at
the top level (applies to every root row) or on an individual matrix row, which
overrides it.
partition: kind: integer # integer | timestamp | offset from: 0 to: 1000000 # or a probe: { from_source: {…}, value_path: "$.max_id" } chunk_size: 10000 bounds: inclusive # integer only — REQUIRED, no default to_unbounded: false # defaults ON when `to` is discovered; the last chunk's `end` is then the largest integer| Kind | Fields | Tokens |
|---|---|---|
integer |
from, to, chunk_size, bounds, to_unbounded |
start, end, index, id |
timestamp |
from, to, chunk_size, timezone |
start, end, start_date, end_date, start_unix, end_unix, index, id |
offset |
total, chunk_size |
offset, limit, index, id |
bounds has no default — inclusive and half-open differ by one at every
boundary, and picking wrong silently duplicates or drops a record per chunk.
total (a count) exists only on offset and to (a key) only on integer, so
the two cannot be confused.
Chunks are ordinary sibling rows: they share execution.max_concurrent, get
per-chunk state keys, and obey execution.on_error. A partitioned row cannot be
referenced by another row’s parent: or depends_on:. Schema: faucet schema partition. See Parallel range partitioning.
mirror
Section titled “mirror”(Formerly replication: — still accepted, with a deprecation warning; renamed in #670 so replication means only bookmark-based incremental reads.)
Present only when you run faucet mirror. It turns the
main pipeline (whose source is a CDC connector) into a snapshot→CDC mirror by
adding a one-time bulk-read snapshot source. faucet run ignores this block, the
same way it ignores schedule:.
mirror: mode: snapshot_then_cdc # REQUIRED. Only mode in v1. continuous: true # After the snapshot, keep streaming CDC until SIGTERM. Default true. snapshot: # REQUIRED. The one-time bulk-read source. source: type: postgres # A non-CDC query reader of the same upstream DB. config: connection_url: ${env:SOURCE_PG_URL} query: "SELECT * FROM public.orders"| Field | Type | Default | Description |
|---|---|---|---|
mode |
snapshot_then_cdc |
required | Replication strategy. Only snapshot_then_cdc exists in v1: capture the CDC position, bulk-snapshot the table, then stream CDC from that position. |
snapshot.source |
connector | required | A non-CDC bulk-read source (e.g. postgres / mysql / mongodb running a query) pointing at the same upstream database. Back-fills the destination through pipeline.sink before CDC starts. |
continuous |
bool | true |
When true, keep streaming CDC after the snapshot completes until Ctrl-C / SIGTERM; a transient CDC-phase failure is logged, backed off (capped, reset on success), and resumed from the persisted bookmark rather than crash-exiting. When false, drain CDC once and exit (surfacing a transient error as a non-zero exit). |
Requirements (enforced at config-load time, also reported by faucet validate):
pipeline.sourcemust be a CDC connector —postgres-cdc,mysql-cdc, ormongodb-cdc(the capture-capable set).pipeline.sinkshould usewrite_mode: upsertwith akeyfor a true mirror; an append sink validates with a warning (boundary duplicates are possible).- A durable
state:backend is required (file/redis/postgres) —memoryis rejected, since the snapshot→CDC handoff and resume depend on the persisted phase marker and bookmark. - No
matrix:— a mirror is one pipeline; mirror several tables withtables:(below). - For
postgres-cdc, a permanent replication slot (slot_type: permanent, the default) is required so WAL is retained across the snapshot.
See the replication cookbook for the correctness model (capture-before-snapshot + upsert idempotency), the resume behaviour, and the per-database log-retention caveats.
Multi-table mirror (mirror.tables)
Section titled “Multi-table mirror (mirror.tables)”With a tables: block the mirror replicates a set of tables over one change
stream (#731). pipeline.sink becomes a template (the table is filled in per
table) and snapshot.source the discovery connection.
| Field | Type | Default | Description |
|---|---|---|---|
tables.include |
list of globs | ["*"] |
Discovered table names to mirror (* any run, ? one character). |
tables.exclude |
list of globs | [] |
Removed from include. |
tables.new_tables |
follow / ignore |
follow |
Snapshot and stream a matching table created after the mirror started. |
tables.discover_interval_secs |
int | 300 |
How often discovery re-runs to notice created / dropped tables (0 = only when the stream shows an unknown table). |
tables.without_primary_key |
refuse / append |
refuse |
A table with no primary key is refused (shown in status) or mirrored append-only. |
tables.destination |
mapping | per sink kind | Sink-config patch per table; strings may use {table}, {table_name}, {schema}. Required for sinks without a single table field (file sinks). |
tables.on_table_error |
pause / fail |
pause |
A table whose pipeline keeps failing is paused (the rest continue) or fails the mirror. |
tables.max_table_failures |
int | 3 |
Consecutive failed cycles before a table is paused. |
tables.retry_paused_secs |
int | 300 |
Seconds before a paused table is re-snapshotted (0 = stay paused until restart). Every start of faucet mirror — including each one-shot (continuous: false) run — retries paused tables. |
tables.lag_warning_secs |
int | 300 |
Flag an active table as lagging past this many seconds without a confirmed position. |
per_table.<table>.key |
list | discovered PK | Upsert key. |
per_table.<table>.write_mode |
string | template’s, else upsert |
Destination write mode. |
per_table.<table>.sink |
mapping | — | Extra sink-config patch (after destination). |
per_table.<table>.snapshot |
mapping | — | Snapshot-source config patch (after the discovered selection). |
per_table.<table>.schema_drift |
schema: block |
top-level schema: |
Drift policy for this table. |
snapshot.concurrency |
int | 4 |
Tables snapshotted in parallel. |
snapshot.shards |
int | 0 |
Primary-key ranges per table snapshot (single-column integer key, a source with shard: support). |
Requirements: a CDC source that routes records to tables (postgres-cdc,
mysql-cdc, mongodb-cdc with a database scope, mssql-cdc, oracle-cdc,
dynamodb in mode: streams), a snapshot source that supports
discovery, per-transaction CDC pages (no
batch_size: 0), and a durable state:. State: the marker lives at
{name}::__replication__, each table’s position at {name}::{table}. See the
cookbook.
backfill
Section titled “backfill”Optional defaults for faucet backfill — the range
itself always comes from the command line. faucet run ignores this block, the
same way it ignores schedule: / mirror:. Whenever the block is
present, faucet validate also checks that at least one root source references
a ${backfill.*} / ${now.*} scoping token (an unscoped source would replay
identical data into every window).
backfill: window: 1d # default --window: 45s / 30m / 6h / 1d / 1w concurrency: 4 # default --concurrency (max units in flight); default 1 timezone: America/New_York # default --timezone (IANA); default UTC| Field | Type | Default | Description |
|---|---|---|---|
window |
string | — (whole range as one unit) | Chunk duration for the requested range. |
concurrency |
int ≥ 1 | 1 |
Max concurrently-running window units. |
timezone |
string | UTC |
IANA zone for date boundaries and ${now.*} rendering. |
faucet schema backfill prints the JSON Schema. See the
backfill cookbook for the token table, resume
semantics, and the HTTP endpoint.
schedule
Section titled “schedule”Present only when you run faucet schedule. Absent configs are rejected by that
command with a hint to use faucet run instead. All fields except cron are
optional.
schedule: cron: "0 2 * * *" # REQUIRED. Standard 5-field cron, or 6-field with leading seconds. timezone: "UTC" # IANA timezone name. Default UTC. overlap_policy: skip # skip | queue | forbid. Default skip. max_runs: null # null = run forever; N = exit 0 after N successful runs. max_consecutive_failures: null # null = never exit on failure; N = exit non-zero after N straight failures. on_failure: continue # continue | stop. Default continue. start_immediately: false # Run once on startup before waiting for the first tick. Default false. run_timeout_secs: null # Per-run wall-clock kill switch (seconds). Timed-out runs count as failed. shutdown_grace_secs: 30 # SIGTERM: wait this long for the in-flight run before aborting. Default 30.| Field | Type | Default | Description |
|---|---|---|---|
cron |
string | required | 5-field standard Unix cron (MIN HOUR DOM MON DOW) or 6-field with a leading seconds field (SEC MIN HOUR DOM MON DOW). Validated at load time. |
timezone |
string | "UTC" |
IANA timezone name (e.g. "America/Los_Angeles", "Europe/Berlin"). Affects how the cron expression is interpreted. |
overlap_policy |
skip | queue | forbid |
skip |
What to do when a tick fires while a run is already in flight. skip drops the tick; queue buffers one missed tick (in-memory only, lost on restart); forbid exits non-zero. |
max_runs |
integer | null | null |
Stop the scheduler cleanly (exit 0) after this many successful runs. null means run forever. 0 is rejected as a config error. |
max_consecutive_failures |
integer | null | null |
Exit non-zero after this many consecutive failed runs without a success in between. A successful run resets the counter. null means never exit on failures alone. |
on_failure |
continue | stop |
continue |
stop exits non-zero immediately after the first failed run. continue keeps scheduling; use max_consecutive_failures to bound sustained outages. |
start_immediately |
bool | false |
When true, the first run fires right on startup before the cron clock reaches its first tick. |
run_timeout_secs |
integer | null | null |
Per-run time limit in seconds. A run that exceeds this is cancelled at its next page boundary (flushing its sinks, within shutdown_grace_secs) and counts as a failure. null means no timeout. |
shutdown_grace_secs |
integer | 30 |
On SIGTERM/SIGINT, wait this many seconds for the in-flight run to finish before forcibly aborting it. |
Validation: faucet validate pipeline.yaml checks the schedule: block at parse time — bad cron
syntax, unknown timezone names, max_runs: 0, and a cron expression that can never fire all produce
a clear config error: schedule: … message before any run starts.
See the scheduling cookbook for worked examples, the DST/timezone details, the overlap-policy decision tree, and the full Prometheus metric set.
lineage
Section titled “lineage”Optional. When present, every pipeline run emits OpenLineage RunEvents
describing the job, its input/output datasets, inferred schemas, and column-level lineage. Emission
never fails a run — transport errors are logged and counted but do not propagate.
lineage: namespace: prod.warehouse # REQUIRED. Logical namespace for all jobs and datasets. transport: # REQUIRED. Where to send events. type: http # http | file | kafka (kafka requires lineage-kafka feature) config: url: ${env:MARQUEZ_URL} job_name: ${name}::${row_id} # Default. Resolved per matrix row at run time. include_schema_facet: false # Emit DatasetFacets.schema (inferred from a sample). include_column_lineage: false # Emit column-level lineage where statically derivable. include_source_code_facet: false # Emit resolved config as a sourceCode job facet (warns; may expose secrets). emit_on: start: true running: false # RUNNING heartbeats; see heartbeat_interval. complete: true fail: true abort: true sample_records: 100 # Max records sampled for schema/column facets. heartbeat_interval: 30 # Seconds between RUNNING heartbeats (when emit_on.running is true).See the Lineage cookbook for the full field reference, the three
transports (HTTP, file, Kafka), the column-lineage support matrix, schema-facet behavior, and
the Prometheus metrics (faucet_lineage_events_total, etc.).
metadata_columns
Section titled “metadata_columns”Optional (#510). Stamp _faucet_* run/lineage metadata columns onto every row
via a connector-agnostic sink decorator (works for any sink). Off unless
present.
metadata_columns: prefix: "_faucet" # column-name prefix (default) columns: [extracted_at, loaded_at, run_id, source] # or add `sequence`| Field | Default | Description |
|---|---|---|
enabled |
true |
Master switch (toggle off without removing the block). |
prefix |
_faucet |
Column-name prefix ({prefix}_{column}). |
columns |
[extracted_at, loaded_at, run_id, source] |
Subset of extracted_at / loaded_at (write time), run_id, source (connector kind), sequence (monotonic per-run ordinal). |
extracted_at is captured at sink-decorator time (≈ loaded_at in this model).
Non-object records pass through unchanged.
Before the first write, a column-mapped destination that already exists is
checked for the metadata columns: missing ones are added (TEXT; sequence an
integer) through the sink’s schema evolution, and a sink that cannot add
columns fails the run rather than dropping them. A source field with a
metadata column’s name is replaced, with a one-time warning.
catalog
Section titled “catalog”Optional. When present, faucet run / schedule / mirror record every
successful root invocation into the Data Movement Catalog —
the persistent, cross-run store of datasets, schema timelines, volume/freshness
stats, and lineage edges. Recording never fails a run. faucet serve
ignores this block: it records into its --history backend automatically.
Requires a build with the catalog feature (in --features full).
catalog: url: sqlite:./faucet-catalog.db # REQUIRED. sqlite:<path> | postgres://… | memory sample_records: 100 # Records sampled per side for schema inference. datasets: # Owners + declared consumers (#707), optional. - dataset: file://./out/records.jsonl # canonical URI (as `faucet catalog datasets` prints it) or the id owners: [data-platform] consumers: - name: records-dashboard kind: dashboard # free-form contact: "#analytics" columns: [id, name] # what it reads; empty = every columndatasets[] annotations are merged into the catalog after every run that
touches the dataset (owners replace, consumers upsert by name) and are what
faucet plan --impact names; the same annotation can
be made with faucet catalog annotate or POST /v1/catalog/datasets/{id}/consumers.
SQL stores additionally require the matching serve-history-sqlite /
serve-history-postgres build feature. Browse the store with
faucet catalog, the /v1/catalog/*
HTTP endpoints, or the web console’s Datasets / Lineage views.
Schema: faucet schema catalog.
local_outputs
Section titled “local_outputs”Optional. Retention policy for the local files this pipeline’s sinks write —
jsonl, csv, and parquet paths on the local filesystem. Requires a build with the
catalog feature (in --features full).
local_outputs: retention_days: 3 # override the runtime default (7) for this pipeline's outputs track: true # record them at all (default true)Without the block a pipeline still records its local outputs — so they can be
listed in the console and reclaimed — and inherits the runtime’s default window
(--local-output-retention-days / FAUCET_LOCAL_SINK_OUTPUT_RETENTION_DAYS,
itself 7 days). retention_days: 0 keeps this pipeline’s outputs forever; the
window is measured from the last write, so an output a local run keeps
refreshing never expires underneath it.
track: false opts the pipeline out of the ledger entirely. Because the GC only
ever deletes recorded paths, that also means its outputs are never
automatically deleted — an opt-out of the bookkeeping, not just of the listing.
Recording needs somewhere to record to: the catalog: block’s store for
faucet run / schedule / mirror, or the --history backend under
faucet serve. With neither, tracking is inert and logs one line rather than
failing the run.
Reclaim outputs with faucet cleanup, the console’s Datasets
page, or the /v1/local-outputs* endpoints;
the sweeper that runs them automatically is described under
Local output retention. It deletes only files
faucet recorded as its own sink outputs — never a glob, never a directory, and
never a file faucet merely appended to — and never touches run history, catalog
entries, or lineage. Schema: faucet schema local-outputs.
observability
Section titled “observability”Optional top-level block that enables runtime observability backends. All
sub-blocks are independently optional; omitting the entire observability: key
leaves the defaults (no Prometheus server, no OTLP export).
Pushes traces and metrics to any OTLP-compatible collector. Requires building
the CLI with --features otel (included in full).
observability: otel: endpoint: "http://localhost:4317" protocol: grpc headers: {} sample_ratio: 1.0 export: [traces, metrics] service_name: faucet timeout_secs: 10 metric_interval_secs: 60| Field | Type | Default | Description |
|---|---|---|---|
endpoint |
string | http://localhost:4317 (grpc) / http://localhost:4318 (http) |
OTLP collector URL. For http, if the URL does not already contain a per-signal path (/v1/traces, /v1/metrics), faucet appends it automatically. |
protocol |
grpc | http |
grpc |
Transport protocol. grpc uses tonic; http uses HTTP/Protobuf. The faucet CLI always runs inside a tokio runtime, so both work without extra setup. |
headers |
map<string, string> | {} |
Extra headers sent on every export request — auth tokens, team keys, etc. Values are secret-interpolated the same as any config value (e.g. "${env:HONEYCOMB_KEY}"). |
sample_ratio |
float | 1.0 |
Head-based trace sampling probability, 0.0–1.0. 1.0 exports every trace; 0.1 keeps ~10%. Does not affect metric export. |
export |
list | [traces, metrics] |
Which signals to push. Each element is traces, metrics or logs. logs ships every run-log line from a durable local buffer (see logs: below). Omit a signal to disable it entirely. |
service_name |
string | faucet |
Value of the OpenTelemetry resource attribute service.name attached to every span and metric point. |
timeout_secs |
integer | 10 |
Per-export timeout in seconds. Timed-out exports are counted in faucet_otel_export_failures_total but do not fail the run. |
metric_interval_secs |
integer | 60 |
How often (in seconds) accumulated metric points are pushed to the collector. |
Coexistence: observability.otel: and observability.prometheus: are
fully independent; both can be active at the same time and metrics fan out to
both exporters. Export failures are never propagated to the pipeline — they
increment faucet_otel_export_failures_total{signal} and are logged.
Durable log shipping (#806), on when otel.export lists logs. Every run-log
line is written to a local spool first and shipped over OTLP; the per-run
watermark advances only when the collector acknowledges a batch. See
Shipping logs.
observability: otel: { endpoint: http://collector:4317, export: [traces, logs] } logs: spool_dir: /var/lib/faucet/logs retention_secs: 86400| Field | Type | Default | Description |
|---|---|---|---|
spool_dir |
path | $XDG_STATE_HOME/faucet/logs (~/.local/state/faucet/logs) |
Where faucet run / faucet schedule spool lines before shipping (one <run_id>.jsonl + cursor + meta + lock per run). |
retention_secs |
integer | 86400 |
How long delivered lines stay in the spool. |
buffer_max_age_secs |
integer | 604800 |
Undelivered lines older than this are dropped (counted; the run reports partially_dropped). Must be > 0. |
buffer_max_bytes |
integer | 1073741824 |
Spool size bound; past it the oldest lines go first. Must be > 0. |
flush_timeout_secs |
integer | 10 |
How long faucet run waits at exit for its lines to ship. The rest stays for the next process or faucet logs ship. |
max_lines_per_run |
integer | 100000 |
Per-run line cap; lines past it are dropped and counted (max_lines). |
notify_after_secs |
integer | 300 |
Emit log_export_failed once a run’s export has been failing this long. |
link_template |
string | — | A link to the run’s logs in the log service. Placeholders {run_id} {pipeline} {row} {tenant} {started_at} {ended_at}, URL-encoded. |
Discovery & env files
Section titled “Discovery & env files”run / validate / preview / schedule auto-discover faucet.yaml → .yml → .json in
the current directory, and load a sibling .env unless --no-env-file is given
(--env-file PATH points elsewhere).