Skip to content

Commit 84809d2

Browse files
committed
feat(streaming): add DynamoDB Kinesis checkpoints
Add optimistic DynamoDB checkpoint persistence and flow cancellation through persistent stream initialization, reads, and checkpoint operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5060d4-b229-476c-bdf3-9923ad459bb2
1 parent dac40cb commit 84809d2

16 files changed

Lines changed: 1661 additions & 34 deletions

src/AWS/Orleans.Streaming.Kinesis/Hosting/SiloKinesisStreamConfigurator.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ public SiloKinesisStreamConfigurator ConfigureCheckpointer<TOptions>(
4242
this.ConfigureComponent(checkpointerFactoryBuilder, configureOptions);
4343
return this;
4444
}
45+
46+
/// <summary>
47+
/// Configures the stream provider to persist checkpoints in DynamoDB.
48+
/// </summary>
49+
public SiloKinesisStreamConfigurator UseDynamoDBCheckpointer(
50+
Action<DynamoDBStreamQueueCheckpointerOptions> configureOptions)
51+
=> UseDynamoDBCheckpointer(options => options.Configure(configureOptions));
52+
53+
/// <summary>
54+
/// Configures the stream provider to persist checkpoints in DynamoDB.
55+
/// </summary>
56+
public SiloKinesisStreamConfigurator UseDynamoDBCheckpointer(
57+
Action<OptionsBuilder<DynamoDBStreamQueueCheckpointerOptions>>? configureOptions = null)
58+
{
59+
ConfigureCheckpointer<DynamoDBStreamQueueCheckpointerOptions>(
60+
DynamoDBStreamQueueCheckpointerFactory.CreateFactory,
61+
options => configureOptions?.Invoke(options));
62+
this.ConfigureDelegate(services => services.AddTransient<IConfigurationValidator>(
63+
sp => new DynamoDBStreamQueueCheckpointerOptionsValidator(
64+
sp.GetOptionsByName<DynamoDBStreamQueueCheckpointerOptions>(Name),
65+
Name)));
66+
return this;
67+
}
4568
}
4669

