Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ A https://docs.redpanda.com/redpanda-connect/components/caches/about[cache resou

A list of options to be included in the changefeed (WITH X, Y...).

NOTE: Both the CURSOR option and UPDATED will be ignored from these options when a `cursor_cache` is specified, as they are set explicitly by Redpanda Connect in this case.
NOTE: Both the CURSOR option and UPDATED will be ignored from these options when a `cursor_cache` is specified, as they are set explicitly by Redpanda Connect in this case. A RESOLVED option is also added (unless one is supplied here): the stored cursor only ever advances to resolved timestamps whose rows have all been acknowledged downstream, so a restart redelivers at most the changes since the last resolved timestamp (bounded by the `changefeed.min_checkpoint_frequency` cluster setting) and never skips data. Resolved records are internal cursor bookkeeping and are never emitted as messages; without a `cursor_cache` they are discarded.


*Type*: `array`
Expand Down
41 changes: 41 additions & 0 deletions internal/impl/cockroachdb/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package crdb

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -46,3 +47,43 @@ options:
assert.Equal(t, "EXPERIMENTAL CHANGEFEED FOR strm_2 WITH UPDATED, CURSOR='1637953249519902405.0000000000'", selectInput.statement)
require.NoError(t, selectInput.Close(t.Context()))
}

func TestCRDBConfigParseWithCursorCache(t *testing.T) {
spec := crdbChangefeedInputConfig()
env := service.NewEnvironment()

parse := func(t *testing.T, conf string) *crdbChangefeedInput {
t.Helper()
selectConfig, err := spec.ParseYAML(conf, env)
require.NoError(t, err)
selectInput, err := newCRDBChangefeedInputFromConfig(selectConfig, service.MockResources(service.MockResourcesOptAddCache("mycache")))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, selectInput.Close(context.Background())) })
return selectInput
}

t.Run("adds RESOLVED and strips CURSOR/UPDATED", func(t *testing.T) {
selectInput := parse(t, `
dsn: postgresql://root@localhost:26257/defaultdb?sslmode=disable
tables:
- strm_2
cursor_cache: mycache
options:
- UPDATED
- CURSOR='1637953249519902405.0000000000'
`)
assert.Equal(t, "EXPERIMENTAL CHANGEFEED FOR strm_2 WITH UPDATED, RESOLVED", selectInput.statement)
})

