[adapters] Add Postgres CDC input connector with crash-safe replication - #5988
Conversation
e1231a8 to
b2f3351
Compare
|
@flak153 this is fantastic! Thank you for your contribution. We'll get this some reviews. |
abhizer
left a comment
There was a problem hiding this comment.
Thank you!
It would also be really nice to have tests for this connector, with all the different data types as such: https://docs.feldera.com/connectors/sources/postgresql/#an-example-for-every-type
|
|
||
| let input_stream = input_handle | ||
| .handle | ||
| .configure_deserializer(RecordFormat::Json(JsonFlavor::Datagen))?; |
There was a problem hiding this comment.
Does the Datagen format correctly represent all incoming JSON records? Specially for date and time related records.
There was a problem hiding this comment.
Investigated — found two mismatches and fixed both. Timestamps now use RFC 3339 format (T separator) and bytes use byte arrays instead of hex strings. Added unit tests covering all Cell/ArrayCell variants.
blp
left a comment
There was a problem hiding this comment.
Thank you very much!
I read the code and I have some comments. I didn't try it or study the way it works in much detail.
It would be helpful to add some documentation under docs.feldera.com/docs/connectors/sources/.
I'll enable the workflows so we can see how an initial CI run goes.
| impl InputEndpoint for PostgresCdcInputEndpoint { | ||
| fn fault_tolerance(&self) -> Option<FtModel> { | ||
| None | ||
| } |
There was a problem hiding this comment.
No fault tolerance will be a problem for a lot of users.
| thread::Builder::new() | ||
| .name("postgres-cdc-input-tokio-wrapper".to_string()) | ||
| .spawn(move || { | ||
| TOKIO.block_on(async { | ||
| let _ = endpoint_clone | ||
| .worker_task(input_stream, receiver, init_status_sender) | ||
| .await; | ||
| }) | ||
| }) | ||
| .expect("failed to create Postgres CDC input connector thread"); |
There was a problem hiding this comment.
I probably would have used TOKIO.spawn rather than creating a thread and then running TOKIO.block_on in it. Maybe you made this as a considered choice though; I don't know whether there is some advantage to it.
There was a problem hiding this comment.
Keeping the thread because open() is sync and needs blocking_recv for init status. The actual CDC work runs on TOKIO via block_on inside the thread.
| let indices: Vec<String> = | ||
| (0..cells.len()).map(|i| format!("col_{i}")).collect(); |
There was a problem hiding this comment.
This will format and allocate a whole array of strings for every row. It would be better to unconditionally construct column_names above so that it can be reused instead of reconstructed for every row.
There was a problem hiding this comment.
Good catch — will fix to pre-construct column_names outside the loop.
| Cell::Numeric(n) => { | ||
| // Preserve precision by encoding as string. | ||
| json!(n.to_string()) | ||
| } |
There was a problem hiding this comment.
I don't know whether this is necessary, since we enable the serde_json arbitrary_precision feature.
There was a problem hiding this comment.
Checked — Feldera does enable arbitrary_precision. However, etl's Cell::Numeric uses pg_numeric which implements Serialize, so json!(n) would serialize it as a number with full precision. The string conversion is a safety choice but you're right it may not be needed. Will investigate further.
mythical-fred
left a comment
There was a problem hiding this comment.
Two blockers not already raised by blp/abhizer.
|
Thanks everyone for the thorough reviews. Pushed fixes for all comments: @abhizer — Good catch on the Datagen format. Found two mismatches and fixed both:
@blp — All comments addressed:
@mythical-fred — Both blockers addressed:
|
mythical-fred
left a comment
There was a problem hiding this comment.
Both of my previous blockers are addressed — etl deps pinned, integration tests added. Thank you for the thorough response.
One remaining issue before this can merge: the second commit must be squashed into the first.
Commit 72b20821 has the message "Address review comments on Postgres CDC connector" — that's not a commit message that belongs in Feldera's linear history. Please use git rebase -i to squash it into the initial commit and produce a single, clean commit with a complete message (or two well-named commits if there's a logical split). Resources:
72b2082 to
194f366
Compare
|
Squashed into a single commit. |
|
Question — beyond the current tests, there are several edge cases we could cover. Would you like any of these before merge, or are they better as follow-ups? High priority (most likely to surface real bugs):
Medium priority:
Lower priority (edge cases):
Happy to add whichever subset you think is important for this PR vs follow-ups. |
194f366 to
95bf3a6
Compare
|
Pushed updates:
|
265f888 to
78a712a
Compare
|
Question on fault tolerance implementation — looking for guidance on the right pattern. The connector now uses To properly return Would it be valid to call self.inner.queue.queue(); // flushes data, calls extended(total, None, watermarks)
self.inner.consumer.extended(
BufferSize::empty(),
Some(Resume::Seek { seek: json!({"pipeline_id": pipeline_id}) }),
vec![],
);Or is there a better pattern for integrated endpoints that need resume metadata but want to use For now I've set |
| // Use a deterministic pipeline_id so etl reuses the same replication slot | ||
| // and stored state across restarts. | ||
| let pipeline_id = { | ||
| let mut hasher = DefaultHasher::new(); |
There was a problem hiding this comment.
DefaultHasher is not stable across Rust versions/builds, so pipeline_id can change on upgrade and the replication slot/state will not be reused. Please switch to a stable hash (e.g., xxhash/sha256) and consider hashing a normalized connection identity (exclude password/other volatile fields).
There was a problem hiding this comment.
Fixed — switched to xxh3 (stable across Rust versions) with a normalized identity string: host:port/db + publication + source_table (excludes password and other volatile fields).
| invalidated_slot_behavior: InvalidatedSlotBehavior::default(), | ||
| }; | ||
|
|
||
| // Use PostgresStore to persist table replication phases across restarts. |
There was a problem hiding this comment.
Switching to PostgresStore + AtLeastOnce changes restart semantics (resume from slot instead of re-snapshot). Per test rule, this behavior change needs an integration test that restarts the pipeline and verifies it resumes correctly.
There was a problem hiding this comment.
Added test_cdc_restart_resumes_from_slot — starts pipeline, inserts data, stops, restarts with a new output file, inserts more data, then asserts the original snapshot row (id=1) does NOT reappear in the second run's output (proving PostgresStore state was preserved and no re-snapshot occurred).
78a712a to
2740fb1
Compare
Right, One way to achieve fault tolerance along with
No, it needs to be called exactly once. |
2740fb1 to
66d55d3
Compare
|
Switched to |
Awesome! Thanks for the update. I will take a look at the revised version as soon as I can. Unfortunately that will be a few days (probably Tuesday) because I am leaving soon for a long weekend. |
66d55d3 to
8dd18e3
Compare
|
Small follow-up fix: the pipeline_id hash was using |
|
@blp gentle ping — ready for another look whenever you have time. mythical-fred's comments are all addressed (approved) and the password-rotation fix is in. No rush, just want to make sure it doesn't fall off your radar. |
|
Hi, here are the issues I'd raise that potentially haven't been flagged yet, in rough priority order: HIGH: Unqualified table name matching is too broad crates/adapters/src/integrated/postgres/cdc_input.rs fn is_target_table(&self, schema_name: &str, table_name: &str) -> bool {
let qualified = format!("{schema_name}.{table_name}");
self.source_table == qualified
|| self.source_table == table_name // ← danger
|| self.source_table == format!("\"{schema_name}\".\"{table_name}\"")
}If a user configures source_table = "orders" (no schema) and the publication covers public.orders AND audit.orders, both will match. The connector will silently ingest both tables and interleave their rows. Either reject unqualified names at config validation time or enforce schema-qualified matching always. HIGH: Missing Relation event causes silent data loss WAL events arrive in order: Relation → Insert/Update/Delete. If a Relation event is ever missed (e.g. slot created after the schema was set up, slot invalidated and recreated), subsequent DML events for that table fall through to the else branch that generates fallback column names (col_0, col_1...) rather than aborting or warning. Schema mismatch goes undetected. Suggest: emit a structured warning/error when DML arrives for an unknown table_id rather than silently falling back to generic column names. MEDIUM: Timestamp format %.f may silently truncate format!("%Y-%m-%dT%H:%M:%S%.f") In chrono, %.f formats fractional seconds with trailing zeros suppressed. So 12:00:00.000000000 formats as "12:00:00" — indistinguishable from a timestamp with no sub-second component. Whether the downstream Datagen parser handles this correctly depends on whether it accepts both "12:00:00" and "12:00:00.123". MEDIUM: Resume metadata doesn't encode actual LSN position The current Resume::Seek payload is just { "pipeline_id": } — a static identifier, not a position. Feldera's fault-tolerance model for Resume::Seek semantics implies the reader can skip to that position; but here the actual LSN tracking is entirely in PostgresStore (etl-side), with no coordination with Feldera's checkpoint system. This means: if Feldera's checkpoint store is wiped independently of PostgresStore (or vice versa), the two systems can diverge. The pipeline_id in the seek metadata has no operational effect on restart — it's identity, not position. This is worth calling out in documentation and probably in a // TODO comment LOW: stable_connection_identity strips password but not all volatile fields sslmode, connect_timeout, application_name can appear in connection URIs and are currently included in the identity hash. Changing application_name (common in monitoring setups) would change pipeline_id, orphaning the replication slot. Recommend extracting only host, port, dbname as the stable identity, not the full path component after stripping password. LOW: PgNumeric string representation for special values Cell::Numeric(n) => json!(n.to_string()) etl's PgNumeric can represent NaN and Infinity. If to_string() produces "NaN" or "Infinity", these are valid JSON strings but the downstream Feldera decimal type parser may not accept them, silently dropping or erroring on those rows. Same pattern as the float handling: should check and special-case. Looking at etl source, PgNumeric::to_string() does in fact produce "NaN" for NaN variants. |
|
@nmarasoiu on your point about the resume metadata being identity rather than position — you're right that it's a bit of a half-measure. Want to flag the tradeoff and get input. Actual resume state lives in etl's PostgresStore + the replication slot's confirmed_flush_lsn. On restart, etl checks its stored table phases and resumes from the slot position. Feldera's Resume::Seek is really just "yes, I support resumption" — the pipeline_id is there so Feldera's controller doesn't think we're stateless. For stateless pipelines (forward CDC events to a sink), this is functionally fine. Outputs are the source of truth, reprocessing the same inputs produces the same outputs. For stateful circuits it's murkier. Step completion fires our async_result and advances the slot, but Feldera's circuit state is only durable after a checkpoint. If Feldera crashes between those two events, the slot could be ahead of the last checkpoint on restart — data between those points wouldn't be redelivered but also wasn't persisted in Feldera's state. The cleaner fix is to wait on So the choice is between faster but racy (current) vs stricter but slower (checkpoint-based). Probably a config flag is the right answer — default to completion-based for the common stateless use case, opt into checkpoint_watcher for stateful pipelines that need the guarantee. Reasonable for this PR, or do you think strict should be the default? |
|
Thanks. On the The precondition for the fast path isn't "stateless circuit" alone: it's stateless circuit and idempotent output commit:
Only the top-left cell is safe. For the other three, the slot can advance past Feldera's last durable checkpoint: data in that window is either already ack'd but lost from circuit state (stateful case), or delivered twice to non-idempotent outputs. The connector has no way to detect which cell a given pipeline lands in: circuit statefulness isn't exposed to input adapters, and sink idempotency is a property of the downstream system and the specific operations emitted, not something knowable from the CDC side. So "pick the fast path when it's safe" isn't an option; the user has to declare it. Correctness-opt-in is thus a wrong default shape imo; performance-opt-in is the healthy one. Worth documenting the preconditions explicitly wherever fast mode is enabled. One operational item we hadn't raised and think is worth flagging prominently —
Not necessarily blocking, but worth documenting very prominently — and a follow-up to support a separate state DB via config would be a meaningful hardening. One concrete one-line fix: Cell::Numeric(n) => match n {
PgNumeric::NaN
| PgNumeric::PositiveInfinity
| PgNumeric::NegativeInfinity => Value::Null,
_ => json!(n.to_string()),
}Future follow-up (not blocking this PR): Event::Relation overwrites the connector's relation_cache without diffing against the deserializer schema configured at startup. An ALTER TABLE ADD/DROP COLUMN mid-stream silently produces misaligned JSON. Worth a dedicated design pass in a follow-up: schema evolution deserves more than a patch here. PS. On keepalive I want to retract a concern I raised earlier about |
|
@blp rebased onto current main. Conflicts resolved in Cargo.lock and mock_input_consumer.rs (both new trait methods kept). All 52 unit tests pass. Ready to merge. Separately — I've been tracking a list of follow-up items from the review (both from reviewers here and things we flagged ourselves): 16 items total, ranging from quick-win fixes (TLS config wiring is a real bug, PgNumeric NaN handling, unqualified table matching, etc.) to larger work (separate state DB config, strict checkpoint sync flag, schema evolution, broader test coverage). Where would you prefer I put this — a GitHub issue, a section in the connector docs as known limitations, split into multiple tracked issues, or somewhere else? |
Thank you! I started the process. (I wouldn't be surprised if it took a few tries. Thanks for being patient with me!)
For things that are mostly internal, I think that a GitHub issue is a good idea. I leave it to your judgment and preferences whether that's better as one issue with a checklist, or separate issues, or some mix. For things that are user-visible, I think it's good to put them into the connector docs. If there's an issue to reference then it's kind to reference it from the docs. |
|
I see that this failed in the pre-merge check. That's not a huge surprise, it happens to me regularly too. From the output from the job (which I think you should be able to read, too; let me know if you can't), it looks like the biggest issue is formatting. |
Head branch was pushed to by a user without write access
e92904e to
6e25c85
Compare
|
@blp ran cargo fmt and regenerated openapi.json. Pushed. Thanks for the guidance on the follow-up list — I'll open a GitHub issue with a checklist for the internal items, and add a known-limitations section to the connector docs for the user-visible ones (cross-referencing the issue). Will do that after this merges. |
Almost there! I see that there is one remaining issue in the rerun: it wants to convert |
6e25c85 to
85ba0fa
Compare
|
Collapsed the nested if. Clippy clean now. |
Add an integrated input connector that reads from Postgres via logical replication using the supabase/etl library. The connector handles both initial table snapshot and ongoing WAL streaming. Key features: - Snapshot + streaming via etl's Pipeline (table copy + CDC follow) - Crash-safe replication slot advancement: the connector defers ETL's async flush confirmation until Feldera completes the circuit step that processed the data, ensuring the Postgres replication slot only advances after data is durably processed - Completion tracking via InputConsumer::completion_watcher() that exposes Feldera's step completion notifications to input adapters - RFC 3339 timestamp format and byte array encoding for correct Datagen parser compatibility Files added: - crates/adapters/src/integrated/postgres/cdc_input.rs Tests: - 47 unit tests (cell_to_json, array_cell_to_json, row_to_json, parse_pg_uri, FelderaDestination helpers) - 3 integration tests (basic insert, all data types, update/delete)
85ba0fa to
b44361f
Compare
|
CI found |
|
CI failure wasn't related to the changes in this PR, requeuing. |
A table declaring a postgres_cdc_input connector failed to compile with "expected an input variant but got an output variant". generate_program_info matches the transport against an allow-list of input variants that omitted PostgresCdcInput, and the catch-all arm that follows reports every unlisted variant as an output variant. PR feldera#5988 added the connector to feldera-types, to the SQL compiler's ConnectorValidator and to the adapter, but not to this allow-list, so the usage documented in docs.feldera.com/docs/connectors/sources/postgresql-cdc.md has never compiled on main. This is the same change as 19b8d33 on the unmerged pg-cdc-ft-fixes branch, split out so the fix does not wait on that branch.
Summary
Adds an integrated input connector that reads from Postgres via logical replication using supabase/etl. Handles both initial table snapshot and ongoing WAL streaming with crash-safe replication slot advancement.
Closes #5208
Key features
InputConsumer::completion_watcher(): new trait method (backwards-compatible defaultNone) that exposes Feldera's step completion notifications to input adaptersHow crash safety works
write_eventspushes data toInputQueueand stores the ETL async result sender in a side channelQueue, data flushes to the circuit and the sender is routed to a background task with the currenttotal_completed_stepsvaluecompletion_notifierand fires the sender whentotal_completed_stepsadvances past the value at flush timeOn crash, unconfirmed data replays from the slot position — consistent with at-least-once delivery.
Upstream work
What's left
wal_level=logical)