shop.orders
example as the quickstart: each order document carries its
customer (1:1) and its line items (1:many).
VentStream writes to a target. OpenSearch and Elasticsearch are
the supported targets today — they share the
_bulk API, so the same
engine handles both. The examples here use OpenSearch; set the endpoint
with VS_OS_ENDPOINT.Production prerequisites
In the demo the agent appears to “just work” with zero setup — but only because the demo’sventstream user is the container’s
bootstrap superuser. It owns every table and has REPLICATION
implicitly, so it can create the publication and the slot itself. A
real production database user almost never has that.
Here’s the split of who does what:
The three operator steps, as commands you run once with a privileged
account (self-managed Postgres shown — for RDS/Aurora and Cloud SQL see
the accordions below):
ventstream, creates its slot, and starts
streaming. No additional operator commands are required for the final two
steps.
Every table the projection touches must be in the publication (step 2) —
otherwise its changes never reach the engine.
Why a dedicated role instead of the admin account? Security —
least privilege. The engine only needs
REPLICATION + SELECT, so it
should run as a role that only has those. If its credentials ever leak,
the blast radius is read + replication on these tables, not admin over
the whole database. It also lets you rotate or revoke the engine’s
password independently of the app, and makes its sessions/slots easy to
identify in pg_stat_activity. The engine works fine as a superuser (the
demo does exactly that) — but in production, don’t.Can a non-admin engine user create the slot?
Yes —REPLICATION is a role attribute, not superuser. A DBA grants
it once and the engine (running as that non-admin role) auto-creates and
consumes its own slot:
REPLICATION is required to
open the logical stream at all, so any role that can stream can
also create the slot. A role without REPLICATION can do neither —
in that case an operator must pre-create the slot and the engine
still won’t be able to consume it. So the engine role needs REPLICATION
regardless; the only question is whether you also let it manage the slot
(it can) or pin slot lifecycle to your IaC (see below).
The role also needs SELECT on every published table — the snapshot
bootstrap reads them directly.
RDS / Aurora PostgreSQL
RDS / Aurora PostgreSQL
RDS/Aurora has no true superuser — the master user is
3. Create the publication as the table owner (or
rds_superuser — and ALTER SYSTEM is blocked. So the replication
setup is a little different. Recommended path:1. Enable logical replication (an AWS admin, not a SQL user):
set rds.logical_replication = 1 in the DB cluster parameter
group, then reboot the cluster. This is what makes
wal_level = logical.2. Create a dedicated engine role (don’t put the master admin in
the connection string) and grant it replication via the RDS role —
rds_superuser does not include this automatically:rds_superuser).AWS documents the required parameter group, reboot, slot, and monitoring
steps in
Setting up logical replication for Aurora PostgreSQL.Cloud SQL for PostgreSQL
Cloud SQL for PostgreSQL
- Set the
cloudsql.logical_decoding = onflag, then restart. ALTER USER <engine_role> WITH REPLICATION;- Grant
SELECTon the published tables to the engine role.
Decommissioning an agent: a slot the engine created keeps pinning WAL
until it’s dropped — Postgres won’t recycle that WAL, and the disk
eventually fills. When you retire an agent, drop its slot:
SELECT pg_drop_replication_slot('ventstream_orders_slot');The projection
cardinality: oneembeds a single object;manyembeds an array.selectprojects only the columns you want; omit it to embed the whole row.- Reserved-word column names (e.g.
from,to,order) are fine — the fetcher double-quotes every column name.
Denormalize modes: in-memory vs SQL
The samejoins: spec can run two ways, chosen with VS_PG_DENORMALIZE_MODE:
-
memory(default) — the in-memory join engine. It keeps the related rows and reverse indexes resident, so a change recomposes a doc with no re-query — lowest tail latency. The trade-off is that RSS scales with the joined working set, so it fits when that working set fits RAM. -
sql— pushes the join into Postgres. Bootstrap runs one keyset-chunked denormalizingSELECTper primary (PG composes each doc; the engine streams them to the sink); the tail recomposes only the affected primaries via SQL on each change. Memory is bounded by the chunk, not the dataset — flat at tens of MB whether the source has hundreds of thousands or tens of millions of rows. The trade-off is an indexed re-query per change instead of an in-memory lookup. Usesqlwhen the working set is too large to hold in memory. It requires indexes on the join / foreign-key columns — the per-chunk bootstrap, the child→parent fan-out, and the foreign-row reverse lookup all rely on them (without them they degrade to sequential scans). Works with single, UUID, and composite primary keys, and with multiple primaries in one spec (each → its own index).
sql mode is the bounded-memory path for very large sources; memory
mode stays the default for the lowest-latency tail when the working set
fits RAM. Both consume the identical joins: spec.Run the agent
TLS
Use strict TLS for a remote database. Amazon RDS needs no downloaded CA file:ca_file instead. Strict mode applies to logical replication, snapshots,
related-row fetching, SQL recomposition, reconciliation, and replication-slot
management. See Database TLS and trust for the complete
decision guide.
Connection resilience
If the replication connection drops mid-stream — a transient network blip, or a load balancer killing an idle connection — the source reconnects in-process with bounded exponential backoff and resumes from the slot’s confirmed LSN. No committed change is missed (events since the last ack are re-delivered, and the sink upserts idempotently by doc id). A connection that streams for a sustained period resets the backoff budget, so brief blips recover instantly; only a genuinely-dead upstream (rapid flapping past the budget) bubbles up and lets Kubernetes restart the pod — keeping a dead database visible to liveness rather than silently spinning.status_interval (default 10s) sends standby status messages that
double as an application-level keepalive, below typical LB idle
timeouts, so idle replication connections aren’t dropped in the first
place.
Bootstrap then tail
WithVS_PG_BOOTSTRAP_MODE=snapshot and no existing slot, the agent
snapshots every published table before opening the stream, so the index
is fully populated before live changes flow. The snapshot and the live
stream share a slot created before the scan, so changes during the
snapshot window aren’t lost — deterministic IDs dedupe any row seen
both ways.
Changing the projection
Edit the YAML and restart. WithVS_PG_AUTO_RESYNC_ON_YAML_CHANGE=true,
the agent fingerprints the spec; if it changed, it drops the slot,
wipes its join state, and re-bootstraps so every document is rewritten
with the new shape. Without that flag, it warns and keeps running —
new changes use the new spec, existing docs update as their rows are
touched.
Source schema changes
The section above is about changing your spec. This is about the source schema changing underneath you — a column added, dropped, renamed, or retyped in Postgres. The agent notices on the nextRELATION message and emits a structured
signal: a metric=schema.drift log (table, column, kind, plus
from_type_oid/to_type_oid for a type change) and the
vs_schema_drift_total{table,kind} counter on
/metrics. Today this is warn-only — the
pipeline keeps flowing:
- A new, unmapped column is ignored (the projection only reads what it references).
- A dropped/renamed column the projection maps degrades that field (null/missing) until you act.
- A type change the sink can’t accept surfaces downstream as a rejected write → the DLQ.
Recovering
The drift logs name exactly which columns changed. To recover, make the projection compatible and re-emit — the same resync path as above:- Edit the joins YAML to match the new source schema (map the renamed column, drop the removed one, adjust the field).
- Restart with
VS_PG_AUTO_RESYNC_ON_YAML_CHANGE=true(orVS_PG_FORCE_RESYNC=true). The fingerprint changes, so the agent drops the slot, wipes join state, and re-bootstraps every row against the new schema — see Changing the projection.
POST /admin/resync re-scans the tables without a restart.
Schema-drift handling is currently warn-only. Review the emitted drift event,
update the projection if needed, and use the resync procedure above.
Child-row deletes
Deleting a row from a child/embedded table (e.g. one line item out of an order) propagates on the default replica identity — no special table configuration needed. This is worth understanding, because Postgres makes it non-obvious. The default replica identity logs only the primary key in aDELETE’s old
tuple — so a deleted line item’s event carries item_id but not the
foreign key order_id the engine needs to find the parent order. The engine
closes that gap from its own join state: it cached the full child row
(including order_id) when the row was inserted or bootstrapped, so on
delete it recovers the FK from there, locates the order, and recomposes the
document without the removed item. Inserts and updates were never affected
(they carry the full new row); neither is deleting the primary.
The one residual case is a child row the engine never indexed — e.g. it
was inserted and deleted entirely while the agent was down and didn’t
re-bootstrap that row. With nothing cached, the FK can’t be recovered from
state. If your workload can produce that and you need the delete to still
propagate, set
REPLICA IDENTITY FULL on the child table so the FK rides
along in the delete event itself:FULL writes the entire old row image to the WAL on every UPDATE/DELETE
(heavier WAL + logical-decode, proportional to row width × write rate), so
only reach for it when that cold-cache case actually applies — the steady
state doesn’t need it.Soft deletes
A soft delete (UPDATE … SET deleted_at = now()) is an UPDATE, not a
DELETE — the row still exists in the table. So CDC emits an update,
the engine re-composes and upserts, and the document stays in the
target (now carrying deleted_at). Only a hard DELETE tombstones a
document; a soft delete never removes it on its own.
You have two ways to handle this:
- Filter at read time (recommended). Leave the document in place and
exclude soft-deleted rows in your search queries — your target’s
equivalent of
WHERE deleted_at IS NULL. Simplest, and keeps the record around for audit. - Hard-delete to remove it. If the document must disappear from the
index, the row has to be physically
DELETEd in Postgres — that emits the delete event the engine tombstones on.
There is currently no spec rule that converts a soft-delete
UPDATE
into a document deletion (a “delete-on-predicate” / tombstone-when-column-
flips feature). The spec controls the shape of the document, not whether
an update removes it. If you need that, use the read-side filter above —
delete-on-predicate is a possible future addition.