| | | 1 | | using AsyncResponse; |
| | | 2 | | using AsyncResponse.DurableFlows.Internal; |
| | | 3 | | using AsyncResponse.DurableFlows.Sqlite; |
| | | 4 | | using Microsoft.Data.Sqlite; |
| | | 5 | | using Microsoft.Extensions.DependencyInjection.Extensions; |
| | | 6 | | using Microsoft.Extensions.Options; |
| | | 7 | | |
| | | 8 | | namespace Microsoft.Extensions.DependencyInjection |
| | | 9 | | { |
| | | 10 | | /// <summary>DI registration for the SQLite durable-flow state store.</summary> |
| | | 11 | | public static class SqliteDurableFlowServiceCollectionExtensions |
| | | 12 | | { |
| | | 13 | | /// <summary>Stores durable-flow state in SQLite.</summary> |
| | | 14 | | public static AsyncResponseRegistrationBuilder WithSqliteDurableFlows( |
| | | 15 | | this AsyncResponseRegistrationBuilder builder, |
| | | 16 | | Action<SqliteDurableFlowOptions>? configure = null) |
| | | 17 | | { |
| | | 18 | | // Singleton on purpose: schema provisioning is cached per store instance, and the |
| | | 19 | | // executor resolves the store from a fresh scope per flow execution — a scoped store |
| | | 20 | | // would re-run EnsureCreated's DDL round-trip on every run. |
| | | 21 | | builder.Services.TryAddSingleton<SqliteFlowStateStore>(); |
| | | 22 | | return builder.WithDurableFlows<SqliteFlowStateStore, SqliteDurableFlowOptions>(configure); |
| | | 23 | | } |
| | | 24 | | } |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | namespace AsyncResponse.DurableFlows.Sqlite |
| | | 28 | | { |
| | | 29 | | /// <summary>Options for the SQLite durable-flow state store.</summary> |
| | | 30 | | public sealed class SqliteDurableFlowOptions : DurableFlowOptions |
| | | 31 | | { |
| | | 32 | | /// <summary>SQLite connection string. Default: <c>Data Source=asyncresponse-flow-state.db</c>.</summary> |
| | | 33 | | public string ConnectionString { get; set; } = "Data Source=asyncresponse-flow-state.db"; |
| | | 34 | | |
| | | 35 | | /// <summary>Table storing one durable-flow ledger row per flow id.</summary> |
| | | 36 | | public string TableName { get; set; } = "asyncresponse_flow_state"; |
| | | 37 | | |
| | | 38 | | /// <summary>Creates the table and expiry index on first use.</summary> |
| | | 39 | | public bool AutoCreateSchema { get; set; } = true; |
| | | 40 | | |
| | | 41 | | /// <summary> |
| | | 42 | | /// How often <see cref="SqliteFlowStateStore.TryCreateAsync"/> opportunistically deletes one bounded |
| | | 43 | | /// batch (1000 rows) of expired rows (loads already treat expired state as absent; pruning |
| | | 44 | | /// bounds table growth). Zero or negative prunes on every save. Default: 5 minutes. |
| | | 45 | | /// </summary> |
| | | 46 | | public TimeSpan PruneInterval { get; set; } = TimeSpan.FromMinutes(5); |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// Maximum serialized flow-state size in bytes accepted by writes; oversized ledgers fail fast |
| | | 50 | | /// with an actionable error instead of an opaque provider error. Default: <c>null</c> |
| | | 51 | | /// (unlimited — SQLite <c>TEXT</c> holds up to ~1 GB), settable as an operator budget. |
| | | 52 | | /// </summary> |
| | | 53 | | public long? MaxStateBytes { get; set; } |
| | | 54 | | |
| | | 55 | | /// <summary>Validates option values and throws on misconfiguration.</summary> |
| | | 56 | | public void Validate() |
| | | 57 | | { |
| | | 58 | | if (string.IsNullOrWhiteSpace(ConnectionString)) |
| | | 59 | | throw new InvalidOperationException($"{nameof(SqliteDurableFlowOptions)}.{nameof(ConnectionString)} must be |
| | | 60 | | |
| | | 61 | | DurableFlowStoreShared.ValidateIdentifier(TableName, $"{nameof(SqliteDurableFlowOptions)}.{nameof(TableName)}", |
| | | 62 | | if (MaxStateBytes is <= 0) |
| | | 63 | | throw new InvalidOperationException($"{nameof(SqliteDurableFlowOptions)}.{nameof(MaxStateBytes)} must be pos |
| | | 64 | | } |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | /// <summary>SQLite implementation of <see cref="IFlowStateStore"/>.</summary> |
| | | 68 | | public sealed class SqliteFlowStateStore : IFlowStateStore |
| | | 69 | | { |
| | | 70 | | private const int PruneBatchSize = 1000; |
| | | 71 | | |
| | | 72 | | // Time authority: this store deliberately keeps the app clock (DateTime.UtcNow) for expiry |
| | | 73 | | // and lease comparisons. A SQLite database file lives on a single machine, and every writer |
| | | 74 | | // is a process on that machine sharing the same clock — the multi-node clock-skew hazard the |
| | | 75 | | // server-clock stores guard against cannot occur, and SQLite has no server clock to ask. |
| | | 76 | | private readonly SqliteDurableFlowOptions _options; |
| | 3 | 77 | | private readonly SemaphoreSlim _ensureGate = new(1, 1); |
| | | 78 | | |
| | | 79 | | // SQLite allows exactly one writer at a time, and its cross-connection busy handler is a |
| | | 80 | | // poll loop, not a queue: under heavy concurrency on a slow machine an unlucky writer can |
| | | 81 | | // lose every poll until the busy timeout expires ('database is locked' storms on 2-core CI |
| | | 82 | | // runners). Serializing this process's writers through a real FIFO gate costs no throughput |
| | | 83 | | // (they would serialize inside SQLite anyway) and makes in-process contention |
| | | 84 | | // starvation-free; the busy timeout then only covers cross-process writers. Reads stay |
| | | 85 | | // concurrent (WAL). |
| | 3 | 86 | | private readonly SemaphoreSlim _writeGate = new(1, 1); |
| | | 87 | | private long _lastPruneTicks; |
| | | 88 | | private bool _created; |
| | | 89 | | |
| | 3 | 90 | | public SqliteFlowStateStore(IOptions<SqliteDurableFlowOptions> options) |
| | | 91 | | { |
| | 3 | 92 | | _options = options.Value; |
| | 3 | 93 | | _options.Validate(); |
| | 3 | 94 | | } |
| | | 95 | | |
| | | 96 | | public async Task<FlowState?> LoadAsync(string flowId, CancellationToken cancellationToken = default) |
| | | 97 | | { |
| | 3 | 98 | | ArgumentException.ThrowIfNullOrWhiteSpace(flowId); |
| | 3 | 99 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | | 100 | | |
| | 3 | 101 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 102 | | await using var command = connection.CreateCommand(); |
| | 3 | 103 | | command.CommandText = |
| | 3 | 104 | | $""" |
| | 3 | 105 | | SELECT state_json, revision |
| | 3 | 106 | | FROM {Table} |
| | 3 | 107 | | WHERE flow_id = $flow_id AND expires_at_utc > $now_utc; |
| | 3 | 108 | | """; |
| | 3 | 109 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 110 | | command.Parameters.AddWithValue("$now_utc", DateTime.UtcNow); |
| | | 111 | | |
| | 3 | 112 | | await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 113 | | if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | 3 | 114 | | return null; |
| | | 115 | | |
| | 3 | 116 | | return DurableFlowStoreShared.ReadState(flowId, reader.GetString(0), reader.GetInt64(1)); |
| | 2 | 117 | | } |
| | | 118 | | |
| | | 119 | | public async Task<bool> TryCreateAsync( |
| | | 120 | | string flowId, |
| | | 121 | | FlowState state, |
| | | 122 | | TimeSpan ttl, |
| | | 123 | | CancellationToken cancellationToken = default) |
| | | 124 | | { |
| | 3 | 125 | | DurableFlowStoreShared.ValidateCreate(flowId, state, ttl); |
| | 3 | 126 | | var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "SQLite"); |
| | 3 | 127 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 128 | | if (DurableFlowStoreShared.ShouldPrune(ref _lastPruneTicks, _options.PruneInterval)) |
| | 3 | 129 | | await PruneExpiredAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 130 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 131 | | await using var command = connection.CreateCommand(); |
| | 3 | 132 | | command.CommandText = |
| | 3 | 133 | | $""" |
| | 3 | 134 | | INSERT INTO {Table} (flow_id, state_json, expires_at_utc, updated_at_utc, revision) |
| | 3 | 135 | | VALUES ($flow_id, $state_json, $expires_at_utc, $now_utc, $revision) |
| | 3 | 136 | | ON CONFLICT(flow_id) DO UPDATE SET |
| | 3 | 137 | | state_json = excluded.state_json, |
| | 3 | 138 | | expires_at_utc = excluded.expires_at_utc, |
| | 3 | 139 | | updated_at_utc = excluded.updated_at_utc, |
| | 3 | 140 | | revision = excluded.revision, |
| | 3 | 141 | | lease_id = NULL, |
| | 3 | 142 | | lease_expires_at_utc = NULL |
| | 3 | 143 | | WHERE {Table}.expires_at_utc <= $now_utc; |
| | 3 | 144 | | """; |
| | 3 | 145 | | var now = DateTime.UtcNow; |
| | 3 | 146 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 147 | | command.Parameters.AddWithValue("$state_json", stateJson); |
| | 3 | 148 | | command.Parameters.AddWithValue("$expires_at_utc", DurableFlowStoreShared.AddSaturating(now, ttl)); |
| | 3 | 149 | | command.Parameters.AddWithValue("$now_utc", now); |
| | 3 | 150 | | command.Parameters.AddWithValue("$revision", state.Revision); |
| | 3 | 151 | | return await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false) > 0; |
| | 2 | 152 | | } |
| | | 153 | | |
| | | 154 | | public async Task<bool> TryUpdateAsync( |
| | | 155 | | string flowId, |
| | | 156 | | FlowState state, |
| | | 157 | | long expectedRevision, |
| | | 158 | | TimeSpan ttl, |
| | | 159 | | string? leaseId = null, |
| | | 160 | | CancellationToken cancellationToken = default) |
| | | 161 | | { |
| | 3 | 162 | | DurableFlowStoreShared.ValidateUpdate(flowId, state, expectedRevision, ttl); |
| | 3 | 163 | | var stateJson = DurableFlowStoreShared.SerializeBounded(flowId, state, _options.MaxStateBytes, "SQLite"); |
| | 3 | 164 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 165 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 166 | | await using var command = connection.CreateCommand(); |
| | 3 | 167 | | var now = DateTime.UtcNow; |
| | 3 | 168 | | command.CommandText = |
| | 3 | 169 | | $""" |
| | 3 | 170 | | UPDATE {Table} |
| | 3 | 171 | | SET state_json = $state_json, |
| | 3 | 172 | | expires_at_utc = $expires_at_utc, |
| | 3 | 173 | | updated_at_utc = $updated_at_utc, |
| | 3 | 174 | | revision = $new_revision |
| | 3 | 175 | | WHERE flow_id = $flow_id |
| | 3 | 176 | | AND revision = $expected_revision |
| | 3 | 177 | | AND expires_at_utc > $now_utc |
| | 3 | 178 | | AND ($lease_id IS NULL OR (lease_id = $lease_id AND lease_expires_at_utc > $now_utc)); |
| | 3 | 179 | | """; |
| | 3 | 180 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 181 | | command.Parameters.AddWithValue("$state_json", stateJson); |
| | 3 | 182 | | command.Parameters.AddWithValue("$expires_at_utc", DurableFlowStoreShared.AddSaturating(now, ttl)); |
| | 3 | 183 | | command.Parameters.AddWithValue("$updated_at_utc", now); |
| | 3 | 184 | | command.Parameters.AddWithValue("$new_revision", state.Revision); |
| | 3 | 185 | | command.Parameters.AddWithValue("$expected_revision", expectedRevision); |
| | 3 | 186 | | command.Parameters.AddWithValue("$now_utc", now); |
| | 3 | 187 | | command.Parameters.AddWithValue("$lease_id", (object?)leaseId ?? DBNull.Value); |
| | 3 | 188 | | return await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false) > 0; |
| | 2 | 189 | | } |
| | | 190 | | |
| | | 191 | | public Task<bool> TryAcquireLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken canc |
| | 3 | 192 | | => UpdateLeaseAsync(flowId, leaseId, leaseDuration, renew: false, cancellationToken); |
| | | 193 | | |
| | | 194 | | public Task<bool> TryRenewLeaseAsync(string flowId, string leaseId, TimeSpan leaseDuration, CancellationToken cancel |
| | 3 | 195 | | => UpdateLeaseAsync(flowId, leaseId, leaseDuration, renew: true, cancellationToken); |
| | | 196 | | |
| | | 197 | | public async Task ReleaseLeaseAsync(string flowId, string leaseId, CancellationToken cancellationToken = default) |
| | | 198 | | { |
| | 3 | 199 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 200 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 201 | | await using var command = connection.CreateCommand(); |
| | 3 | 202 | | command.CommandText = $"UPDATE {Table} SET lease_id = NULL, lease_expires_at_utc = NULL WHERE flow_id = $flow_id |
| | 3 | 203 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 204 | | command.Parameters.AddWithValue("$lease_id", leaseId); |
| | 3 | 205 | | await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false); |
| | 2 | 206 | | } |
| | | 207 | | |
| | | 208 | | public async Task<bool> TryDeleteAsync(string flowId, CancellationToken cancellationToken = default) |
| | | 209 | | { |
| | 3 | 210 | | ArgumentException.ThrowIfNullOrWhiteSpace(flowId); |
| | 3 | 211 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | | 212 | | |
| | 3 | 213 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 214 | | await using var command = connection.CreateCommand(); |
| | 3 | 215 | | command.CommandText = $"DELETE FROM {Table} WHERE flow_id = $flow_id;"; |
| | 3 | 216 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 217 | | return await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false) > 0; |
| | 2 | 218 | | } |
| | | 219 | | |
| | | 220 | | private async Task PruneExpiredAsync(CancellationToken cancellationToken) |
| | | 221 | | { |
| | | 222 | | // Timestamps are stored as ISO-8601 TEXT, which compares correctly lexicographically. |
| | | 223 | | // One bounded batch per prune interval (policy shared by all relational stores): an |
| | | 224 | | // unbatched DELETE over a large expired backlog holds the single SQLite write lock for |
| | | 225 | | // the whole sweep. Loads already filter on expiry, so any backlog beyond the batch just |
| | | 226 | | // waits for the next interval. Id-subquery form because DELETE ... LIMIT needs a |
| | | 227 | | // non-default SQLite compile flag. |
| | 3 | 228 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 229 | | await using var command = connection.CreateCommand(); |
| | 3 | 230 | | command.CommandText = |
| | 3 | 231 | | $""" |
| | 3 | 232 | | DELETE FROM {Table} |
| | 3 | 233 | | WHERE flow_id IN (SELECT flow_id FROM {Table} WHERE expires_at_utc <= $now_utc LIMIT {PruneBatchSize}); |
| | 3 | 234 | | """; |
| | 3 | 235 | | command.Parameters.AddWithValue("$now_utc", DateTime.UtcNow); |
| | 3 | 236 | | await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false); |
| | 2 | 237 | | } |
| | | 238 | | |
| | | 239 | | private async Task EnsureCreatedAsync(CancellationToken cancellationToken) |
| | | 240 | | { |
| | 3 | 241 | | if (_created || !_options.AutoCreateSchema) |
| | 3 | 242 | | return; |
| | | 243 | | |
| | 3 | 244 | | await _ensureGate.WaitAsync(cancellationToken).ConfigureAwait(false); |
| | | 245 | | try |
| | | 246 | | { |
| | 3 | 247 | | if (_created) |
| | 3 | 248 | | return; |
| | | 249 | | |
| | 3 | 250 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 251 | | await using var command = connection.CreateCommand(); |
| | 3 | 252 | | command.CommandText = |
| | 3 | 253 | | $""" |
| | 3 | 254 | | -- WAL is the right journal mode for this store's use case (concurrent flow |
| | 3 | 255 | | -- executors on one node): readers never block behind a writer, which rollback |
| | 3 | 256 | | -- journal mode does not guarantee — concurrent load/save storms on slow disks |
| | 3 | 257 | | -- surface as SQLITE_BUSY 'database is locked' there. The mode is persistent in |
| | 3 | 258 | | -- the database file, so setting it alongside the schema costs nothing per |
| | 3 | 259 | | -- operation. Manually-provisioned databases (AutoCreateSchema=false) should set |
| | 3 | 260 | | -- it themselves — see docs/durable-flow-state-stores.md. |
| | 3 | 261 | | PRAGMA journal_mode=WAL; |
| | 3 | 262 | | CREATE TABLE IF NOT EXISTS {Table} ( |
| | 3 | 263 | | flow_id TEXT NOT NULL PRIMARY KEY, |
| | 3 | 264 | | state_json TEXT NOT NULL, |
| | 3 | 265 | | expires_at_utc TEXT NOT NULL, |
| | 3 | 266 | | updated_at_utc TEXT NOT NULL, |
| | 3 | 267 | | revision INTEGER NOT NULL DEFAULT 0, |
| | 3 | 268 | | lease_id TEXT NULL, |
| | 3 | 269 | | lease_expires_at_utc TEXT NULL |
| | 3 | 270 | | ); |
| | 3 | 271 | | CREATE INDEX IF NOT EXISTS {IndexName} ON {Table} (expires_at_utc); |
| | 3 | 272 | | """; |
| | 3 | 273 | | await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false); |
| | 3 | 274 | | _created = true; |
| | 2 | 275 | | } |
| | | 276 | | finally |
| | | 277 | | { |
| | 3 | 278 | | _ensureGate.Release(); |
| | | 279 | | } |
| | 2 | 280 | | } |
| | | 281 | | |
| | | 282 | | private async Task<bool> UpdateLeaseAsync( |
| | | 283 | | string flowId, |
| | | 284 | | string leaseId, |
| | | 285 | | TimeSpan leaseDuration, |
| | | 286 | | bool renew, |
| | | 287 | | CancellationToken cancellationToken) |
| | | 288 | | { |
| | 3 | 289 | | ArgumentException.ThrowIfNullOrWhiteSpace(flowId); |
| | 3 | 290 | | ArgumentException.ThrowIfNullOrWhiteSpace(leaseId); |
| | 3 | 291 | | if (leaseDuration <= TimeSpan.Zero) |
| | 3 | 292 | | throw new ArgumentOutOfRangeException(nameof(leaseDuration)); |
| | | 293 | | |
| | 3 | 294 | | await EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 295 | | await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 296 | | await using var command = connection.CreateCommand(); |
| | 3 | 297 | | var now = DateTime.UtcNow; |
| | 3 | 298 | | command.CommandText = |
| | 3 | 299 | | $""" |
| | 3 | 300 | | UPDATE {Table} |
| | 3 | 301 | | SET lease_id = $lease_id, lease_expires_at_utc = $lease_expires_at_utc |
| | 3 | 302 | | WHERE flow_id = $flow_id |
| | 3 | 303 | | AND expires_at_utc > $now_utc |
| | 3 | 304 | | AND {(renew ? "lease_id = $lease_id AND lease_expires_at_utc > $now_utc" : "(lease_id IS NULL OR lease_exp |
| | 3 | 305 | | """; |
| | 3 | 306 | | command.Parameters.AddWithValue("$flow_id", flowId); |
| | 3 | 307 | | command.Parameters.AddWithValue("$lease_id", leaseId); |
| | 3 | 308 | | command.Parameters.AddWithValue("$lease_expires_at_utc", DurableFlowStoreShared.AddSaturating(now, leaseDuration |
| | 3 | 309 | | command.Parameters.AddWithValue("$now_utc", now); |
| | 3 | 310 | | return await ExecuteWriteAsync(command, cancellationToken).ConfigureAwait(false) > 0; |
| | 2 | 311 | | } |
| | | 312 | | |
| | | 313 | | private async Task<int> ExecuteWriteAsync(SqliteCommand command, CancellationToken cancellationToken) |
| | | 314 | | { |
| | 3 | 315 | | await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); |
| | | 316 | | try |
| | | 317 | | { |
| | 3 | 318 | | return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); |
| | | 319 | | } |
| | | 320 | | finally |
| | | 321 | | { |
| | 3 | 322 | | _writeGate.Release(); |
| | | 323 | | } |
| | 2 | 324 | | } |
| | | 325 | | |
| | | 326 | | private async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken) |
| | | 327 | | { |
| | 3 | 328 | | var connection = new SqliteConnection(_options.ConnectionString); |
| | | 329 | | try |
| | | 330 | | { |
| | 3 | 331 | | await connection.OpenAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 332 | | return connection; |
| | | 333 | | } |
| | 3 | 334 | | catch |
| | | 335 | | { |
| | 2 | 336 | | await connection.DisposeAsync().ConfigureAwait(false); |
| | 2 | 337 | | throw; |
| | | 338 | | } |
| | 2 | 339 | | } |
| | | 340 | | |
| | 3 | 341 | | private string Table => Quote(_options.TableName); |
| | 3 | 342 | | private string IndexName => Quote($"{_options.TableName}_expires_idx"); |
| | 3 | 343 | | private static string Quote(string identifier) => "\"" + identifier.Replace("\"", "\"\"", StringComparison.Ordinal) |
| | | 344 | | } |
| | | 345 | | } |