Skip to content

[adapters] Add Postgres CDC input connector with crash-safe replication - #5988

Merged
blp merged 2 commits into
feldera:mainfrom
no-flaks-given:postgres-cdc-input
Apr 24, 2026
Merged

[adapters] Add Postgres CDC input connector with crash-safe replication#5988
blp merged 2 commits into
feldera:mainfrom
no-flaks-given:postgres-cdc-input

Conversation

@no-flaks-given

Copy link
Copy Markdown

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

  • Snapshot + streaming via etl's Pipeline (table copy + CDC follow)
  • Crash-safe replication: 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
  • InputConsumer::completion_watcher(): new trait method (backwards-compatible default None) that exposes Feldera's step completion notifications to input adapters

How crash safety works

  1. write_events pushes data to InputQueue and stores the ETL async result sender in a side channel
  2. When the controller calls Queue, data flushes to the circuit and the sender is routed to a background task with the current total_completed_steps value
  3. The background task watches completion_notifier and fires the sender when total_completed_steps advances past the value at flush time
  4. ETL receives the confirmation and advances the replication slot

On crash, unconfirmed data replays from the slot position — consistent with at-least-once delivery.

Upstream work

What's left

  • Integration tests (need Postgres with wal_level=logical)
  • Error handling refinement
  • Additional configuration options (slot name, TLS, batch config)
  • Documentation

@lalithsuresh
lalithsuresh requested review from abhizer and blp April 3, 2026 12:07
@lalithsuresh

Copy link
Copy Markdown
Contributor

@flak153 this is fantastic! Thank you for your contribution. We'll get this some reviews.

