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
15 changes: 14 additions & 1 deletion docs/modules/components/pages/inputs/aws_dynamodb_cdc.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ input:
checkpoint_table: redpanda_dynamodb_checkpoints
checkpoint_namespace: ""
start_from: trim_horizon
auto_replay_nacks: true
snapshot_mode: none
```

Expand All @@ -69,6 +70,7 @@ input:
start_from: trim_horizon
checkpoint_limit: 1000
max_tracked_shards: 10000
auto_replay_nacks: true
throttle_backoff: 100ms
snapshot_mode: none
snapshot_segments: 1
Expand Down Expand Up @@ -382,7 +384,9 @@ Time to wait between polling attempts when no records are available.

=== `start_from`

Where to start reading when no checkpoint exists. `trim_horizon` starts from the oldest available record, `latest` starts from new records.
Where to start reading on a genuinely fresh pipeline (no checkpoint state exists yet under this `checkpoint_namespace` for the stream). `trim_horizon` starts from the oldest available record, `latest` starts from new records.

`latest` is honoured only on that first discovery: once any checkpoint state exists, shards discovered later - rotation children found by the periodic refresh, and any checkpoint-less shard after a restart - always start at `trim_horizon` so their backlog is never skipped. In practice a restart under `latest` therefore replays from each shard's oldest retained record rather than only new records; at-least-once delivery takes precedence over the configured start position.


*Type*: `string`
Expand Down Expand Up @@ -412,6 +416,15 @@ Maximum number of shards to track simultaneously. Prevents memory issues with ex

*Default*: `10000`

=== `auto_replay_nacks`

Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. If set to `false` these messages will instead be deleted. Disabling auto replays can greatly improve memory efficiency of high throughput streams as the original shape of the data can be discarded immediately upon consumption and mutation.


*Type*: `bool`

*Default*: `true`

=== `throttle_backoff`

Time to wait when applying backpressure due to too many in-flight messages.
Expand Down
32 changes: 32 additions & 0 deletions internal/impl/aws/dynamodb/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,38 @@ func (c *Checkpointer) CheckpointLimit() int {
return c.checkpointLimit
}

// HasAnyState reports whether any checkpoint state (shard checkpoints or
// snapshot progress) exists under this pipeline's namespace and stream/table
// key. Used to scope start_from to genuinely fresh pipelines: shards that
// appear once state exists are stream-rotation children whose backlog must
// not be skipped.
func (c *Checkpointer) HasAnyState(ctx context.Context) (bool, error) {
result, err := c.svc.Query(ctx, &dynamodb.QueryInput{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HasAnyState is now the sole gate for honoring start_from: latest, but Query defaults to eventually consistent reads.

Failure scenario: a pipeline crashes and is restarted within a second or two (the common restart case). The checkpoint rows written moments before the crash are not yet visible to this probe, so HasAnyState returns falsehonorStartFrom.Store(true) → shards whose per-shard checkpoint read (also eventually consistent, Get) is stale for the same reason fall into the default branch and are re-positioned at LATEST — silently skipping their backlog, which is exactly the hole this change closes.

Since the probe is Limit: 1, adding ConsistentRead: true makes the decision deterministic for a negligible extra RCU.

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 bea7052 — the probe now sets ConsistentRead: true. (The per-shard Get staleness alone is safe: with honorStartFrom false a missing checkpoint falls through to TRIM_HORIZON, so the probe was the only decision that needed the strong read.)

TableName: aws.String(c.tableName),
KeyConditionExpression: aws.String("#hk = :hv"),
ExpressionAttributeNames: map[string]string{
"#hk": c.hashAttrName(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":hv": &types.AttributeValueMemberS{Value: c.hashKeyValue()},
},
Limit: aws.Int32(1),
// This probe is the sole gate for honoring start_from: latest. An
// eventually consistent read could miss checkpoint rows written
// moments before a crash-restart and reposition shards at LATEST,
// silently skipping their backlog; Limit 1 makes the strong read
// nearly free.
ConsistentRead: aws.Bool(true),
})
if err != nil {
if _, ok := errors.AsType[*types.ResourceNotFoundException](err); ok {
return false, nil
}
return false, fmt.Errorf("probing checkpoint state for table=%s key=%s: %w", c.tableName, c.hashKeyValue(), err)
}
return len(result.Items) > 0, nil
}

type resumeMode int

const (
Expand Down
43 changes: 43 additions & 0 deletions internal/impl/aws/dynamodb/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,49 @@ func shardRow(streamArn, shardID, seq, ts string) map[string]types.AttributeValu
return row
}

func TestHasAnyState(t *testing.T) {
t.Run("false on an empty partition", func(t *testing.T) {
c := globalCheckpointerWithPartition(t, "arn:A", nil)
has, err := c.HasAnyState(context.Background())
require.NoError(t, err)
require.False(t, has)
})

t.Run("true when any row exists", func(t *testing.T) {
c := globalCheckpointerWithPartition(t, "arn:A", []map[string]types.AttributeValue{
shardRow("arn:A", "shard-1", "seq-9", ""),
})
has, err := c.HasAnyState(context.Background())
require.NoError(t, err)
require.True(t, has)
})

t.Run("queries the namespaced hash key", func(t *testing.T) {
var queryIn *dynamodb.QueryInput
api := &fakeCheckpointAPI{
describeTable: func(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {
return &dynamodb.DescribeTableOutput{Table: &types.TableDescription{TableStatus: types.TableStatusActive}}, nil
},
query: func(_ context.Context, in *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {
queryIn = in
return &dynamodb.QueryOutput{}, nil
},
}
c, err := NewCheckpointer(context.Background(), api, CheckpointerConfig{
TableName: "cps", SourceTable: "t", StreamArn: "arn:A",
Namespace: "dev", CheckpointLimit: 1, Region: "us-east-1",
}, checkpointTestLogger())
require.NoError(t, err)

_, err = c.HasAnyState(context.Background())
require.NoError(t, err)
require.NotNil(t, queryIn)
hv, ok := queryIn.ExpressionAttributeValues[":hv"].(*types.AttributeValueMemberS)
require.True(t, ok)
require.Equal(t, "dev#arn:A", hv.Value)
})
}

func TestCDCCheckpointProbeNeeded(t *testing.T) {
// Only an exact, same-region resume can be a stale checkpoint: its sequence
// number belongs to this stream, so a failed iterator means trimmed data.
Expand Down
Loading