4770
internal sealed class KinesisStreamCheckpointerConfigurationValidator(

src/AWS/Orleans.Streaming.Kinesis/Orleans.Streaming.Kinesis.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<ItemGroup>
1818
<ProjectReference Include="$(SourceRoot)src\Orleans.Streaming\Orleans.Streaming.csproj" />
19+
<PackageReference Include="AWSSDK.DynamoDBv2" />
1920
<PackageReference Include="AWSSDK.Kinesis" />
2021
</ItemGroup>
2122

src/AWS/Orleans.Streaming.Kinesis/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,26 @@ siloBuilder.AddKinesisStreams("Kinesis", configurator =>
7878
});
7979
```
8080

81+
To persist checkpoints directly in DynamoDB without configuring grain storage, select the DynamoDB table checkpointer:
82+
83+
```csharp
84+
siloBuilder.AddKinesisStreams("Kinesis", configurator =>
85+
{
86+
configurator.ConfigureKinesis(options => options.Configure(kinesis =>
87+
{
88+
kinesis.StreamName = "orders";
89+
kinesis.Region = "us-east-1";
90+
}));
91+
configurator.UseDynamoDBCheckpointer(options =>
92+
{
93+
options.Service = "us-east-1";
94+
options.TableName = "OrleansStreamCheckpoints";
95+
});
96+
});
97+
```
98+
99+
The DynamoDB checkpointer uses on-demand billing and creates its table by default. It stores one versioned row per service, provider, and shard. Conditional writes prevent a stale silo owner from moving a checkpoint backward. Set `CreateIfNotExists` to `false` when tables are provisioned separately.
100+
81101
To provide a different checkpoint implementation, use the configurator overload and call
82102
`ConfigureCheckpointer<TOptions>` with an `IStreamQueueCheckpointerFactory`.
83103

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.Linq;
5+
using System.Text;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Amazon.DynamoDBv2;
9+
using Amazon.DynamoDBv2.Model;
10+
using Microsoft.Extensions.Logging;
11+
using Orleans.Configuration;
12+
using Orleans.Streams;
13+
14+
namespace Orleans.Streaming.Kinesis
15+
{
16+
internal interface IDynamoDBStreamCheckpointStore
17+
{
18+
ValueTask<string> Load(CancellationToken cancellationToken);
19+
20+
ValueTask<string> Update(
21+
string checkpoint,
22+
string expectedCheckpoint,
23+
CancellationToken cancellationToken);
24+
}
25+
26+
internal sealed partial class DynamoDBStreamCheckpointStore : IDynamoDBStreamCheckpointStore
27+
{
28+
internal const string NamespaceAttribute = "CheckpointNamespace";
29+
internal const string PartitionAttribute = "Partition";
30+
internal const string CheckpointAttribute = "Checkpoint";
31+
internal const string VersionAttribute = "Version";
32+
33+
private static readonly TimeSpan TableStatusPollInterval = TimeSpan.FromSeconds(1);
34+
35+
private readonly IAmazonDynamoDB _client;
36+
private readonly string _tableName;
37+
private readonly Dictionary<string, AttributeValue> _key;
38+
private readonly SemaphoreSlim _mutex = new(1, 1);
39+
40+
private string _checkpoint = string.Empty;
41+
private long _version;
42+
private bool _loaded;
43+
44+
public DynamoDBStreamCheckpointStore(
45+
IAmazonDynamoDB client,
46+
string tableName,
47+
string serviceId,
48+
string providerName,
49+
string partition)
50+
{
51+
_client = client;
52+
_tableName = tableName;
53+
_key = new Dictionary<string, AttributeValue>
54+
{
55+
[NamespaceAttribute] = new(FormatNamespace(serviceId, providerName)),
56+
[PartitionAttribute] = new(partition),
57+
};
58+
}
59+
60+
public async ValueTask<string> Load(CancellationToken cancellationToken)
61+
{
62+
await _mutex.WaitAsync(cancellationToken);
63+
try
64+
{
65+
await LoadCore(cancellationToken);
66+
return _checkpoint;
67+
}
68+
finally
69+
{
70+
_mutex.Release();
71+
}
72+
}
73+
74+
public async ValueTask<string> Update(
75+
string checkpoint,
76+
string expectedCheckpoint,
77+
CancellationToken cancellationToken)
78+
{
79+
ArgumentNullException.ThrowIfNull(checkpoint);
80+
ArgumentNullException.ThrowIfNull(expectedCheckpoint);
81+
82+
await _mutex.WaitAsync(cancellationToken);
83+
try
84+
{
85+
if (!_loaded)
86+
{
87+
await LoadCore(cancellationToken);
88+
}
89+
90+
if (!string.Equals(_checkpoint, expectedCheckpoint, StringComparison.Ordinal))
91+
{
92+
return _checkpoint;
93+
}
94+
95+
try
96+
{
97+
var nextVersion = checked(_version + 1);
98+
var item = new Dictionary<string, AttributeValue>(_key)
99+
{
100+
[CheckpointAttribute] = new(checkpoint),
101+
[VersionAttribute] = new() { N = nextVersion.ToString(CultureInfo.InvariantCulture) },
102+
};
103+
var request = new PutItemRequest
104+
{
105+
TableName = _tableName,
106+
Item = item,
107+
ConditionExpression = _version == 0
108+
? "attribute_not_exists(#namespace) AND attribute_not_exists(#partition)"
109+
: "#version = :expectedVersion",
110+
ExpressionAttributeNames = _version == 0
111+
? new Dictionary<string, string>
112+
{
113+
["#namespace"] = NamespaceAttribute,
114+
["#partition"] = PartitionAttribute,
115+
}
116+
: new Dictionary<string, string>
117+
{
118+
["#version"] = VersionAttribute,
119+
},
120+
ExpressionAttributeValues = _version == 0
121+
? null
122+
: new Dictionary<string, AttributeValue>
123+
{
124+
[":expectedVersion"] = new()
125+
{
126+
N = _version.ToString(CultureInfo.InvariantCulture),
127+
},
128+
},
129+
};
130+
131+
_ = await _client.PutItemAsync(request, cancellationToken);
132+
_checkpoint = checkpoint;
133+
_version = nextVersion;
134+
}
135+
catch (ConditionalCheckFailedException)
136+
{
137+
await LoadCore(cancellationToken);
138+
}
139+
140+
return _checkpoint;
141+
}
142+
finally
143+
{
144+
_mutex.Release();
145+
}
146+
}
147+
148+
internal static async Task InitializeTable(
149+
IAmazonDynamoDB client,
150+
DynamoDBStreamQueueCheckpointerOptions options,
151+
ILogger logger,
152+
CancellationToken cancellationToken = default)
153+
{
154+
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
155+
timeout.CancelAfter(options.InitializationTimeout);
156+
try
157+
{
158+
TableDescription? table = null;
159+
try
160+
{
161+
table = (await client.DescribeTableAsync(options.TableName, timeout.Token)).Table;
162+
}
163+
catch (ResourceNotFoundException) when (options.CreateIfNotExists)
164+
{
165+
var request = new CreateTableRequest
166+
{
167+
TableName = options.TableName,
168+
AttributeDefinitions =
169+
[
170+
new(NamespaceAttribute, ScalarAttributeType.S),
171+
new(PartitionAttribute, ScalarAttributeType.S),
172+
],
173+
KeySchema =
174+
[
175+
new(NamespaceAttribute, KeyType.HASH),
176+
new(PartitionAttribute, KeyType.RANGE),
177+
],
178+
BillingMode = options.UseProvisionedThroughput
179+
? BillingMode.PROVISIONED
180+
: BillingMode.PAY_PER_REQUEST,
181+
ProvisionedThroughput = options.UseProvisionedThroughput
182+
? new ProvisionedThroughput(options.ReadCapacityUnits, options.WriteCapacityUnits)
183+
: null,
184+
};
185+
186+
try
187+
{
188+
table = (await client.CreateTableAsync(request, timeout.Token)).TableDescription;
189+
}
190+
catch (ResourceInUseException)
191+
{
192+
table = null;
193+
}
194+
}
195+
catch (ResourceNotFoundException)
196+
{
197+
throw new OrleansConfigurationException(
198+
$"The DynamoDB checkpoint table '{options.TableName}' does not exist and " +
199+
$"{nameof(DynamoDBStreamQueueCheckpointerOptions.CreateIfNotExists)} is disabled.");
200+
}
201+
202+
while (table is null || table.TableStatus != TableStatus.ACTIVE)
203+
{
204+
LogWaitingForTable(logger, options.TableName, table?.TableStatus);
205+
await Task.Delay(TableStatusPollInterval, timeout.Token);
206+
try
207+
{
208+
table = (await client.DescribeTableAsync(options.TableName, timeout.Token)).Table;
209+
}
210+
catch (ResourceNotFoundException) when (options.CreateIfNotExists)
211+
{
212+
table = null;
213+
}
214+
}
215+
216+
ValidateTableSchema(table, options.TableName);
217+
}
218+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
219+
{
220+
cancellationToken.ThrowIfCancellationRequested();
221+
throw;
222+
}
223+
catch (OperationCanceledException exception)
224+
when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
225+
{
226+
throw new OrleansConfigurationException(
227+
$"The DynamoDB checkpoint table '{options.TableName}' did not become active within " +
228+
$"{options.InitializationTimeout}.",
229+
exception);
230+
}
231+
}
232+
233+
internal static string FormatNamespace(string serviceId, string providerName)
234+
{
235+
static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
236+
return $"{Encode(serviceId)}:{Encode(providerName)}";
237+
}
238+
239+
private async Task LoadCore(CancellationToken cancellationToken)
240+
{
241+
var response = await _client.GetItemAsync(
242+
new GetItemRequest
243+
{
244+
TableName = _tableName,
245+
Key = _key,
246+
ConsistentRead = true,
247+
},
248+
cancellationToken);
249+
250+
if (response.Item is not { Count: > 0 } item)
251+
{
252+
_checkpoint = string.Empty;
253+
_version = 0;
254+
_loaded = true;
255+
return;
256+
}
257+
258+
if (!item.TryGetValue(CheckpointAttribute, out var checkpoint)
259+
|| string.IsNullOrEmpty(checkpoint.S)
260+
|| !item.TryGetValue(VersionAttribute, out var version)
261+
|| !long.TryParse(version.N, NumberStyles.None, CultureInfo.InvariantCulture, out _version)
262+
|| _version <= 0)
263+
{
264+
throw new InvalidOperationException(
265+
$"The checkpoint row in DynamoDB table '{_tableName}' has an invalid format.");
266+
}
267+
268+
_checkpoint = checkpoint.S;
269+
_loaded = true;
270+
}
271+
272+
private static void ValidateTableSchema(TableDescription table, string tableName)
273+
{
274+
var hasExpectedKeys = table.KeySchema?.Count == 2
275+
&& table.KeySchema.Any(
276+
key => key.AttributeName == NamespaceAttribute && key.KeyType == KeyType.HASH)
277+
&& table.KeySchema.Any(
278+
key => key.AttributeName == PartitionAttribute && key.KeyType == KeyType.RANGE);
279+
var hasExpectedAttributes = table.AttributeDefinitions?.Any(
280+
attribute => attribute.AttributeName == NamespaceAttribute
281+
&& attribute.AttributeType == ScalarAttributeType.S) == true
282+
&& table.AttributeDefinitions.Any(
283+
attribute => attribute.AttributeName == PartitionAttribute
284+
&& attribute.AttributeType == ScalarAttributeType.S);
285+
286+
if (!hasExpectedKeys || !hasExpectedAttributes)
287+
{
288+
throw new OrleansConfigurationException(
289+
$"The DynamoDB checkpoint table '{tableName}' does not have the expected key schema.");
290+
}
291+
}
292+
293+
[LoggerMessage(
294+
Level = LogLevel.Debug,
295+
Message = "Waiting for DynamoDB checkpoint table {TableName} to become active. Current status: {TableStatus}.")]
296+
private static partial void LogWaitingForTable(ILogger logger, string tableName, TableStatus? tableStatus);
297+
}
298+
}

0 commit comments

Comments
 (0)