Skip to content

Commit d736bd6

Browse files
authored
aws_dynamodb_cdc: add checkpoint_namespace for checkpoint table sharing (#4602)
Add an optional checkpoint_namespace field to the aws_dynamodb_cdc input so multiple independent pipelines can share a single checkpoint table without overwriting each other's checkpoints. When set, the namespace is prefixed to the checkpoint hash key value (StreamArn in default mode, TableId in global_table mode); shard checkpoints, snapshot progress rows, and the global-mode resume query are all scoped by it. The default (empty) leaves checkpoint keys byte-identical to previous behavior, so existing deployments and checkpoint tables are unaffected. Namespaces containing '#' are rejected at config validation time.
1 parent c9136a9 commit d736bd6

6 files changed

Lines changed: 297 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ All notable changes to this project will be documented in this file.
2424

2525
## Unreleased
2626

27+
### Added
28+
29+
- aws_dynamodb_cdc: DynamoDB CDC now supports an optional checkpoint_namespace field, allowing multiple independent pipelines to share a single checkpoint table without overwriting each other's checkpoints. ([@squiidz](https://github.com/squiidz), [#4602](https://github.com/redpanda-data/connect/pull/4602))
30+
2731
### Fixed
2832

2933
- general: The CGO-enabled distribution binary now embeds the IANA time zone database via the `timetzdata` build tag, matching the other distributions, so `time.LoadLocation` works in minimal runtimes without system tzdata instead of silently falling back to UTC (which shifts JQL date predicates in the `jira` input). ([@squiidz](https://github.com/squiidz), [#4583](https://github.com/redpanda-data/connect/pull/4583))

docs/modules/components/pages/inputs/aws_dynamodb_cdc.adoc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ input:
4141
aws_dynamodb_cdc:
4242
tables: []
4343
checkpoint_table: redpanda_dynamodb_checkpoints
44+
checkpoint_namespace: ""
4445
start_from: trim_horizon
4546
snapshot_mode: none
4647
```
@@ -60,6 +61,7 @@ input:
6061
table_tag_filter: ""
6162
table_discovery_interval: 5m
6263
checkpoint_table: redpanda_dynamodb_checkpoints
64+
checkpoint_namespace: ""
6365
global_table: false
6466
global_table_replicas: []
6567
batch_size: 1000
@@ -141,6 +143,8 @@ NOTE: Snapshots use eventually consistent reads and do not provide point-in-time
141143
142144
Checkpoints are stored in a separate DynamoDB table (configured via `checkpoint_table`). This table is created automatically if it does not exist. On restart, the input resumes from the last checkpointed position for each shard. Snapshot progress is also checkpointed, allowing resumption mid-snapshot after failures.
143145
146+
Multiple independent pipelines can share a single checkpoint table by giving each one a distinct `checkpoint_namespace` (for example one namespace per developer or environment). Namespaces isolate checkpoints from each other: a pipeline only sees checkpoints written under its own namespace, so changing (or removing) the namespace causes the pipeline to restart from `start_from`. Note that namespaces do not coordinate consumers — two pipelines sharing the *same* namespace will still overwrite each other's checkpoints.
147+
144148
### Alternative
145149
146150
For better performance and longer retention (up to 1 year vs 24 hours), consider using Kinesis Data Streams for DynamoDB with the `aws_kinesis` input instead.
@@ -331,6 +335,15 @@ DynamoDB table name for storing checkpoints. Will be created if it doesn't exist
331335
332336
*Default*: `"redpanda_dynamodb_checkpoints"`
333337
338+
=== `checkpoint_namespace`
339+
340+
An optional namespace for checkpoints, allowing multiple independent pipelines (for example one per developer or environment) to share a single checkpoint table without overwriting each other's positions. Checkpoints written under one namespace are invisible to pipelines using a different namespace (or none), so changing this value causes the pipeline to restart from `start_from`. Must not contain `#`.
341+
342+
343+
*Type*: `string`
344+
345+
*Default*: `""`
346+
334347
=== `global_table`
335348
336349
Provision the checkpoint table as a DynamoDB Global Table (v2) so checkpoints replicate across regions. Requires `global_table_replicas`. When the table is auto-created it is created as a global table; when it already exists, its replicas are reconciled (missing regions are added via `UpdateTable`). The existing table must have been created in global mode (`TableId` hash key) — enabling this against a pre-existing non-global checkpoint table fails fast with a clear error.

internal/impl/aws/dynamodb/checkpoint.go

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type CheckpointerConfig struct {
4848
TableName string // checkpoint table name
4949
SourceTable string // source table name (portable key in global mode)
5050
StreamArn string // current region's stream ARN
51+
Namespace string // optional logical-reader scope, prefixed to the hash key value
5152
CheckpointLimit int
5253
GlobalTable bool
5354
Region string // pipeline's own region (global mode)
@@ -61,6 +62,7 @@ type Checkpointer struct {
6162
tableName string
6263
sourceTable string
6364
streamArn string
65+
namespace string
6466
checkpointLimit int
6567
globalTable bool
6668
region string
@@ -87,6 +89,7 @@ func NewCheckpointer(
8789
tableName: cfg.TableName,
8890
sourceTable: cfg.SourceTable,
8991
streamArn: cfg.StreamArn,
92+
namespace: cfg.Namespace,
9093
checkpointLimit: cfg.CheckpointLimit,
9194
globalTable: cfg.GlobalTable,
9295
region: cfg.Region,
@@ -149,10 +152,7 @@ func (c *Checkpointer) ensureTableExists(ctx context.Context) error {
149152
}
150153

151154
// Table doesn't exist, create it.
152-
hashAttr := checkpointHashKeyDefault
153-
if c.globalTable {
154-
hashAttr = checkpointHashKeyGlobal
155-
}
155+
hashAttr := c.hashAttrName()
156156
input := &dynamodb.CreateTableInput{
157157
AttributeDefinitions: []types.AttributeDefinition{
158158
{AttributeName: aws.String(hashAttr), AttributeType: types.ScalarAttributeTypeS},
@@ -246,19 +246,36 @@ func (c *Checkpointer) waitForTableActive(ctx context.Context) error {
246246
return nil
247247
}
248248

249-
// checkpointKey builds the primary key for a shard's checkpoint row. In global
250-
// mode the hash key is the source table name (portable across regions); in the
251-
// default mode it is the current stream ARN.
252-
func (c *Checkpointer) checkpointKey(shardID string) map[string]types.AttributeValue {
249+
// hashKeyValue returns the value stored in the checkpoint table's hash key:
250+
// the source table name in global mode (portable across regions), otherwise
251+
// the current stream ARN. A configured checkpoint namespace is prefixed as
252+
// "<namespace>#<value>" so independent logical readers sharing one checkpoint
253+
// table occupy separate partitions.
254+
func (c *Checkpointer) hashKeyValue() string {
255+
v := c.streamArn
253256
if c.globalTable {
254-
return map[string]types.AttributeValue{
255-
checkpointHashKeyGlobal: &types.AttributeValueMemberS{Value: c.sourceTable},
256-
checkpointRangeKey: &types.AttributeValueMemberS{Value: shardID},
257-
}
257+
v = c.sourceTable
258+
}
259+
if c.namespace == "" {
260+
return v
261+
}
262+
return c.namespace + "#" + v
263+
}
264+
265+
// hashAttrName returns the name of the checkpoint table's hash key attribute
266+
// for the current mode.
267+
func (c *Checkpointer) hashAttrName() string {
268+
if c.globalTable {
269+
return checkpointHashKeyGlobal
258270
}
271+
return checkpointHashKeyDefault
272+
}
273+
274+
// checkpointKey builds the primary key for a shard's checkpoint row.
275+
func (c *Checkpointer) checkpointKey(shardID string) map[string]types.AttributeValue {
259276
return map[string]types.AttributeValue{
260-
checkpointHashKeyDefault: &types.AttributeValueMemberS{Value: c.streamArn},
261-
checkpointRangeKey: &types.AttributeValueMemberS{Value: shardID},
277+
c.hashAttrName(): &types.AttributeValueMemberS{Value: c.hashKeyValue()},
278+
checkpointRangeKey: &types.AttributeValueMemberS{Value: shardID},
262279
}
263280
}
264281

@@ -272,8 +289,8 @@ func (c *Checkpointer) Get(ctx context.Context, shardID string) (string, error)
272289
if _, ok := errors.AsType[*types.ResourceNotFoundException](err); ok {
273290
return "", nil
274291
}
275-
return "", fmt.Errorf("getting checkpoint for table=%s stream=%s shard=%s: %w",
276-
c.tableName, c.streamArn, shardID, err)
292+
return "", fmt.Errorf("getting checkpoint for table=%s key=%s shard=%s: %w",
293+
c.tableName, c.hashKeyValue(), shardID, err)
277294
}
278295

279296
if result.Item == nil {
@@ -304,8 +321,8 @@ func (c *Checkpointer) Set(ctx context.Context, shardID, sequenceNumber, approxC
304321
Item: item,
305322
})
306323
if err != nil {
307-
return fmt.Errorf("setting checkpoint for table=%s stream=%s shard=%s seq=%s: %w",
308-
c.tableName, c.streamArn, shardID, sequenceNumber, err)
324+
return fmt.Errorf("setting checkpoint for table=%s key=%s shard=%s seq=%s: %w",
325+
c.tableName, c.hashKeyValue(), shardID, sequenceNumber, err)
309326
}
310327
return nil
311328
}
@@ -380,15 +397,15 @@ func (c *Checkpointer) prepareResume(ctx context.Context) error {
380397
TableName: aws.String(c.tableName),
381398
KeyConditionExpression: aws.String("TableId = :hash"),
382399
ExpressionAttributeValues: map[string]types.AttributeValue{
383-
":hash": &types.AttributeValueMemberS{Value: c.sourceTable},
400+
":hash": &types.AttributeValueMemberS{Value: c.hashKeyValue()},
384401
},
385402
ExclusiveStartKey: startKey,
386403
})
387404
if err != nil {
388405
if _, ok := errors.AsType[*types.ResourceNotFoundException](err); ok {
389406
return nil
390407
}
391-
return fmt.Errorf("querying checkpoint partition for %s: %w", c.sourceTable, err)
408+
return fmt.Errorf("querying checkpoint partition for %s: %w", c.hashKeyValue(), err)
392409
}
393410
for _, item := range out.Items {
394411
seqAttr, isShardRow := item["SequenceNumber"].(*types.AttributeValueMemberS)
@@ -465,10 +482,7 @@ func (c *Checkpointer) SnapshotProgress(ctx context.Context) (*SnapshotCheckpoin
465482
}
466483
}
467484

468-
hashName, hashVal := "StreamArn", c.streamArn
469-
if c.globalTable {
470-
hashName, hashVal = "TableId", c.sourceTable
471-
}
485+
hashName, hashVal := c.hashAttrName(), c.hashKeyValue()
472486
queryRes, err := c.svc.Query(ctx, &dynamodb.QueryInput{
473487
TableName: aws.String(c.tableName),
474488
KeyConditionExpression: aws.String(hashName + " = :hash AND begins_with(ShardID, :snapshot_prefix)"),

internal/impl/aws/dynamodb/checkpoint_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,190 @@ func TestResolveResume_NonGlobalFallsBackToGet(t *testing.T) {
337337
require.Equal(t, resumeExact, d.Mode)
338338
require.Equal(t, "seq-42", d.SequenceNumber)
339339
}
340+
341+
// sharedStoreAPI returns a fakeCheckpointAPI backed by a shared in-memory item
342+
// store keyed by "<hashValue>|<shardID>", so multiple Checkpointers can
343+
// exercise cross-namespace visibility against one "table".
344+
func sharedStoreAPI(store map[string]map[string]types.AttributeValue) *fakeCheckpointAPI {
345+
keyOf := func(m map[string]types.AttributeValue) string {
346+
attrStr := func(name string) string {
347+
if v, ok := m[name].(*types.AttributeValueMemberS); ok {
348+
return v.Value
349+
}
350+
return ""
351+
}
352+
// Global-mode items carry a non-key StreamArn attribute alongside the
353+
// TableId hash key, so prefer TableId when present.
354+
hash := attrStr(checkpointHashKeyGlobal)
355+
if hash == "" {
356+
hash = attrStr(checkpointHashKeyDefault)
357+
}
358+
return hash + "|" + attrStr(checkpointRangeKey)
359+
}
360+
return &fakeCheckpointAPI{
361+
describeTable: func(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {
362+
return &dynamodb.DescribeTableOutput{Table: &types.TableDescription{TableStatus: types.TableStatusActive}}, nil
363+
},
364+
putItem: func(_ context.Context, in *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {
365+
store[keyOf(in.Item)] = in.Item
366+
return &dynamodb.PutItemOutput{}, nil
367+
},
368+
getItem: func(_ context.Context, in *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {
369+
return &dynamodb.GetItemOutput{Item: store[keyOf(in.Key)]}, nil
370+
},
371+
}
372+
}
373+
374+
func TestCheckpointer_NamespacePrefixesHashKeyValue(t *testing.T) {
375+
cases := []struct {
376+
name string
377+
namespace string
378+
global bool
379+
wantAttr string
380+
wantValue string
381+
}{
382+
{"default mode no namespace", "", false, "StreamArn", "arn:A"},
383+
{"default mode namespaced", "dev-alice", false, "StreamArn", "dev-alice#arn:A"},
384+
{"global mode no namespace", "", true, "TableId", "t"},
385+
{"global mode namespaced", "dev-alice", true, "TableId", "dev-alice#t"},
386+
}
387+
for _, tc := range cases {
388+
t.Run(tc.name, func(t *testing.T) {
389+
var put *dynamodb.PutItemInput
390+
api := &fakeCheckpointAPI{
391+
describeTable: func(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {
392+
desc := &types.TableDescription{TableStatus: types.TableStatusActive}
393+
if tc.global {
394+
desc.KeySchema = globalTableKeySchema()
395+
desc.Replicas = []types.ReplicaDescription{
396+
{RegionName: aws.String("us-east-1")},
397+
{RegionName: aws.String("us-west-2")},
398+
}
399+
}
400+
return &dynamodb.DescribeTableOutput{Table: desc}, nil
401+
},
402+
putItem: func(_ context.Context, in *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {
403+
put = in
404+
return &dynamodb.PutItemOutput{}, nil
405+
},
406+
}
407+
cfg := CheckpointerConfig{
408+
TableName: "cps", SourceTable: "t", StreamArn: "arn:A",
409+
Namespace: tc.namespace, CheckpointLimit: 1,
410+
}
411+
if tc.global {
412+
cfg.GlobalTable = true
413+
cfg.Region = "us-east-1"
414+
cfg.ReplicaRegions = []string{"us-west-2"}
415+
}
416+
c, err := NewCheckpointer(context.Background(), api, cfg, checkpointTestLogger())
417+
require.NoError(t, err)
418+
419+
require.NoError(t, c.Set(context.Background(), "shard-1", "seq-1", ""))
420+
require.Equal(t, tc.wantValue, put.Item[tc.wantAttr].(*types.AttributeValueMemberS).Value)
421+
require.Equal(t, "shard-1", put.Item[checkpointRangeKey].(*types.AttributeValueMemberS).Value,
422+
"range key must never be namespaced")
423+
})
424+
}
425+
}
426+
427+
func TestCheckpointer_NamespaceIsolation(t *testing.T) {
428+
store := map[string]map[string]types.AttributeValue{}
429+
api := sharedStoreAPI(store)
430+
431+
newCP := func(ns string) *Checkpointer {
432+
c, err := NewCheckpointer(context.Background(), api, CheckpointerConfig{
433+
TableName: "cps", SourceTable: "t", StreamArn: "arn:A",
434+
Namespace: ns, CheckpointLimit: 1,
435+
}, checkpointTestLogger())
436+
require.NoError(t, err)
437+
return c
438+
}
439+
alice, bob, unscoped := newCP("dev-alice"), newCP("dev-bob"), newCP("")
440+
441+
require.NoError(t, alice.Set(context.Background(), "shard-1", "seq-alice", ""))
442+
require.NoError(t, unscoped.Set(context.Background(), "shard-1", "seq-legacy", ""))
443+
444+
got, err := alice.Get(context.Background(), "shard-1")
445+
require.NoError(t, err)
446+
require.Equal(t, "seq-alice", got, "a namespace must read back its own checkpoint")
447+
448+
got, err = bob.Get(context.Background(), "shard-1")
449+
require.NoError(t, err)
450+
require.Empty(t, got, "a namespace must not see another namespace's checkpoint")
451+
452+
got, err = unscoped.Get(context.Background(), "shard-1")
453+
require.NoError(t, err)
454+
require.Equal(t, "seq-legacy", got, "un-namespaced reader must not see namespaced rows")
455+
}
456+
457+
func TestSnapshotCheckpoints_UseNamespacedHashValue(t *testing.T) {
458+
var getIn *dynamodb.GetItemInput
459+
var queryIn *dynamodb.QueryInput
460+
var putIn *dynamodb.PutItemInput
461+
api := &fakeCheckpointAPI{
462+
describeTable: func(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {
463+
return &dynamodb.DescribeTableOutput{Table: &types.TableDescription{TableStatus: types.TableStatusActive}}, nil
464+
},
465+
getItem: func(_ context.Context, in *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {
466+
getIn = in
467+
return &dynamodb.GetItemOutput{}, nil
468+
},
469+
query: func(_ context.Context, in *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {
470+
queryIn = in
471+
return &dynamodb.QueryOutput{}, nil
472+
},
473+
putItem: func(_ context.Context, in *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {
474+
putIn = in
475+
return &dynamodb.PutItemOutput{}, nil
476+
},
477+
}
478+
c, err := NewCheckpointer(context.Background(), api, CheckpointerConfig{
479+
TableName: "cps", SourceTable: "t", StreamArn: "arn:A",
480+
Namespace: "dev-alice", CheckpointLimit: 1,
481+
}, checkpointTestLogger())
482+
require.NoError(t, err)
483+
484+
_, err = c.SnapshotProgress(context.Background())
485+
require.NoError(t, err)
486+
require.Equal(t, "dev-alice#arn:A", getIn.Key["StreamArn"].(*types.AttributeValueMemberS).Value,
487+
"snapshot completion sentinel must use the namespaced hash value")
488+
require.Equal(t, "dev-alice#arn:A", queryIn.ExpressionAttributeValues[":hash"].(*types.AttributeValueMemberS).Value,
489+
"snapshot segment query must use the namespaced hash value")
490+
491+
require.NoError(t, c.UpdateSnapshotProgress(context.Background(), 3, nil, 42))
492+
require.Equal(t, "dev-alice#arn:A", putIn.Item["StreamArn"].(*types.AttributeValueMemberS).Value,
493+
"snapshot segment writes must use the namespaced hash value")
494+
require.Equal(t, "snapshot#segment#3", putIn.Item[checkpointRangeKey].(*types.AttributeValueMemberS).Value)
495+
}
496+
497+
func TestPrepareResume_UsesNamespacedHashValue(t *testing.T) {
498+
var queryIn *dynamodb.QueryInput
499+
api := &fakeCheckpointAPI{
500+
describeTable: func(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {
501+
return &dynamodb.DescribeTableOutput{Table: &types.TableDescription{
502+
TableName: aws.String("cps"),
503+
TableStatus: types.TableStatusActive,
504+
KeySchema: globalTableKeySchema(),
505+
Replicas: []types.ReplicaDescription{
506+
{RegionName: aws.String("us-east-1")},
507+
{RegionName: aws.String("us-west-2")},
508+
},
509+
}}, nil
510+
},
511+
query: func(_ context.Context, in *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {
512+
queryIn = in
513+
return &dynamodb.QueryOutput{}, nil
514+
},
515+
}
516+
c, err := NewCheckpointer(context.Background(), api, CheckpointerConfig{
517+
TableName: "cps", SourceTable: "t", StreamArn: "arn:A", CheckpointLimit: 1,
518+
Namespace: "dev-alice", GlobalTable: true, Region: "us-east-1", ReplicaRegions: []string{"us-west-2"},
519+
}, checkpointTestLogger())
520+
require.NoError(t, err)
521+
522+
_, err = c.ResolveResume(context.Background(), "shard-1")
523+
require.NoError(t, err)
524+
require.Equal(t, "dev-alice#t", queryIn.ExpressionAttributeValues[":hash"].(*types.AttributeValueMemberS).Value,
525+
"global-mode resume partition query must use the namespaced hash value")
526+
}

0 commit comments

Comments
 (0)