|
| 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