t.Run("preserves a user-supplied resolved interval", func(t *testing.T) {
selectInput := parse(t, `
dsn: postgresql://root@localhost:26257/defaultdb?sslmode=disable
tables:
- strm_2
cursor_cache: mycache
options:
- resolved='5s'
`)
assert.Equal(t, "EXPERIMENTAL CHANGEFEED FOR strm_2 WITH resolved='5s', UPDATED", selectInput.statement)
})
}
189 changes: 138 additions & 51 deletions internal/impl/cockroachdb/input_changefeed.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ func crdbChangefeedInputConfig() *service.ConfigSpec {
ShortDescription("Cache resource storing the last delivered cursor, so restarts resume instead of re-reading the table.").
Optional(),
service.NewStringListField("options").
Description("A list of options to be included in the changefeed (WITH X, Y...).\n\nNOTE: Both the CURSOR option and UPDATED will be ignored from these options when a `cursor_cache` is specified, as they are set explicitly by Redpanda Connect in this case.").
ShortDescription("Options to include in the changefeed. CURSOR and UPDATED are ignored when cursor_cache is set.").
Description("A list of options to be included in the changefeed (WITH X, Y...).\n\nNOTE: Both the CURSOR option and UPDATED will be ignored from these options when a `cursor_cache` is specified, as they are set explicitly by Redpanda Connect in this case. A RESOLVED option is also added (unless one is supplied here): the stored cursor only ever advances to resolved timestamps whose rows have all been acknowledged downstream, so a restart redelivers at most the changes since the last resolved timestamp (bounded by the `changefeed.min_checkpoint_frequency` cluster setting) and never skips data. Resolved records are internal cursor bookkeeping and are never emitted as messages; without a `cursor_cache` they are discarded.").
ShortDescription("Options to include in the changefeed. CURSOR and UPDATED are ignored when cursor_cache is set, and RESOLVED is added.").
Example([]string{`virtual_columns="omitted"`}).
Advanced().
Optional(),
Expand All @@ -72,6 +72,19 @@ type crdbChangefeedInput struct {
statement string
cursorCache string
cursorCheckpointer *checkpoint.Capped[string]
// lastResolved is the most recent resolved timestamp seen in stream order.
// Data rows carry it as their tracker payload so an out-of-order ack can
// never mask a pending resolved timestamp behind an empty payload. Only
// touched from the Read goroutine.
lastResolved string
// persistMu serializes release+persistCursor pairs. Releases hand out
// monotonically increasing timestamps, but acks (pipeline goroutines) and
// resolved records (Read goroutine) persist concurrently: without a shared
// critical section two writes can land out of order and regress the cursor.
persistMu sync.Mutex
// resolvedDropWarning fires once when resolved records are discarded
// because no cursor_cache is configured.
resolvedDropWarning sync.Once

pgConfig *pgxpool.Config
pgPool *pgxpool.Pool
Expand Down Expand Up @@ -124,16 +137,28 @@ func newCRDBChangefeedInputFromConfig(conf *service.ParsedConfig, res *service.R
if c.cursorCache == "" {
options = tmpOptions
} else {
hasResolved := false
for _, o := range tmpOptions {
if strings.HasPrefix(strings.ToLower(o), "updated") {
continue
}
if strings.HasPrefix(strings.ToLower(o), "cursor") {
continue
}
if strings.HasPrefix(strings.ToLower(o), "resolved") {
hasResolved = true
}
options = append(options, o)
}
options = append(options, "UPDATED")
if !hasResolved {
// Only RESOLVED timestamps are safe cursors: every row of a
// transaction (and the entire initial backfill) shares one
// `updated` timestamp, and CURSOR resume is exclusive, so a
// row-level cursor would skip that timestamp's remaining rows on
// restart. A user-supplied resolved='interval' option is kept.
options = append(options, "RESOLVED")
}
if err := res.AccessCache(context.Background(), c.cursorCache, func(c service.Cache) {
cursorBytes, cErr := c.Get(context.Background(), cursorCacheKey)
if cErr != nil {
Expand Down Expand Up @@ -244,6 +269,16 @@ func (c *crdbChangefeedInput) closeConnection() {
}
}

// persistCursor writes a resolved cursor timestamp to the cursor cache.
func (c *crdbChangefeedInput) persistCursor(ctx context.Context, cursorTimestamp string) (cErr error) {
if err := c.res.AccessCache(ctx, c.cursorCache, func(cache service.Cache) {
cErr = cache.Set(ctx, cursorCacheKey, []byte(cursorTimestamp), nil)
}); err != nil {
return err
}
return
}

func (c *crdbChangefeedInput) Read(ctx context.Context) (*service.Message, service.AckFunc, error) {
c.dbMut.Lock()
defer c.dbMut.Unlock()
Expand All @@ -252,64 +287,116 @@ func (c *crdbChangefeedInput) Read(ctx context.Context) (*service.Message, servi
return nil, nil, service.ErrNotConnected
}

// rows.Next() blocks until the next changefeed event. The mutex is held to
// prevent closeConnection() from calling rows.Close() concurrently. On
// shutdown, SoftStopCtx cancels the query context which unblocks this call.
if !c.rows.Next() {
err := c.rows.Err()
c.closeQueryLocked()
for {
// rows.Next() blocks until the next changefeed event. The mutex is held to
// prevent closeConnection() from calling rows.Close() concurrently. On
// shutdown, SoftStopCtx cancels the query context which unblocks this call.
if !c.rows.Next() {
err := c.rows.Err()
c.closeQueryLocked()

if c.shutSig.IsSoftStopSignalled() {
return nil, nil, service.ErrNotConnected
}
if err == nil {
err = service.ErrNotConnected
} else {
err = fmt.Errorf("row read: %w", err)
if c.shutSig.IsSoftStopSignalled() {
return nil, nil, service.ErrNotConnected
}
if err == nil {
err = service.ErrNotConnected
} else {
err = fmt.Errorf("row read: %w", err)
}
return nil, nil, err
}
return nil, nil, err
}

values, err := c.rows.Values()
if err != nil {
return nil, nil, fmt.Errorf("row values: %w", err)
}

var cursorReleaseFn func() *string

rowBytes := values[2].([]byte)
if gObj, err := gabs.ParseJSON(rowBytes); err == nil {
if cursorTimestamp, _ := gObj.S("updated").Data().(string); cursorTimestamp != "" {
cursorReleaseFn, _ = c.cursorCheckpointer.Track(ctx, cursorTimestamp, 1)
values, err := c.rows.Values()
if err != nil {
return nil, nil, fmt.Errorf("row values: %w", err)
}
}

// Construct the new JSON
var jsonBytes []byte
if jsonBytes, err = json.Marshal(map[string]string{
"table": values[0].(string),
"primary_key": string(values[1].([]byte)), // Stringified JSON (Array)
"row": string(rowBytes), // Stringified JSON (Object)
}); err != nil {
return nil, nil, err
}

msg := service.NewMessage(jsonBytes)
return msg, func(ctx context.Context, _ error) (cErr error) {
if cursorReleaseFn == nil {
return nil
rowBytes := values[2].([]byte)
gObj, gErr := gabs.ParseJSON(rowBytes)

// Resolved records carry NULL table/key columns and are bookkeeping,
// never emitted downstream. A resolved timestamp is CockroachDB's
// guarantee that nothing at or below it will be emitted again — the
// only safe cursor (and CockroachDB does not emit one until the
// initial scan completes, so a persisted cursor always covers the
// backfill). Register it behind the in-flight rows (immediately
// resolved marker): the timestamp persists once every row before it
// is acked, either right here or inside the last outstanding ack.
if gErr == nil {
if resolvedTs, _ := gObj.S("resolved").Data().(string); resolvedTs != "" {
if c.cursorCache == "" {
// Resolved records are cursor bookkeeping, never emitted as
// messages; without a cursor_cache there is no cursor to
// advance, so they are dropped. Warn once so a user who
// supplied resolved='...' expecting output can see why
// nothing surfaces.
c.resolvedDropWarning.Do(func() {
c.logger.Warnf("Discarding RESOLVED timestamp records: they are cursor bookkeeping and are never emitted as messages, and without a cursor_cache there is no cursor to advance")
})
continue
}
Comment on lines +327 to +337

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

NOTE (observability/docs): when a user supplies resolved='...' in options without cursor_cache, this branch now silently discards every resolved record — no message emitted, no log at any level. Previously those records reached the row-assertion path (a crash, per the first commit's message), so going from panic to silent drop is an improvement, but the new behaviour is undocumented and unobservable:

  • The options doc note added in this PR (input_changefeed.go#L62-L63) only explains the cursor_cache-is-set case; nothing tells a user that resolved records are consumed as bookkeeping and never surface downstream.
  • CONTRIBUTING.md §1.2.2 — "Unexpected behavior should emit warning or error logs"; §1.2.3 — "Known limitations and edge cases are documented."

Suggested fix: emit a debug (or one-shot warn) log when a resolved record is dropped because no cursor_cache is configured, and extend the options field description to state that resolved records are internal bookkeeping and are never emitted as messages.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c095c91 — a one-shot warning explains the discard when resolved records arrive with no cursor_cache configured, and the options field description (and regenerated docs) now state that resolved records are internal cursor bookkeeping, never emitted as messages, and are discarded without a cursor_cache.

releaseFn, err := c.cursorCheckpointer.Track(ctx, resolvedTs, 1)
if err != nil {
return nil, nil, fmt.Errorf("tracking resolved cursor: %w", err)
}
c.lastResolved = resolvedTs
c.persistMu.Lock()
if cursorTimestamp := releaseFn(); cursorTimestamp != nil && *cursorTimestamp != "" {
if err := c.persistCursor(ctx, *cursorTimestamp); err != nil {
c.logger.Errorf("Failed to persist resolved cursor: %v", err)
}
}
c.persistMu.Unlock()
continue
}
}
cursorTimestamp := cursorReleaseFn()
if cursorTimestamp == nil {
return nil

var cursorReleaseFn func() *string
if c.cursorCache != "" {
// Data rows carry the last resolved timestamp seen before them as
// payload: they hold the ordered tracker's frontier (so no resolved
// timestamp can persist past an un-acked row) without masking one —
// if rows tracked with an empty payload resolved out of order, the
// frontier payload could regress to "" and a safe pending resolved
// timestamp would never persist. A row tracked after resolved T
// only becomes contiguously resolved once T and everything before
// it acked, so carrying T is always safe. Row-level `updated`
// timestamps are unsafe cursors: every row of a transaction — and
// the entire initial backfill — shares one, and CURSOR resume is
// exclusive.
if cursorReleaseFn, err = c.cursorCheckpointer.Track(ctx, c.lastResolved, 1); err != nil {
return nil, nil, fmt.Errorf("tracking row checkpoint: %w", err)
}
}
Comment on lines +354 to 370

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tracking data rows with an empty payload can silently swallow a pending resolved timestamp, so the cursor may stop advancing under out-of-order acks.

checkpoint.Capped's release closure returns the payload of the highest contiguously-resolved entry, not the payload of the entry being released — proven in-repo by oracledb/batcher_test.go#L75-L81 ("expected the streaming batch's SCN to survive the out-of-order snapshot ack").

Consider the tracked sequence r1(""), resolved("T1"), r2(""):

  1. resolved("T1") is released immediately in the branch above → returns nil because r1 is still pending.
  2. r2 is acked out of order → returns nil.
  3. r1 is acked → the contiguous run now extends through r2, so the release returns r2's payload "", not "T1"input_changefeed.go#L366-L369 then treats it as "nothing to persist" and T1 is dropped.

Every row before T1 was acked at that point, so T1 was safe to persist. With a multi-threaded pipeline (acks routinely complete out of order) a resolved marker is persisted only in the narrow case where the row tracked immediately after it is still un-acked, so under sustained load the cursor can go long stretches without advancing and a restart replays far more than the options docs promise ("redelivers at most the changes since the last resolved timestamp").

Suggested fix: track data rows with the last-seen resolved timestamp as the payload rather than "", the way mssqlserver/oracledb carry checkpointLSN/checkpointSCN on every batch. A row tracked after resolved T is only ever contiguously resolved once T and everything before it is acked, so carrying T forward is safe and keeps the frontier payload from regressing to the empty string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b335937. Data rows now carry the last resolved timestamp seen before them as their tracker payload (the mssqlserver/oracledb pattern suggested): a row tracked after resolved T only becomes contiguously resolved once T and everything before it acked, so the frontier payload can never regress to the empty string and a safe pending timestamp always persists. The same commit also serializes each release+persistCursor pair behind a persistMu — with carried payloads the concurrent ack/Read persist paths had the same out-of-order-write race just fixed in mssqlserver's batcher. Both integration tests re-verified green.

if err := c.res.AccessCache(ctx, c.cursorCache, func(c service.Cache) {
cErr = c.Set(ctx, cursorCacheKey, []byte(*cursorTimestamp), nil)

// Construct the new JSON
var jsonBytes []byte
if jsonBytes, err = json.Marshal(map[string]string{
"table": values[0].(string),
"primary_key": string(values[1].([]byte)), // Stringified JSON (Array)
"row": string(rowBytes), // Stringified JSON (Object)
}); err != nil {
return err
return nil, nil, err
}
return
}, nil

msg := service.NewMessage(jsonBytes)
// The ack error is deliberately ignored: nacks are replayed by
// auto_replay_nacks (the default), and disabling that is a documented
// opt-in to DROP rejected messages, so the cursor must advance past
// them rather than pin the tracker.
return msg, func(ctx context.Context, _ error) error {
if cursorReleaseFn == nil {
return nil
}
c.persistMu.Lock()
defer c.persistMu.Unlock()
cursorTimestamp := cursorReleaseFn()
if cursorTimestamp == nil || *cursorTimestamp == "" {
return nil
}
return c.persistCursor(ctx, *cursorTimestamp)
}, nil
}
}

func (c *crdbChangefeedInput) Close(ctx context.Context) error {
Expand Down
Loading