@abhizer abhizer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the Datagen format correctly represent all incoming JSON records? Specially for date and time related records.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 blp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/adapterlib/src/transport.rs Outdated
Comment thread crates/adapters/Cargo.toml
impl InputEndpoint for PostgresCdcInputEndpoint {
fn fault_tolerance(&self) -> Option<FtModel> {
None
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No fault tolerance will be a problem for a lot of users.

Comment on lines +98 to +107
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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/adapters/src/integrated/postgres/cdc_input.rs Outdated
Comment thread crates/adapters/src/integrated/postgres/cdc_input.rs Outdated
Comment thread crates/adapters/src/integrated/postgres/cdc_input.rs Outdated
Comment on lines +410 to +411
let indices: Vec<String> =
(0..cells.len()).map(|i| format!("col_{i}")).collect();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — will fix to pre-construct column_names outside the loop.

Comment on lines +643 to +646
Cell::Numeric(n) => {
// Preserve precision by encoding as string.
json!(n.to_string())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know whether this is necessary, since we enable the serde_json arbitrary_precision feature.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto for ArrayCell::Numeric.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers not already raised by blp/abhizer.

Comment thread Cargo.toml Outdated
Comment thread crates/adapters/src/integrated/postgres/cdc_input.rs
@no-flaks-given

Copy link
Copy Markdown
Author

Thanks everyone for the thorough reviews. Pushed fixes for all comments:

@abhizer — Good catch on the Datagen format. Found two mismatches and fixed both:

  • Cell::Timestamp now uses RFC 3339 format (T separator) instead of space-separated
  • Cell::Bytes now encodes as byte arrays instead of hex strings to match BinaryFormat::Array
  • Same fixes for the ArrayCell variants
  • Added 47 unit tests covering all cell_to_json/array_cell_to_json variants plus URI parsing and table matching

@blp — All comments addressed:

  • completion_watcher default impl removed, added explicit None to all 3 implementations
  • with-postgres-cdc added to default features
  • mpsconeshot for init status channel
  • mem::take instead of drain().collect()
  • Kept thread::Builder because open() is sync and needs blocking_recv for init status — happy to switch if there's a better pattern
  • On FT: the path is switching MemoryStorePostgresStore so etl persists table phases across restarts, then implementing Resume::Seek. Planning that as a follow-up since it's a significant addition.

@mythical-fred — Both blockers addressed:

  • etl deps pinned to rev = "05cb11ae"
  • Added 3 integration tests (test_cdc_basic_insert, test_cdc_all_data_types, test_cdc_update_delete) marked #[ignore] since they require wal_level=logical

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

@no-flaks-given

Copy link
Copy Markdown
Author

Squashed into a single commit.

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@no-flaks-given

no-flaks-given commented Apr 6, 2026

Copy link
Copy Markdown
Author

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):

  • Crash-resume / failover: start pipeline, insert data, kill without confirming, restart, verify data replays
  • Schema changes during streaming: column added/dropped while CDC is running
  • Large transactions: single transaction with thousands of rows (tests 2MB buffer splitting)
  • Multiple tables in publication: verify source_table filtering ignores other tables

Medium priority:

  • TRUNCATE events (currently ignored — verify it doesn't crash)
  • Empty table snapshot (zero rows, then streaming starts)
  • REPLICA IDENTITY behavior without FULL (no old row on updates/deletes)
  • Rapid insert/update/delete cycles on same row

Lower priority (edge cases):

  • Connection loss / Postgres restart mid-stream
  • Slot invalidation (slot dropped externally)
  • Long-running transactions
  • Special float values end-to-end (NaN, Infinity)

Happy to add whichever subset you think is important for this PR vs follow-ups.

@no-flaks-given

Copy link
Copy Markdown
Author

Pushed updates:

  • Fault tolerance implemented: Switched MemoryStorePostgresStore so etl persists table replication phases across restarts. The connector now returns Some(FtModel::AtLeastOnce). On restart, etl reads the stored phases from Postgres and resumes from the replication slot position instead of re-snapshotting.
  • Column names pre-constructed outside the per-row loop in write_table_rows (blp's comment)
  • Numeric: Keeping .to_string()PgNumeric is a custom enum (NaN/Infinity/Value) without Serialize, so json!(n) wouldn't work even with arbitrary_precision
  • Pipeline ID: Uses a deterministic hash of (URI, publication, source_table) so the same replication slot and stored state are reused across restarts

@no-flaks-given

Copy link
Copy Markdown
Author

Question on fault tolerance implementation — looking for guidance on the right pattern.

The connector now uses PostgresStore so etl persists table replication phases in Postgres. On restart, etl checks the stored phases and resumes from the replication slot position (no re-snapshot). This works regardless of what fault_tolerance() returns.

To properly return Some(FtModel::AtLeastOnce), we need to provide Resume::Seek metadata via consumer.extended(). The issue is that InputQueue::queue() hardcodes resume: None in its internal extended() call, and we use queue() to avoid reimplementing its batching/transaction logic.

Would it be valid to call extended() a second time after queue() with empty data + resume metadata?

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 queue() for the data path?

For now I've set fault_tolerance() to AtLeastOnce with the PostgresStore backing — the crash recovery works because etl manages its own state. But want to make sure the Feldera checkpoint integration is correct too.

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers, see inline.

// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@blp

blp commented Apr 8, 2026

Copy link
Copy Markdown
Member

Question on fault tolerance implementation — looking for guidance on the right pattern.

The connector now uses PostgresStore so etl persists table replication phases in Postgres. On restart, etl checks the stored phases and resumes from the replication slot position (no re-snapshot). This works regardless of what fault_tolerance() returns.

To properly return Some(FtModel::AtLeastOnce), we need to provide Resume::Seek metadata via consumer.extended(). The issue is that InputQueue::queue() hardcodes resume: None in its internal extended() call, and we use queue() to avoid reimplementing its batching/transaction logic.

Right, InputQueue::queue can't be used for fault tolerance.

One way to achieve fault tolerance along with InputQueue is to add auxiliary data (the A generic parameter to InputQueue) for the resume information to each batch in the queue, and then use flush_with_aux instead of queue and subsequently call extended directly. The nats connector does this.

Would it be valid to call extended() a second time after queue() with empty data + resume metadata?

No, it needs to be called exactly once.

@no-flaks-given

Copy link
Copy Markdown
Author

Switched to flush_with_aux + manual extended() with Resume::Seek per blp's guidance (nats pattern). The Queue handler now calls extended exactly once with resume metadata containing the pipeline_id. Also moved pipeline_id computation to new() so it's available in the Queue handler.

@blp

blp commented Apr 9, 2026

Copy link
Copy Markdown
Member

Switched to flush_with_aux + manual extended() with Resume::Seek per blp's guidance (nats pattern). The Queue handler now calls extended exactly once with resume metadata containing the pipeline_id. Also moved pipeline_id computation to new() so it's available in the Queue handler.

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.

@no-flaks-given

Copy link
Copy Markdown
Author

Small follow-up fix: the pipeline_id hash was using config.uri directly, which includes the password. Rotating the password would change the pipeline_id and orphan the replication slot. Fixed with a new stable_connection_identity() helper that parses the URI and hashes only host:port/db + publication + source_table. Added 5 unit tests covering password rotation, username changes, host changes, publication changes, and URI parse failure fallback.

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@no-flaks-given

Copy link
Copy Markdown
Author

@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.

@nmarasoiu

nmarasoiu commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

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".
Worth an explicit test with a timestamp that has nanosecond precision to verify round-trip fidelity.


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
explaining the limitation, since it affects the semantic correctness claim of AtLeastOnce.


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.

@no-flaks-given

no-flaks-given commented Apr 17, 2026

Copy link
Copy Markdown
Author

@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 checkpoint_watcher instead of completion_watcher — only advance the slot after Feldera has actually checkpointed. The tradeoff is real and user-facing: since etl is single-inflight, batches would be gated on checkpoint cadence instead of step completion. If Feldera checkpoints every ~30s, downstream latency goes from few-second to ~30s. Data from Postgres would sit in etl's buffers until the next checkpoint.

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?

@dumitru-nicolae-marasoiu

dumitru-nicolae-marasoiu commented Apr 19, 2026

Copy link
Copy Markdown

Thanks.

On the completion_watcher vs checkpoint_watcher flag. I agree with the flag direction and would argue for flipping the default: checkpoint_watcher (strict) as default, completion_watcher (fast) as an explicit opt-in.

The precondition for the fast path isn't "stateless circuit" alone: it's stateless circuit and idempotent output commit:

Idempotent sink Non-idempotent sink
Stateless circuit safe under completion duplicate side-effects on crash
Stateful circuit state-loss window both failure modes compound

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 prominentlyPostgresStore::new(pipeline_id, source_config) uses the source connection, and etl/migrations/20250827000000_base.sql opens with "Base schema for etl-replicator
store (applied on the source DB)"
. So Feldera is creating an etl.* schema (four tables) inside the user's source Postgres. Implications:

  • Requires CREATE SCHEMA / CREATE TABLE privileges on the source, not just REPLICATION + SELECT.
  • Read-replicas can't serve as sources.
  • An etl schema appears in the customer's DB with nothing in Feldera's config making that explicit.

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_to_json at cdc_input.rs:697-710 converts f32/f64 NaN/Infinity to Value::Null, but Cell::Numeric at line 711-714 passes through n.to_string() unconditionally. PgNumeric::Display produces literal
"NaN" / "Infinity" / "-Infinity" (etl/etl/src/conversions/numeric.rs:524-530), which downstream decimal parsers won't accept. Suggest:

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 wal_sender_timeout risks during long confirmation waits. Reading etl more carefully, etl/etl/src/replication/apply.rs:792-809 (PRIORITY 6 in run_active_iteration) explicitly fires PeriodicKeepAlive on a deadline including while blocked on an in-flight flush, with a comment calling out exactly that scenario. Connection drop on slow waits isn't a live concern. Credit where due: that path is solidly handled.

@no-flaks-given

no-flaks-given commented Apr 22, 2026

Copy link
Copy Markdown
Author

@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?

@blp
blp enabled auto-merge April 23, 2026 17:14
@blp

blp commented Apr 23, 2026

Copy link
Copy Markdown
Member

@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.

Thank you! I started the process. (I wouldn't be surprised if it took a few tries. Thanks for being patient with me!)

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?

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.

@blp

blp commented Apr 23, 2026

Copy link
Copy Markdown
Member

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. cargo fmt should be able to fix that. In addition, openapi.json needs to be regenerated. You can either do these things or apply the diff that appears in the job output.

auto-merge was automatically disabled April 23, 2026 19:16

Head branch was pushed to by a user without write access

@no-flaks-given

Copy link
Copy Markdown
Author

@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.

@blp

blp commented Apr 23, 2026

Copy link
Copy Markdown
Member

@blp ran cargo fmt and regenerated openapi.json. Pushed.

Almost there! I see that there is one remaining issue in the rerun: it wants to convert if a { if b { c } } into if a && b { c }.

@no-flaks-given

Copy link
Copy Markdown
Author

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)
@no-flaks-given

Copy link
Copy Markdown
Author

CI found PostgresCdcReaderConfig was referenced in openapi.json but not registered in the pipeline-manager ApiDoc schemas. Added it there and regenerated openapi.json. Pushed.

@blp
blp enabled auto-merge April 23, 2026 22:31
@blp
blp added this pull request to the merge queue Apr 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Apr 23, 2026
@blp
blp added this pull request to the merge queue Apr 23, 2026
@blp

blp commented Apr 23, 2026

Copy link
Copy Markdown
Member

CI failure wasn't related to the changes in this PR, requeuing.

Merged via the queue into feldera:main with commit 36dab77 Apr 24, 2026
1 check passed
nmarasoiu pushed a commit to nmarasoiu/feldera that referenced this pull request Aug 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connector for postgres snapshot-and-follow

7 participants