faucet-state-postgres
PostgreSQL-backed StateStore for the faucet-stream ecosystem. Persists incremental-replication bookmarks in a single JSONB-backed table so sources can resume exactly where they left off after a crash, restart, or scheduled invocation.
Reach for it when you run faucet pipelines across multiple machines (or in Kubernetes) and want durable, shared resume state in a database you already operate — instead of a local file store that lives on one node's disk.
Why durable state?
faucet-stream sources that support incremental replication (CDC sources like postgres-cdc / mysql-cdc / mongodb-cdc, and query sources with a watermark column) emit a bookmark after each committed batch — the replication position they reached. A StateStore persists that bookmark; on the next run the pipeline reads it back and resumes from there, so a restart never re-emits already-delivered rows or skips data.
The built-in memory store forgets everything on exit, and the built-in file store ties state to one node's filesystem. A Postgres-backed store gives you:
- Durability across restarts and crashes — state survives the process and the host.
- A shared source of truth — every replica of a
faucet servecluster, every scheduled tick, and every operator runningfaucet replicatereads and writes the same row, keyed by pipeline + matrix row. - One less moving part — if you already run Postgres for the pipeline's source or sink, state lives in the same database you back up and monitor.
Who should use this crate
- End users reference it indirectly: add the CLI's
state-postgresfeature and point a pipeline'sstate:block attype: postgres(see below). You never import this crate directly. - Library authors building their own pipeline runner construct [
PostgresStateStore] and hand it toPipeline::with_state_store/RunStreamOptions.
It implements the [faucet_core::state::StateStore] trait — nothing more — so it slots in anywhere a StateStore is accepted.
Installation
# As a library (you also need faucet-core for the StateStore trait + Pipeline):
# In the CLI (opt-in state backend):
The postgres state backend is not in the CLI default build — enable the state-postgres feature explicitly (it is included in the full aggregate). The built-in memory and file backends ship in faucet-core and need no feature.
CLI configuration
Point a pipeline's top-level state: block at the postgres backend:
# pipeline.yaml — faucet run pipeline.yaml
version: 1
pipeline:
source:
type: postgres-cdc
config:
connection_url: ${env:SOURCE_PG_URL}
slot_name: faucet_slot
publication: faucet_pub
tables:
sink:
type: jsonl
config:
path: ./orders.jsonl
state:
type: postgres
config:
url: ${env:STATE_PG_URL} # e.g. postgres://user:pass@localhost:5432/faucet
table: faucet_state
ensure_table: true
state.config fields
| Field | Type | Default | Description |
|---|---|---|---|
url |
string | — (required) | PostgreSQL connection URL (postgres://user:pass@host:port/db). Resolve secrets via ${env:VAR} / ${secret:…} indirection — never hard-code credentials. |
table |
string | faucet_state |
Table that holds state rows. Validated as a Postgres identifier (ASCII letters/digits/underscore, ≤ 63 chars). |
ensure_table |
bool | false |
When true, run CREATE TABLE IF NOT EXISTS on startup. Leave false if you provision the table via migrations or want to grant the runtime role no DDL. |
The CLI uses a fixed pool size of 5 connections for the state store. Library callers can size the pool themselves via [PostgresStateStore::connect_with].
Tip: the state database can be the same Postgres instance as your source or sink — just give it its own
url/table. It does not have to be a separate server.
Library usage
Build it and hand it to a Pipeline
use Arc;
use ;
use PostgresStateStore;
# async #
Custom pool size, table name, or a shared pool
use PostgresStateStore;
use PgPool;
# async
Use the store directly
PostgresStateStore implements StateStore, so you can read and write bookmarks yourself:
use StateStore;
use json;
use PostgresStateStore;
# async
How it works
Storage layout
Every entry is one row in a single table:
(
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW
);
ensure_table() runs the CREATE TABLE IF NOT EXISTS form (the identifier is double-quoted via faucet_core::util::quote_ident). If you manage schema with migrations, skip it.
Operations
| Trait method | SQL | Notes |
|---|---|---|
get(key) |
SELECT value FROM <table> WHERE key = $1 |
Returns None when no row exists. |
put(key, value) |
INSERT … VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() |
Atomic upsert — the bookmark and its updated_at advance together. |
delete(key) |
DELETE FROM <table> WHERE key = $1 |
A missing row is not an error. |
The pipeline reads the bookmark before fetching and writes it only after the sink confirms the batch, so a crash mid-write never advances state past delivered data.
Performance & safety
- Pooled connections — the
sqlx::PgPoolis built once inconnect/connect_withand reused for every operation. State writes are cheap and infrequent (one per committed batch), so the default pool of 5 is intentionally small. - Single-row upsert —
putis one round-trip with no read-modify-write race; concurrent writers to the same key resolve viaON CONFLICT. - Validated identifiers — table names are checked against Postgres identifier rules (non-empty, ≤ 63 chars,
[A-Za-z0-9_]) at construction and double-quoted in every statement, so a misconfigured table name fails fast instead of risking SQL injection. - Validated keys — state keys are checked by [
faucet_core::state::validate_state_key] (ASCII alphanumerics plus_ - : ., ≤ 256 chars).
Health check
check() runs a real upsert → select → delete round-trip on a sentinel key (__faucet_doctor__), so faucet doctor verifies connectivity, credentials, table existence, and read/write permissions through the actual code path — and leaves no residue.
Feature flags
This crate has no optional features of its own. Enable the backend in the CLI / umbrella via the state-postgres feature.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
State: PostgreSQL state-store connection failed |
The url is wrong, the server is unreachable, or auth failed. Verify the URL, that Postgres is listening, and that the role can log in. |
State: … relation "faucet_state" does not exist |
The table was never created. Set ensure_table: true (CLI) or call ensure_table() (library), or create it via your migrations. |
Config: state-store table name '…' contains illegal character |
table must be a bare Postgres identifier — letters, digits, underscores only, ≤ 63 chars. No dots, dashes, spaces, or schema-qualified names. |
State: Postgres UPSERT … failed: permission denied |
The runtime role lacks INSERT/UPDATE (or SELECT/DELETE) on the table. Grant the needed privileges, or use a role that has them. |
ensure_table fails with permission denied for schema |
The role can't run DDL. Provision the table out-of-band (migrations / a privileged role) and set ensure_table: false. |
| Pipeline re-processes data after a restart | State isn't being persisted — confirm a state: block is present and points at this backend (not memory), and that the source actually emits bookmarks (incremental/CDC sources do; one-shot query sources without a watermark don't). |
faucet doctor reports the sentinel probe failing |
The round-trip couldn't complete — follow the hint it prints: check reachability, credentials, and that the table exists. |
See also
- State & resumability cookbook — how bookmarks, resume, and exactly-once work end to end.
- Configuration reference — the full
state:block grammar. faucet-core— theStateStoretrait,Pipeline, and the built-inMemoryStateStore/FileStateStore.faucet-state-redis— the Redis-backed alternative for the same role.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.