From 29f9eeb769675f43d3ecca70911464b160b17b99 Mon Sep 17 00:00:00 2001 From: Zeynel Koca Date: Mon, 6 Oct 2025 15:03:18 +0200 Subject: [PATCH 1/3] Global ttlInSeconds metadata configuration for DynamoDb Signed-off-by: Zeynel Koca --- state/aws/dynamodb/dynamodb.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/state/aws/dynamodb/dynamodb.go b/state/aws/dynamodb/dynamodb.go index af620165e3..85044bc338 100644 --- a/state/aws/dynamodb/dynamodb.go +++ b/state/aws/dynamodb/dynamodb.go @@ -48,6 +48,7 @@ type StateStore struct { table string ttlAttributeName string partitionKey string + ttlInSeconds *int dynamodbClient *dynamodb.Client } @@ -64,6 +65,7 @@ type dynamoDBMetadata struct { Table string `json:"table"` TTLAttributeName string `json:"ttlAttributeName"` PartitionKey string `json:"partitionKey"` + TTLInSeconds *int `json:"ttlInSeconds" mapstructure:"ttlInSeconds"` } const ( @@ -110,6 +112,7 @@ func (d *StateStore) Init(ctx context.Context, metadata state.Metadata) error { d.table = meta.Table d.ttlAttributeName = meta.TTLAttributeName d.partitionKey = meta.PartitionKey + d.ttlInSeconds = meta.TTLInSeconds if err := d.validateTableAccess(ctx); err != nil { return fmt.Errorf("error validating DynamoDB table '%s' access: %w", d.table, err) @@ -368,6 +371,11 @@ func (d *StateStore) parseTTL(req *state.SetRequest) (*int64, error) { return &expirationTime, nil } + // apply global TTL if no explicit TTL in request metadata + if d.ttlInSeconds != nil { + expirationTime := time.Now().Unix() + int64(*d.ttlInSeconds) + return &expirationTime, nil + } } return nil, nil @@ -414,6 +422,10 @@ func (d *StateStore) Multi(ctx context.Context, request *state.TransactionalStat if err != nil { return fmt.Errorf("dynamodb error: failed to marshal value for key %s: %w", req.Key, err) } + ttl, err := d.parseTTL(&req) + if err != nil { + return fmt.Errorf("dynamodb error: failed to parse ttlInSeconds: %w", err) + } twi.Put = &types.Put{ TableName: ptr.Of(d.table), Item: map[string]types.AttributeValue{ @@ -425,6 +437,11 @@ func (d *StateStore) Multi(ctx context.Context, request *state.TransactionalStat }, }, } + if ttl != nil { + twi.Put.Item[d.ttlAttributeName] = &types.AttributeValueMemberN{ + Value: strconv.FormatInt(*ttl, 10), + } + } case state.DeleteRequest: twi.Delete = &types.Delete{ From 9102a8e2a514e6f506cb979054d6e0913b7c981c Mon Sep 17 00:00:00 2001 From: Zeynel Koca Date: Mon, 6 Oct 2025 15:03:33 +0200 Subject: [PATCH 2/3] Document new property Signed-off-by: Zeynel Koca --- state/aws/dynamodb/metadata.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/state/aws/dynamodb/metadata.yaml b/state/aws/dynamodb/metadata.yaml index dfc7d0ab36..c5400e0e12 100644 --- a/state/aws/dynamodb/metadata.yaml +++ b/state/aws/dynamodb/metadata.yaml @@ -36,6 +36,12 @@ metadata: The table attribute name which should be used for TTL. example: '"expiresAt"' type: string + - name: ttlInSeconds + required: false + description: | + Allows specifying a default Time-to-live (TTL) in seconds that will be applied to every state store request unless TTL is explicitly defined via the request metadata. + example: '"600"' + type: number - name: partitionKey required: false description: | From 031a050998a9370c2dd2022d2ea20dbdca313eab Mon Sep 17 00:00:00 2001 From: Zeynel Koca Date: Mon, 6 Oct 2025 15:03:45 +0200 Subject: [PATCH 3/3] Unit tests Signed-off-by: Zeynel Koca --- state/aws/dynamodb/dynamodb_test.go | 167 ++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/state/aws/dynamodb/dynamodb_test.go b/state/aws/dynamodb/dynamodb_test.go index abd2448004..ecd1c5aa39 100644 --- a/state/aws/dynamodb/dynamodb_test.go +++ b/state/aws/dynamodb/dynamodb_test.go @@ -1161,3 +1161,170 @@ func TestMultiTx(t *testing.T) { require.NoError(t, err) }) } + +func TestParseTTLWithDefault(t *testing.T) { + t.Run("Use explicit TTL from request metadata", func(t *testing.T) { + defaultTTL := 600 + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{ + "ttlInSeconds": "300", + }, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + require.NotNil(t, ttl) + + // Should use explicit value (300), not default (600) + expectedTime := time.Now().Unix() + 300 + assert.InDelta(t, expectedTime, *ttl, 2) // Allow 2 second tolerance + }) + + t.Run("Use default TTL when no explicit TTL in request", func(t *testing.T) { + defaultTTL := 600 + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{}, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + require.NotNil(t, ttl) + + // Should use default value (600) + expectedTime := time.Now().Unix() + 600 + assert.InDelta(t, expectedTime, *ttl, 2) // Allow 2 second tolerance + }) + + t.Run("No TTL when no default and no explicit TTL", func(t *testing.T) { + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: nil, // No default configured + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{}, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + assert.Nil(t, ttl) + }) + + t.Run("No TTL when ttlAttributeName is not set", func(t *testing.T) { + defaultTTL := 600 + s := StateStore{ + ttlAttributeName: "", // TTL not enabled in component + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{ + "ttlInSeconds": "300", + }, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + assert.Nil(t, ttl) // Should return nil when TTL not enabled + }) + + t.Run("Explicit TTL with value -1", func(t *testing.T) { + defaultTTL := 600 + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{ + "ttlInSeconds": "-1", + }, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + require.NotNil(t, ttl) + + // -1 should result in immediate expiration (now + -1) + expectedTime := time.Now().Unix() - 1 + assert.InDelta(t, expectedTime, *ttl, 2) + }) + + t.Run("Default TTL with large value", func(t *testing.T) { + defaultTTL := 86400 // 24 hours + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{}, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + require.NotNil(t, ttl) + + expectedTime := time.Now().Unix() + 86400 + assert.InDelta(t, expectedTime, *ttl, 2) + }) + + t.Run("Error on invalid TTL value", func(t *testing.T) { + defaultTTL := 600 + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{ + "ttlInSeconds": "invalid", + }, + } + + ttl, err := s.parseTTL(req) + require.Error(t, err) + assert.Nil(t, ttl) + assert.Contains(t, err.Error(), "invalid syntax") + }) + + t.Run("Explicit TTL overrides default in request with empty metadata", func(t *testing.T) { + defaultTTL := 1200 + s := StateStore{ + ttlAttributeName: "expiresAt", + ttlInSeconds: &defaultTTL, + } + + req := &state.SetRequest{ + Key: "test-key", + Metadata: map[string]string{ + "ttlInSeconds": "0", + }, + } + + ttl, err := s.parseTTL(req) + require.NoError(t, err) + require.NotNil(t, ttl) + + // Should use explicit value 0, not default + expectedTime := time.Now().Unix() + assert.InDelta(t, expectedTime, *ttl, 2) + }) +} \ No newline at end of file