-
Notifications
You must be signed in to change notification settings - Fork 957
mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504) #4676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
d01137a
7ed6bc5
b6fed39
0d016a2
00c7cdd
2e245cf
dd2bb0d
d20f32c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // Copyright 2026 Redpanda Data, Inc. | ||
| // | ||
| // Licensed as a Redpanda Enterprise file under the Redpanda Community | ||
| // License (the "License"); you may not use this file except in compliance with | ||
| // the License. You may obtain a copy of the License at | ||
| // | ||
| // https://github.com/redpanda-data/connect/v4/blob/main/licenses/rcl.md | ||
|
|
||
| package cdc | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| "go.mongodb.org/mongo-driver/v2/bson" | ||
| ) | ||
|
|
||
| func TestSnapshotAckFn(t *testing.T) { | ||
| noPersist := func(context.Context, bson.Raw) error { | ||
| return errors.New("persist must not be called for a nil token") | ||
| } | ||
|
|
||
| t.Run("a nack resolves too: auto_replay_nacks off is an opt-in drop", func(t *testing.T) { | ||
| resolved := false | ||
| ackFn := snapshotAckFn(func() *bson.Raw { | ||
| resolved = true | ||
| return nil | ||
| }, noPersist) | ||
| require.NoError(t, ackFn(t.Context(), errors.New("downstream failure"))) | ||
| require.True(t, resolved, "a nacked batch is deleted per the auto_replay_nacks contract; the stream must continue past it") | ||
| }) | ||
|
|
||
| t.Run("ack resolves and accepts a nil resume token", func(t *testing.T) { | ||
| resolved := false | ||
| ackFn := snapshotAckFn(func() *bson.Raw { | ||
| resolved = true | ||
| return nil | ||
| }, noPersist) | ||
| require.NoError(t, ackFn(t.Context(), nil)) | ||
| require.True(t, resolved) | ||
| }) | ||
|
|
||
| t.Run("a streaming token surfaced by out-of-order acks is persisted", func(t *testing.T) { | ||
| // Snapshot and streaming share one ordered tracker: when a streaming | ||
| // batch acks before an earlier snapshot batch, the snapshot slot's | ||
| // resolve legitimately returns the streaming token as the contiguous | ||
| // frontier. It must be persisted, not dropped. | ||
| token := bson.Raw("streaming-token") | ||
| var persisted bson.Raw | ||
| ackFn := snapshotAckFn(func() *bson.Raw { return &token }, func(_ context.Context, tok bson.Raw) error { | ||
| persisted = tok | ||
| return nil | ||
| }) | ||
| require.NoError(t, ackFn(t.Context(), nil)) | ||
| require.Equal(t, token, persisted, "the resolved streaming checkpoint must persist through the same path as a streaming ack") | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -275,6 +275,83 @@ pg_stream: | |
| require.NoError(t, streamOut.StopWithin(time.Second*10)) | ||
| } | ||
|
|
||
| // TestIntegrationPostgresMarshalFailureStopsStream verifies that a row whose | ||
| // value cannot be marshalled to JSON (float8 NaN: the decoder passes it | ||
| // through as a float64, which encoding/json rejects) stops the stream instead | ||
| // of being silently skipped. Before the fix the row and the remainder of its | ||
| // WAL batch were dropped while the stream kept running and checkpointed past | ||
| // them. See CON-504. | ||
| func TestIntegrationPostgresMarshalFailureStopsStream(t *testing.T) { | ||
| integration.CheckSkip(t) | ||
| databaseURL, db, err := ResourceWithPostgreSQLVersion(t, "16") | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = db.Exec("CREATE TABLE IF NOT EXISTS nan_floats (id serial PRIMARY KEY, value DOUBLE PRECISION);") | ||
| require.NoError(t, err) | ||
|
|
||
| template := fmt.Sprintf(` | ||
| pg_stream: | ||
| dsn: %s | ||
| slot_name: test_slot_marshal_failure | ||
| stream_snapshot: false | ||
| schema: public | ||
| tables: | ||
| - nan_floats | ||
| `, databaseURL) | ||
|
|
||
| var ( | ||
| receivedMu sync.Mutex | ||
| received []string | ||
| ) | ||
| builder := service.NewStreamBuilder() | ||
| require.NoError(t, builder.SetLoggerYAML(`level: OFF`)) | ||
| require.NoError(t, builder.AddInputYAML(template)) | ||
| require.NoError(t, builder.AddConsumerFunc(func(_ context.Context, m *service.Message) error { | ||
| b, err := m.AsBytes() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| receivedMu.Lock() | ||
| received = append(received, string(b)) | ||
| receivedMu.Unlock() | ||
| return nil | ||
| })) | ||
| stream, err := builder.Build() | ||
| require.NoError(t, err) | ||
| license.InjectTestService(stream.Resources()) | ||
| go func() { _ = stream.Run(t.Context()) }() | ||
|
|
||
| // Give the input time to create the replication slot: streaming-only mode | ||
| // only sees rows inserted after the slot exists. | ||
| time.Sleep(5 * time.Second) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed sleep as a readiness gate makes this test flaky. With Suggested fix: replace the sleep with a readiness poll — e.g. Ref: |
||
|
|
||
| // Sentinel row proves the stream is live before the poison row arrives. | ||
| _, err = db.Exec("INSERT INTO nan_floats (value) VALUES (1.5);") | ||
| require.NoError(t, err) | ||
| require.Eventually(t, func() bool { | ||
| receivedMu.Lock() | ||
| defer receivedMu.Unlock() | ||
| return len(received) == 1 | ||
| }, 30*time.Second, 100*time.Millisecond, "sentinel row was never streamed - stream not live") | ||
|
|
||
| // Poison row (unmarshalable), then a normal row behind it. | ||
| _, err = db.Exec("INSERT INTO nan_floats (value) VALUES ('NaN'::double precision);") | ||
| require.NoError(t, err) | ||
| _, err = db.Exec("INSERT INTO nan_floats (value) VALUES (2.5);") | ||
| require.NoError(t, err) | ||
|
|
||
| // The core guarantee: nothing may be delivered past the poison row. In the | ||
| // buggy version the NaN row was silently dropped and 2.5 arrived here. | ||
| time.Sleep(10 * time.Second) | ||
| receivedMu.Lock() | ||
| got := append([]string(nil), received...) | ||
| receivedMu.Unlock() | ||
| require.Len(t, got, 1, "no row may be delivered past an unmarshalable row; got: %v", got) | ||
| require.Contains(t, got[0], "1.5") | ||
|
|
||
| require.NoError(t, stream.StopWithin(30*time.Second)) | ||
| } | ||
|
|
||
| // TestIntegrationPostgresSnapshotAckBarrier verifies that a crash during the | ||
| // snapshot->stream handoff (after snapshot rows are emitted but before they are | ||
| // acknowledged) does not lose data: because the replication slot is only | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.