diff --git a/decisions/2026-09-29-exact-idempotency-key-storage.md b/decisions/2026-09-29-exact-idempotency-key-storage.md new file mode 100644 index 00000000..e1ff2542 --- /dev/null +++ b/decisions/2026-09-29-exact-idempotency-key-storage.md @@ -0,0 +1,60 @@ +--- +authors: + - Martin Stühmer + +applyTo: + - "src/NetEvolve.Pulse.Extensibility/Idempotency/*.cs" + - "src/NetEvolve.Pulse/Idempotency/*.cs" + - "src/**/Idempotency/*.cs" + - "src/**/Scripts/IdempotencyKey.sql" + - "src/NetEvolve.Pulse.EntityFramework/Configurations/*IdempotencyKeyConfiguration*.cs" + +created: 2026-09-29 + +lastModified: 2026-09-29 + +state: proposed + +instructions: | + IdempotencyKeySchema.MaxLengths.IdempotencyKey is 450 characters, so NVARCHAR(450) (900 bytes) fits the SQL Server clustered index key limit. + IdempotencyStore and the SQL Server, MySQL and Entity Framework repositories MUST reject a key longer than MaxLengths.IdempotencyKey with an ArgumentException before any database call. A key MUST never be truncated. + Key columns MUST use a binary collation: Latin1_General_100_BIN2 on SQL Server and utf8mb4_bin on MySQL, in the provider scripts and in the EF Core configurations. Keys are case-sensitive and accent-sensitive on every provider. + Provider code MUST NOT use statements that turn data errors into warnings (MySQL INSERT IGNORE). Duplicate keys are detected by catching the provider's duplicate-key error. +--- + +# Decision: Store and Compare Idempotency Keys Exactly + +Idempotency keys are stored and compared exactly as the client sent them. The maximum length is 450 characters, keys that are too long are rejected instead of truncated, and the key columns use binary collations. + +## Context + +* `IdempotencyKeySchema.MaxLengths.IdempotencyKey` was 500. On SQL Server the key column was `NVARCHAR(500)`, and it is also the clustered primary key. [CREATE INDEX, Index key size](https://learn.microsoft.com/sql/t-sql/statements/create-index-transact-sql#index-key-size) states: "The maximum size for an index key is 900 bytes for a clustered index and 1,700 bytes for a nonclustered index." `NVARCHAR` needs 2 bytes per character, so keys of 451 to 500 characters failed with Msg 1946 (#850). +* SqlClient silently cuts a parameter value to its `Size`. MySQL `INSERT IGNORE` turns `ER_DATA_TOO_LONG` into a warning and stores a prefix ([MySQL 8.0, INSERT](https://dev.mysql.com/doc/refman/8.0/en/insert.html): "With `IGNORE`, invalid values are adjusted to the closest values and inserted"). Two keys that share a long prefix collided, or a stored key was never found again (#858). +* The MySQL column used `utf8mb4_unicode_ci`, and the SQL Server column used the database default collation. Both are usually case-insensitive, so `aBc123` and `ABC123` counted as the same key. Base62 and base64 client keys differ only by case all the time. PostgreSQL, SQLite, Redis and EF InMemory already compare keys exactly. + +## Decision + +* `MaxLengths.IdempotencyKey` is 450 for every provider. The SQL Server column and stored procedure parameters are `NVARCHAR(450)`. +* `IdempotencyStore` (`ExistsAsync`, `StoreAsync`, `TryReserveAsync`) and the SQL Server, MySQL and Entity Framework repositories reject a longer key with `ArgumentOutOfRangeException` (an `ArgumentException`) before any database call. +* SQL Server uses `Latin1_General_100_BIN2`. [Collation and Unicode support](https://learn.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support) describes BIN2 as "a pure code-point comparison". MySQL uses `utf8mb4_bin`. The [MySQL 8.0 manual](https://dev.mysql.com/doc/refman/8.0/en/charset-binary-collations.html) says that for `_bin` collations "ordering is based on numeric character code values". The EF Core configurations set the same collations with `UseCollation`. +* MySQL stores keys with a plain `INSERT` and treats error 1062 (`ER_DUP_ENTRY`) as a duplicate. `INSERT ... ON DUPLICATE KEY UPDATE` is not used, because MySql.Data reports found rows by default, so a duplicate would also report one affected row and `TryReserveAsync` would always return `true`. +* The provider scripts upgrade existing tables on re-run. SQL Server drops the primary key, alters the column and re-creates the primary key in one transaction. MySQL changes the column collation through the `information_schema` guard pattern of the re-runnable MySQL scripts decision. The MySQL column stays `VARCHAR(500)`, because existing rows may already be longer than 450 characters. The core guard still enforces the 450 limit. + +## Consequences + +* Every key up to 450 characters works on every provider, and a longer key fails fast with a clear exception instead of a `SqlException` or a silent truncation. +* Keys that differ only by case are distinct on every provider. +* Trailing spaces are still ignored on SQL Server and MySQL. SQL Server pads strings before every `=` comparison, whatever the collation ([= (String comparison)](https://learn.microsoft.com/sql/t-sql/language-elements/string-comparison-assignment)), and `utf8mb4_bin` is a `PAD SPACE` collation. `utf8mb4_0900_bin` (`NO PAD`) was not chosen, because it would still differ from SQL Server and MariaDB does not provide it. +* The public constant drops from 500 to 450. Code that compiled against the old value keeps 500 until it is recompiled. +* EF Core users need a new migration for the column type and collation. ADO.NET users re-run the provider script. + +## Alternatives Considered + +* **`PRIMARY KEY NONCLUSTERED` with the 1,700-byte limit**: keeps 500 characters on SQL Server, but needs a separate clustered index or a heap, and the limit would differ between providers. +* **Hash the key into a fixed-length column**: allows any length and exact comparison including trailing spaces, but changes the schema contract of every provider and makes stored keys unreadable for operators. +* **Case-sensitive, accent-sensitive linguistic collations** (for example `Latin1_General_100_CS_AS`): still apply linguistic equivalence rules, so they are not an exact comparison. + +## Related Decisions (Optional) + +* [Refresh Expired Idempotency Keys on Reserve](2026-09-28-idempotency-refresh-expired-keys-on-reserve.md) +* [Re-runnable MySQL Schema Scripts](2026-09-28-rerunnable-mysql-schema-scripts.md) diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlIdempotencyKeyConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlIdempotencyKeyConfiguration.cs index 4ae66f81..74b51c4b 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlIdempotencyKeyConfiguration.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlIdempotencyKeyConfiguration.cs @@ -12,7 +12,7 @@ namespace NetEvolve.Pulse.Configurations; /// /// Column Types: /// -/// varchar(500) for the idempotency key +/// varchar(500) with the binary collation utf8mb4_bin for the idempotency key /// bigint for — stored as UTC ticks via a value converter /// /// Why bigint for DateTimeOffset: @@ -23,6 +23,11 @@ namespace NetEvolve.Pulse.Configurations; /// internal sealed class MySqlIdempotencyKeyConfiguration : IdempotencyKeyConfigurationBase { + /// + /// The column collation annotation of the Oracle provider (MySql.EntityFrameworkCore). + /// + private const string MySqlCollationAnnotation = "MySQL:Collation"; + /// /// Initializes a new instance of the class with default options. /// @@ -39,7 +44,17 @@ public MySqlIdempotencyKeyConfiguration(IOptions options) /// protected override void ApplyColumnTypes(EntityTypeBuilder builder) { - _ = builder.Property(k => k.Key).HasColumnType("varchar(500)"); + // The binary collation compares keys by code point, so keys differing only by case stay distinct. + // The Oracle provider ignores the relational collation set by UseCollation and reads its own + // "MySQL:Collation" annotation instead, so both are set. + // The model keeps MaxLength 500, so migrations created by earlier releases stay in sync; + // the store and the repository enforce IdempotencyKeySchema.MaxLengths.IdempotencyKey. + _ = builder + .Property(k => k.Key) + .HasColumnType("varchar(500)") + .HasMaxLength(500) + .UseCollation("utf8mb4_bin") + .HasAnnotation(MySqlCollationAnnotation, "utf8mb4_bin"); // DateTimeOffset is stored as BIGINT (UTC ticks). // The Oracle MySQL provider lacks a proper DateTimeOffset type mapping for diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/PostgreSqlIdempotencyKeyConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/PostgreSqlIdempotencyKeyConfiguration.cs index b98bd09b..f6bd86bd 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Configurations/PostgreSqlIdempotencyKeyConfiguration.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/PostgreSqlIdempotencyKeyConfiguration.cs @@ -27,7 +27,9 @@ public PostgreSqlIdempotencyKeyConfiguration(IOptions opt /// protected override void ApplyColumnTypes(EntityTypeBuilder builder) { - _ = builder.Property(k => k.Key).HasColumnType("character varying(500)"); + // The model keeps MaxLength 500, so migrations created by earlier releases stay in sync; + // the store and the repository enforce IdempotencyKeySchema.MaxLengths.IdempotencyKey. + _ = builder.Property(k => k.Key).HasColumnType("character varying(500)").HasMaxLength(500); // "timestamp with time zone" (timestamptz) preserves UTC correctly. _ = builder.Property(k => k.CreatedAt).HasColumnType("timestamp with time zone"); } diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqlServerIdempotencyKeyConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqlServerIdempotencyKeyConfiguration.cs index 0a8b350b..709cd590 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqlServerIdempotencyKeyConfiguration.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqlServerIdempotencyKeyConfiguration.cs @@ -3,6 +3,7 @@ namespace NetEvolve.Pulse.Configurations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.Extensions.Options; +using NetEvolve.Pulse.Extensibility.Idempotency; using NetEvolve.Pulse.Idempotency; /// @@ -27,7 +28,12 @@ public SqlServerIdempotencyKeyConfiguration(IOptions opti /// protected override void ApplyColumnTypes(EntityTypeBuilder builder) { - _ = builder.Property(k => k.Key).HasColumnType("nvarchar(500)"); + // 450 NVARCHAR characters take 900 bytes, the SQL Server limit for a clustered index key. + // The binary collation compares keys by code point, so keys differing only by case stay distinct. + _ = builder + .Property(k => k.Key) + .HasColumnType($"nvarchar({IdempotencyKeySchema.MaxLengths.IdempotencyKey})") + .UseCollation("Latin1_General_100_BIN2"); _ = builder.Property(k => k.CreatedAt).HasColumnType("datetimeoffset"); } } diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteIdempotencyKeyConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteIdempotencyKeyConfiguration.cs index 19546fa1..05058e8b 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteIdempotencyKeyConfiguration.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteIdempotencyKeyConfiguration.cs @@ -32,7 +32,9 @@ public SqliteIdempotencyKeyConfiguration(IOptions options /// protected override void ApplyColumnTypes(EntityTypeBuilder builder) { - _ = builder.Property(k => k.Key).HasColumnType("TEXT"); + // The model keeps MaxLength 500, so migrations created by earlier releases stay in sync; + // the store and the repository enforce IdempotencyKeySchema.MaxLengths.IdempotencyKey. + _ = builder.Property(k => k.Key).HasColumnType("TEXT").HasMaxLength(500); // DateTimeOffset stored as INTEGER (UTC ticks) for correct ordering in SQLite. _ = builder .Property(k => k.CreatedAt) diff --git a/src/NetEvolve.Pulse.EntityFramework/Idempotency/EntityFrameworkIdempotencyKeyRepository{TContext}.cs b/src/NetEvolve.Pulse.EntityFramework/Idempotency/EntityFrameworkIdempotencyKeyRepository{TContext}.cs index 9cdda6c0..849f1129 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Idempotency/EntityFrameworkIdempotencyKeyRepository{TContext}.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Idempotency/EntityFrameworkIdempotencyKeyRepository{TContext}.cs @@ -44,6 +44,10 @@ public Task ExistsAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); if (validFrom.HasValue) { @@ -66,6 +70,10 @@ public async Task StoreAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); _ = await TryInsertAsync(idempotencyKey, createdAt, cancellationToken).ConfigureAwait(false); } @@ -87,6 +95,10 @@ public async Task TryReserveAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); if (validFrom.HasValue) { diff --git a/src/NetEvolve.Pulse.EntityFramework/Idempotency/IdempotencyKey.cs b/src/NetEvolve.Pulse.EntityFramework/Idempotency/IdempotencyKey.cs index 0636faec..37461c6d 100644 --- a/src/NetEvolve.Pulse.EntityFramework/Idempotency/IdempotencyKey.cs +++ b/src/NetEvolve.Pulse.EntityFramework/Idempotency/IdempotencyKey.cs @@ -12,7 +12,7 @@ namespace NetEvolve.Pulse.Idempotency; /// to ensure interchangeability. /// Column Specifications: /// -/// : VARCHAR(500), Primary Key — the client-supplied idempotency key. +/// : up to characters, compared case-sensitively (binary collation on SQL Server and MySQL), Primary Key — the client-supplied idempotency key. /// : DATETIMEOFFSET, NOT NULL — timestamp when the key was stored. /// /// diff --git a/src/NetEvolve.Pulse.EntityFramework/README.md b/src/NetEvolve.Pulse.EntityFramework/README.md index 043e6728..d283e1d1 100644 --- a/src/NetEvolve.Pulse.EntityFramework/README.md +++ b/src/NetEvolve.Pulse.EntityFramework/README.md @@ -313,6 +313,13 @@ services.AddPulse(config => config ); ``` +## Idempotency Key Length and Case Sensitivity + +- Keys can be up to 450 characters long (`IdempotencyKeySchema.MaxLengths.IdempotencyKey`). The store and the repository reject a longer key with an `ArgumentOutOfRangeException` before it touches the database. +- On SQL Server the key column is `nvarchar(450)` with the collation `Latin1_General_100_BIN2`, because a clustered index key is limited to 900 bytes. On MySQL it is `varchar(500)` with the collation `utf8mb4_bin`. Keys are case-sensitive on every provider. + +Existing SQL Server and MySQL databases created with an earlier release need a new migration for these column changes (`dotnet ef migrations add IdempotencyKeyExactComparison`). Keys longer than 450 characters could never be stored in the old SQL Server column, so no key is truncated. The PostgreSQL (`character varying(500)`) and SQLite (`TEXT`) models are unchanged and need no migration. + ## Processing Lease Reclaim A message claimed by `GetPendingAsync` stays in `Processing` until it is completed or failed. If a worker crashes or shuts down in between, the next pending poll reclaims the message once its `UpdatedAt` is older than `OutboxOptions.ProcessingLeaseTimeout` (default: 5 minutes, must be greater than zero). diff --git a/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyStore.cs b/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyStore.cs index e383e5da..e0ca467d 100644 --- a/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyStore.cs +++ b/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyStore.cs @@ -47,7 +47,10 @@ public interface IIdempotencyStore /// Determines whether the specified idempotency key has already been stored, /// indicating that the corresponding command was previously processed. /// - /// The idempotency key to look up. Must not be or empty. + /// The idempotency key to look up. Must not be or empty. + /// Keys longer than characters are not supported; + /// the built-in store rejects them with an . Keys are compared case-sensitively. + /// /// A token to monitor for cancellation requests. /// /// if the key is already present in the store; otherwise . @@ -58,7 +61,10 @@ public interface IIdempotencyStore /// Persists the specified idempotency key so that future calls to /// with the same key return . /// - /// The idempotency key to store. Must not be or empty. + /// The idempotency key to store. Must not be or empty. + /// Keys longer than characters are not supported; + /// the built-in store rejects them with an . Keys are compared case-sensitively. + /// /// A token to monitor for cancellation requests. /// A task representing the asynchronous store operation. Task StoreAsync(string idempotencyKey, CancellationToken cancellationToken = default); @@ -67,7 +73,10 @@ public interface IIdempotencyStore /// Attempts to reserve the specified idempotency key before the corresponding command executes, /// so that concurrent or subsequent submissions of the same key are rejected. /// - /// The idempotency key to reserve. Must not be or empty. + /// The idempotency key to reserve. Must not be or empty. + /// Keys longer than characters are not supported; + /// the built-in store rejects them with an . Keys are compared case-sensitively. + /// /// A token to monitor for cancellation requests. /// /// if the key was newly reserved and the command may execute; diff --git a/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotentCommand{TResponse}.cs b/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotentCommand{TResponse}.cs index a7ddc473..ae21fa6f 100644 --- a/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotentCommand{TResponse}.cs +++ b/src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotentCommand{TResponse}.cs @@ -32,7 +32,9 @@ public interface IIdempotentCommand : ICommand /// Gets the client-supplied idempotency key that uniquely identifies this logical operation. /// /// - /// The key MUST be non- and non-empty. + /// The key MUST be non- and non-empty, and MUST NOT be longer than + /// characters. + /// Keys are compared case-sensitively, so keys that differ only by case are distinct. /// string IdempotencyKey { get; } } diff --git a/src/NetEvolve.Pulse.Extensibility/Idempotency/IdempotencyKeySchema.cs b/src/NetEvolve.Pulse.Extensibility/Idempotency/IdempotencyKeySchema.cs index 0ca67fce..1ad495ce 100644 --- a/src/NetEvolve.Pulse.Extensibility/Idempotency/IdempotencyKeySchema.cs +++ b/src/NetEvolve.Pulse.Extensibility/Idempotency/IdempotencyKeySchema.cs @@ -50,8 +50,13 @@ public static class Columns public static class MaxLengths { /// - /// Maximum length for the IdempotencyKey column (500 characters). + /// Maximum length for the IdempotencyKey column (450 characters). /// - public const int IdempotencyKey = 500; + /// + /// 450 characters of NVARCHAR take 900 bytes, the SQL Server limit for a clustered index key. + /// Longer keys are rejected with an ; keys are never truncated. + /// Keys are compared exactly, including their case. + /// + public const int IdempotencyKey = 450; } } diff --git a/src/NetEvolve.Pulse.MySql/Idempotency/MySqlIdempotencyKeyRepository.cs b/src/NetEvolve.Pulse.MySql/Idempotency/MySqlIdempotencyKeyRepository.cs index f063d1f4..5cbf7840 100644 --- a/src/NetEvolve.Pulse.MySql/Idempotency/MySqlIdempotencyKeyRepository.cs +++ b/src/NetEvolve.Pulse.MySql/Idempotency/MySqlIdempotencyKeyRepository.cs @@ -21,8 +21,12 @@ namespace NetEvolve.Pulse.Idempotency; /// All tables reside in the active database specified by the connection string. /// The property is ignored for MySQL. /// Duplicate Key Handling: -/// Uses INSERT IGNORE to handle duplicate key inserts gracefully. +/// Uses a plain INSERT and treats the duplicate-key error (ER_DUP_ENTRY, 1062) as an existing key. /// Concurrent inserts of the same key are idempotent and will not throw exceptions. +/// INSERT IGNORE is not used, because it would also hide data errors and store a truncated key. +/// Key comparison: +/// The schema script declares the key column with the binary collation utf8mb4_bin, +/// so keys that differ only by case are distinct. /// Timestamps: /// Stores values as BIGINT (UTC ticks), matching the /// interoperability contract with the Entity Framework MySQL provider. @@ -81,10 +85,11 @@ SELECT 1 FROM {table} LIMIT 1 """; - // INSERT IGNORE silently discards the new row when the primary key already exists, - // making concurrent inserts of the same key idempotent. + // A plain INSERT: a duplicate key raises ER_DUP_ENTRY, which TryInsertAsync treats as "already stored". + // INSERT IGNORE is not used, because it also turns data errors such as ER_DATA_TOO_LONG into + // warnings and would store a truncated key. _insertSql = $""" - INSERT IGNORE INTO {table} + INSERT INTO {table} (`{IdempotencyKeySchema.Columns.IdempotencyKey}`, `{IdempotencyKeySchema.Columns.CreatedAt}`) VALUES (@key, @createdAtTicks) """; @@ -110,6 +115,10 @@ public async Task ExistsAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var sql = validFrom.HasValue ? _existsWithTtlSql : _existsSql; @@ -142,27 +151,24 @@ public async Task StoreAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using (connection.ConfigureAwait(false)) { - var command = new MySqlCommand(_insertSql, connection); - await using (command.ConfigureAwait(false)) - { - _ = command.Parameters.AddWithValue("@key", idempotencyKey); - _ = command.Parameters.AddWithValue("@createdAtTicks", createdAt.UtcTicks); - - _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } + _ = await TryInsertAsync(connection, idempotencyKey, createdAt, cancellationToken).ConfigureAwait(false); } } /// /// - /// Runs INSERT IGNORE and, when is set, a conditional + /// Runs an INSERT and, when is set, a conditional /// UPDATE of an expired row. InnoDB's row lock makes a concurrent refresh re-read the /// already refreshed row, so only one caller for the same key receives . - /// When the refresh matches no row, the INSERT IGNORE runs once more, because the expired row + /// When the refresh matches no row, the INSERT runs once more, because the expired row /// may have been deleted by a cleanup job between the two statements. /// public async Task TryReserveAsync( @@ -175,20 +181,17 @@ public async Task TryReserveAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using (connection.ConfigureAwait(false)) { - var insert = new MySqlCommand(_insertSql, connection); - await using (insert.ConfigureAwait(false)) + if (await TryInsertAsync(connection, idempotencyKey, createdAt, cancellationToken).ConfigureAwait(false)) { - _ = insert.Parameters.AddWithValue("@key", idempotencyKey); - _ = insert.Parameters.AddWithValue("@createdAtTicks", createdAt.UtcTicks); - - if (await insert.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) > 0) - { - return true; - } + return true; } if (!validFrom.HasValue) @@ -210,14 +213,42 @@ public async Task TryReserveAsync( } // A cleanup job can delete the expired row between the two statements; the key is then - // absent, so one more INSERT IGNORE reserves it instead of reporting a false duplicate. - var retry = new MySqlCommand(_insertSql, connection); - await using (retry.ConfigureAwait(false)) - { - _ = retry.Parameters.AddWithValue("@key", idempotencyKey); - _ = retry.Parameters.AddWithValue("@createdAtTicks", createdAt.UtcTicks); + // absent, so one more INSERT reserves it instead of reporting a false duplicate. + return await TryInsertAsync(connection, idempotencyKey, createdAt, cancellationToken).ConfigureAwait(false); + } + } - return await retry.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) > 0; + /// + /// Inserts the key and reports whether this call stored it. + /// + /// The open connection to execute the insert on. + /// The idempotency key to insert. + /// The creation timestamp stored for the key. + /// A token to monitor for cancellation requests. + /// when the key was inserted; when it already exists. + private async Task TryInsertAsync( + MySqlConnection connection, + string idempotencyKey, + DateTimeOffset createdAt, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var command = new MySqlCommand(_insertSql, connection); + await using (command.ConfigureAwait(false)) + { + _ = command.Parameters.AddWithValue("@key", idempotencyKey); + _ = command.Parameters.AddWithValue("@createdAtTicks", createdAt.UtcTicks); + + try + { + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) > 0; + } + catch (MySqlException ex) when (ex.Number == (int)MySqlErrorCode.DuplicateKeyEntry) + { + // The key is already stored, by an earlier call or a concurrent request. + return false; } } } diff --git a/src/NetEvolve.Pulse.MySql/README.md b/src/NetEvolve.Pulse.MySql/README.md index ab8bdfdf..761fbfcd 100644 --- a/src/NetEvolve.Pulse.MySql/README.md +++ b/src/NetEvolve.Pulse.MySql/README.md @@ -46,6 +46,19 @@ DEALLOCATE PREPARE pulse_stmt; The scripts use a session user variable (`@pulse_sql`). To execute them through MySql.Data instead of the `mysql` client, set `AllowUserVariables=True` in the connection string. +### Idempotency key length and case sensitivity + +- Keys can be up to 450 characters long (`IdempotencyKeySchema.MaxLengths.IdempotencyKey`). `ExistsAsync`, `StoreAsync` and `TryReserveAsync` reject a longer key with an `ArgumentOutOfRangeException`. Keys are never truncated. The column stays `VARCHAR(500)`, so existing rows need no change. +- The key column uses the binary collation `utf8mb4_bin`, so keys are case-sensitive and accent-sensitive: `aBc123` and `ABC123` are two different keys. `utf8mb4_bin` is a `PAD SPACE` collation, so trailing spaces are not significant. +- Keys are stored with a plain `INSERT`, and a duplicate key (`ER_DUP_ENTRY`, 1062) counts as an existing key. `INSERT IGNORE` is no longer used, because it turned data errors into warnings. + +Tables created by earlier releases use `utf8mb4_unicode_ci`. Re-running `IdempotencyKey.sql` switches the key column to `utf8mb4_bin`. To apply the change on its own: + +```sql +ALTER TABLE `IdempotencyKey` + MODIFY `IdempotencyKey` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; +``` + ### 2. Register services **Outbox:** diff --git a/src/NetEvolve.Pulse.MySql/Scripts/IdempotencyKey.sql b/src/NetEvolve.Pulse.MySql/Scripts/IdempotencyKey.sql index 5cc9d324..2fccbc48 100644 --- a/src/NetEvolve.Pulse.MySql/Scripts/IdempotencyKey.sql +++ b/src/NetEvolve.Pulse.MySql/Scripts/IdempotencyKey.sql @@ -10,9 +10,15 @@ -- MySQL 8.0 or later -- -- Column types: --- IdempotencyKey VARCHAR(500) — the idempotency key (primary key) +-- IdempotencyKey VARCHAR(500) — the idempotency key (primary key), binary collation utf8mb4_bin -- CreatedAt BIGINT — UTC ticks (use dto.UtcTicks / new DateTimeOffset(ticks, TimeSpan.Zero)) -- +-- Idempotency keys: +-- Keys are compared by code point (utf8mb4_bin), so keys that differ only by case are distinct. +-- utf8mb4_bin is a PAD SPACE collation, so trailing spaces are not significant. +-- The application rejects keys longer than 450 characters (IdempotencyKeySchema.MaxLengths.IdempotencyKey). +-- The column stays VARCHAR(500), so tables created by earlier releases need no data change. +-- -- Usage: -- Run this script in the target MySQL database before deploying the application: -- mysql -u -p < IdempotencyKey.sql @@ -20,11 +26,13 @@ -- The script is safe to re-run. MySQL 8.0 has no CREATE INDEX IF NOT EXISTS, so every index -- is guarded by an information_schema.statistics lookup executed through PREPARE / EXECUTE. -- Re-run the script after upgrading the package to apply indexes added in later releases. --- Existing tables and indexes are left unchanged. When executing it through MySql.Data +-- Existing tables and indexes are left unchanged, except that a case-insensitive key column +-- created by an earlier release is switched to utf8mb4_bin through ALTER TABLE ... MODIFY. When executing it through MySql.Data -- instead of the mysql client, set AllowUserVariables=True (the guards use @pulse_sql). -- -- If you need a custom table name, replace every table reference to IdempotencyKey --- (CREATE TABLE IF NOT EXISTS `IdempotencyKey`, ON `IdempotencyKey` and TABLE_NAME = 'IdempotencyKey') +-- (CREATE TABLE IF NOT EXISTS `IdempotencyKey`, ALTER TABLE `IdempotencyKey`, ON `IdempotencyKey` +-- and TABLE_NAME = 'IdempotencyKey') -- and update IdempotencyKeyOptions.TableName in your application configuration accordingly. -- -- Note on schema: @@ -34,11 +42,23 @@ -- ============================================================================ CREATE TABLE IF NOT EXISTS `IdempotencyKey` ( - `IdempotencyKey` VARCHAR(500) NOT NULL, + `IdempotencyKey` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `CreatedAt` BIGINT NOT NULL, CONSTRAINT `PK_IdempotencyKey` PRIMARY KEY (`IdempotencyKey`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Switch the key column of a table created by an earlier release to the binary collation +SET @pulse_sql := IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'IdempotencyKey' AND COLUMN_NAME = 'IdempotencyKey' + AND COLLATION_NAME <> 'utf8mb4_bin') > 0, + 'ALTER TABLE `IdempotencyKey` MODIFY `IdempotencyKey` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL', + 'DO 0' +); +PREPARE pulse_stmt FROM @pulse_sql; +EXECUTE pulse_stmt; +DEALLOCATE PREPARE pulse_stmt; + -- Index to efficiently filter keys by creation time (for TTL-based existence checks) SET @pulse_sql := IF( (SELECT COUNT(*) FROM information_schema.statistics diff --git a/src/NetEvolve.Pulse.PostgreSql/README.md b/src/NetEvolve.Pulse.PostgreSql/README.md index 1bd5a563..8844f81d 100644 --- a/src/NetEvolve.Pulse.PostgreSql/README.md +++ b/src/NetEvolve.Pulse.PostgreSql/README.md @@ -96,6 +96,8 @@ When upgrading from an earlier version, **re-run `OutboxMessage.sql`** against e The idempotency store reserves keys through `fn_reserve_idempotency_key` (`ON CONFLICT ... DO UPDATE ... WHERE`), which also refreshes the `created_at` of a key that has outlived `IdempotencyKeyOptions.TimeToLive`. **Re-run `IdempotencyKey.sql`** together with the package upgrade; it keeps the table and its data and creates the new function. +Idempotency keys can be up to 450 characters long and are compared case-sensitively. See [Idempotency Keys](https://github.com/dailydevops/pulse/blob/main/src/NetEvolve.Pulse/README.md#idempotency-keys) in the `NetEvolve.Pulse` README. + ## Quick Start ```csharp diff --git a/src/NetEvolve.Pulse.Redis/README.md b/src/NetEvolve.Pulse.Redis/README.md index 4b19a913..2d58f4f2 100644 --- a/src/NetEvolve.Pulse.Redis/README.md +++ b/src/NetEvolve.Pulse.Redis/README.md @@ -80,3 +80,5 @@ When `TimeToLive` is set, reservation runs a Lua script. The Redis user must be The script compares stored timestamps as UTC round-trip text. The provider always writes UTC values. A value with a non-UTC offset, written by an earlier version through a direct `IIdempotencyKeyRepository.StoreAsync` call, is treated as present until its physical Redis expiry removes it. A value stored while `TimeToLive` was `null` has no physical expiry, so delete such keys manually if they must become reservable again. Invalid options cause an `OptionsValidationException` at startup or on first resolution of the options. + +Idempotency keys can be up to 450 characters long and are compared case-sensitively. See [Idempotency Keys](https://github.com/dailydevops/pulse/blob/main/src/NetEvolve.Pulse/README.md#idempotency-keys) in the `NetEvolve.Pulse` README. diff --git a/src/NetEvolve.Pulse.SqlServer/Idempotency/SqlServerIdempotencyKeyRepository.cs b/src/NetEvolve.Pulse.SqlServer/Idempotency/SqlServerIdempotencyKeyRepository.cs index 7bbb7971..65c0a61d 100644 --- a/src/NetEvolve.Pulse.SqlServer/Idempotency/SqlServerIdempotencyKeyRepository.cs +++ b/src/NetEvolve.Pulse.SqlServer/Idempotency/SqlServerIdempotencyKeyRepository.cs @@ -80,6 +80,10 @@ public async Task ExistsAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using (connection.ConfigureAwait(false)) @@ -88,7 +92,14 @@ public async Task ExistsAsync( await using (command.ConfigureAwait(false)) { _ = command.Parameters.Add( - new SqlParameter("@idempotencyKey", SqlDbType.NVarChar, 500) { Value = idempotencyKey } + new SqlParameter( + "@idempotencyKey", + SqlDbType.NVarChar, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ) + { + Value = idempotencyKey, + } ); if (validFrom.HasValue) @@ -114,6 +125,10 @@ public async Task StoreAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using (connection.ConfigureAwait(false)) @@ -122,7 +137,14 @@ public async Task StoreAsync( await using (command.ConfigureAwait(false)) { _ = command.Parameters.Add( - new SqlParameter("@idempotencyKey", SqlDbType.NVarChar, 500) { Value = idempotencyKey } + new SqlParameter( + "@idempotencyKey", + SqlDbType.NVarChar, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ) + { + Value = idempotencyKey, + } ); _ = command.Parameters.Add( new SqlParameter("@createdAt", SqlDbType.DateTimeOffset) { Value = createdAt } @@ -152,6 +174,10 @@ public async Task TryReserveAsync( cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using (connection.ConfigureAwait(false)) @@ -160,7 +186,14 @@ public async Task TryReserveAsync( await using (command.ConfigureAwait(false)) { _ = command.Parameters.Add( - new SqlParameter("@idempotencyKey", SqlDbType.NVarChar, 500) { Value = idempotencyKey } + new SqlParameter( + "@idempotencyKey", + SqlDbType.NVarChar, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ) + { + Value = idempotencyKey, + } ); _ = command.Parameters.Add( new SqlParameter("@createdAt", SqlDbType.DateTimeOffset) { Value = createdAt } diff --git a/src/NetEvolve.Pulse.SqlServer/README.md b/src/NetEvolve.Pulse.SqlServer/README.md index 4a57687f..841ed5ea 100644 --- a/src/NetEvolve.Pulse.SqlServer/README.md +++ b/src/NetEvolve.Pulse.SqlServer/README.md @@ -158,6 +158,22 @@ The script creates: **Upgrading:** re-run `IdempotencyKey.sql` together with the package upgrade. The script keeps the table and its data and recreates the stored procedures; the new package version calls `usp_ReserveIdempotencyKey`, which older scripts do not create. +#### Idempotency Key Length and Case Sensitivity + +- Keys can be up to 450 characters long (`IdempotencyKeySchema.MaxLengths.IdempotencyKey`). The column is `NVARCHAR(450)`, which takes 900 bytes, the SQL Server limit for a clustered index key. `ExistsAsync`, `StoreAsync` and `TryReserveAsync` reject a longer key with an `ArgumentOutOfRangeException`. Keys are never truncated. +- The column uses the binary collation `Latin1_General_100_BIN2`, so keys are case-sensitive and accent-sensitive: `aBc123` and `ABC123` are two different keys. +- SQL Server pads strings before comparing them, so trailing spaces are not significant. + +**Upgrading from an earlier release:** tables created by earlier scripts use `NVARCHAR(500)` in the database default collation. Re-running `IdempotencyKey.sql` upgrades them in one transaction: it drops `PK_`, changes the column to `NVARCHAR(450) COLLATE Latin1_General_100_BIN2` and re-creates the clustered primary key. The old table could never hold a key longer than 450 characters (Msg 1946), so no key is truncated. If you manage the schema yourself, apply the same steps: + +```sql +ALTER TABLE [pulse].[IdempotencyKey] DROP CONSTRAINT [PK_IdempotencyKey]; +ALTER TABLE [pulse].[IdempotencyKey] + ALTER COLUMN [IdempotencyKey] NVARCHAR(450) COLLATE Latin1_General_100_BIN2 NOT NULL; +ALTER TABLE [pulse].[IdempotencyKey] + ADD CONSTRAINT [PK_IdempotencyKey] PRIMARY KEY CLUSTERED ([IdempotencyKey]); +``` + #### Using Idempotent Commands ```csharp diff --git a/src/NetEvolve.Pulse.SqlServer/Scripts/IdempotencyKey.sql b/src/NetEvolve.Pulse.SqlServer/Scripts/IdempotencyKey.sql index 6f5297a9..863523ee 100644 --- a/src/NetEvolve.Pulse.SqlServer/Scripts/IdempotencyKey.sql +++ b/src/NetEvolve.Pulse.SqlServer/Scripts/IdempotencyKey.sql @@ -10,6 +10,18 @@ -- - sqlcmd utility: sqlcmd -i IdempotencyKey.sql -- - SSMS: Query > SQLCMD Mode (Ctrl+Shift+Q) -- - Azure Data Studio: Enable SQLCMD in the query toolbar +-- +-- Idempotency keys: +-- [IdempotencyKey] is NVARCHAR(450) with the binary collation Latin1_General_100_BIN2. +-- 450 NVARCHAR characters take 900 bytes, the SQL Server limit for a clustered index key. +-- Keys are compared by code point, so keys that differ only by case are distinct. +-- SQL Server pads strings before comparing them, so trailing spaces are not significant. +-- +-- Upgrading: +-- The script is safe to re-run. Re-running it upgrades a table created by an earlier release +-- (NVARCHAR(500) in the database default collation): it drops [PK_$(TableName)], changes the column +-- and re-creates the primary key in one transaction. Existing keys are kept; keys longer than +-- 450 characters could never be stored in the old table (Msg 1946), so no key is truncated. -- ============================================================================ -- ============================================================================ @@ -30,7 +42,7 @@ IF NOT EXISTS (SELECT 1 FROM sys.objects WHERE [object_id] = OBJECT_ID(N'[$(Sche BEGIN CREATE TABLE [$(SchemaName)].[$(TableName)] ( - [IdempotencyKey] NVARCHAR(500) NOT NULL, + [IdempotencyKey] NVARCHAR(450) COLLATE Latin1_General_100_BIN2 NOT NULL, [CreatedAt] DATETIMEOFFSET(7) NOT NULL, CONSTRAINT [PK_$(TableName)] PRIMARY KEY CLUSTERED ([IdempotencyKey]) ); @@ -41,6 +53,43 @@ BEGIN END GO +-- Upgrade the key column of a table created by an earlier release to NVARCHAR(450) with a binary collation +IF EXISTS ( + SELECT 1 FROM sys.columns + WHERE [object_id] = OBJECT_ID(N'[$(SchemaName)].[$(TableName)]') + AND [name] = N'IdempotencyKey' + AND ([max_length] <> 900 OR [collation_name] <> N'Latin1_General_100_BIN2') +) +BEGIN + SET XACT_ABORT ON; + + BEGIN TRY + BEGIN TRANSACTION; + + ALTER TABLE [$(SchemaName)].[$(TableName)] DROP CONSTRAINT [PK_$(TableName)]; + ALTER TABLE [$(SchemaName)].[$(TableName)] + ALTER COLUMN [IdempotencyKey] NVARCHAR(450) COLLATE Latin1_General_100_BIN2 NOT NULL; + ALTER TABLE [$(SchemaName)].[$(TableName)] + ADD CONSTRAINT [PK_$(TableName)] PRIMARY KEY CLUSTERED ([IdempotencyKey]); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + + SET XACT_ABORT OFF; + + DECLARE @upgradeError NVARCHAR(2048) = + N'Upgrading [$(SchemaName)].[$(TableName)].[IdempotencyKey] to NVARCHAR(450) COLLATE Latin1_General_100_BIN2 failed: ' + + ERROR_MESSAGE(); + THROW 50001, @upgradeError, 1; + END CATCH + + SET XACT_ABORT OFF; +END +GO + -- ============================================================================ -- Stored Procedures -- ============================================================================ @@ -53,7 +102,7 @@ END GO CREATE PROCEDURE [$(SchemaName)].[usp_ExistsIdempotencyKey] - @idempotencyKey NVARCHAR(500), + @idempotencyKey NVARCHAR(450), @validFrom DATETIMEOFFSET = NULL AS BEGIN @@ -89,7 +138,7 @@ END GO CREATE PROCEDURE [$(SchemaName)].[usp_InsertIdempotencyKey] - @idempotencyKey NVARCHAR(500), + @idempotencyKey NVARCHAR(450), @createdAt DATETIMEOFFSET AS BEGIN @@ -122,7 +171,7 @@ END GO CREATE PROCEDURE [$(SchemaName)].[usp_ReserveIdempotencyKey] - @idempotencyKey NVARCHAR(500), + @idempotencyKey NVARCHAR(450), @createdAt DATETIMEOFFSET, @validFrom DATETIMEOFFSET = NULL AS diff --git a/src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs b/src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs index c398b607..c55c47de 100644 --- a/src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs +++ b/src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs @@ -50,6 +50,10 @@ public Task ExistsAsync(string idempotencyKey, CancellationToken cancellat cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); return _repository.ExistsAsync(idempotencyKey, GetCutoff(), cancellationToken); } @@ -71,6 +75,10 @@ public Task TryReserveAsync(string idempotencyKey, CancellationToken cance cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + idempotencyKey.Length, + IdempotencyKeySchema.MaxLengths.IdempotencyKey + ); var now = _timeProvider.GetUtcNow(); return _repository.TryReserveAsync(idempotencyKey, now, GetCutoff(now), cancellationToken); diff --git a/src/NetEvolve.Pulse/README.md b/src/NetEvolve.Pulse/README.md index 310bf6ce..2a79a0f7 100644 --- a/src/NetEvolve.Pulse/README.md +++ b/src/NetEvolve.Pulse/README.md @@ -261,6 +261,13 @@ Audit writes are best effort (fail open): A missing audit record therefore shows up as an `Error` log entry, never as a failed request. +### Idempotency Keys + +Commands that implement `IIdempotentCommand` or `IIdempotentCommand` are checked against the registered `IIdempotencyStore`. The built-in store applies the same key rules for every provider: + +- A key can be up to 450 characters long (`IdempotencyKeySchema.MaxLengths.IdempotencyKey`). `ExistsAsync`, `StoreAsync` and `TryReserveAsync` reject a longer key with an `ArgumentOutOfRangeException`, so a key is never truncated. +- Keys are compared case-sensitively: `aBc123` and `ABC123` are two different keys. + ### Outbox Pattern Configuration The outbox pattern ensures reliable event delivery by persisting events before dispatching: diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/IdempotencyTestsBase.cs b/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/IdempotencyTestsBase.cs index 7cc27375..dddad4c1 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/IdempotencyTestsBase.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/IdempotencyTestsBase.cs @@ -685,6 +685,68 @@ await RunAndVerify( .ConfigureAwait(false); } + [Test] + public async Task Should_Treat_Keys_Differing_Only_By_Case_As_Distinct(CancellationToken cancellationToken) => + await RunAndVerify( + async (services, token) => + { + var store = services.GetRequiredService(); + + await store.StoreAsync("aBc123", token).ConfigureAwait(false); + + var exists = await store.ExistsAsync("ABC123", token).ConfigureAwait(false); + var reserved = await store.TryReserveAsync("ABC123", token).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(exists).IsFalse(); + _ = await Assert.That(reserved).IsTrue(); + } + }, + cancellationToken + ) + .ConfigureAwait(false); + + [Test] + public async Task Should_Store_And_Find_Key_Of_Max_Length(CancellationToken cancellationToken) => + await RunAndVerify( + async (services, token) => + { + var store = services.GetRequiredService(); + var prefix = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey - 1); + + await store.StoreAsync(prefix + "a", token).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert + .That(await store.ExistsAsync(prefix + "a", token).ConfigureAwait(false)) + .IsTrue(); + _ = await Assert + .That(await store.ExistsAsync(prefix + "b", token).ConfigureAwait(false)) + .IsFalse(); + } + }, + cancellationToken + ) + .ConfigureAwait(false); + + [Test] + public async Task Should_Reject_Key_Longer_Than_Max_Length(CancellationToken cancellationToken) => + await RunAndVerify( + async (services, token) => + { + var store = services.GetRequiredService(); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => await store.StoreAsync(key, token).ConfigureAwait(false)) + .Throws(); + }, + cancellationToken + ) + .ConfigureAwait(false); + private sealed record TestIdempotentVoidCommand(string IdempotencyKey) : IIdempotentCommand { public string? CausationId { get; set; } diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/SqlServerAdoNetIdempotencyTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/SqlServerAdoNetIdempotencyTests.cs index a6592e5f..8343f1dd 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/SqlServerAdoNetIdempotencyTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/Idempotency/SqlServerAdoNetIdempotencyTests.cs @@ -1,6 +1,11 @@ namespace NetEvolve.Pulse.Tests.Integration.Idempotency; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility.Idempotency; +using NetEvolve.Pulse.Idempotency; using NetEvolve.Pulse.Tests.Integration.Internals; using NetEvolve.Pulse.Tests.Integration.Internals.Idempotency; using NetEvolve.Pulse.Tests.Integration.Internals.Services; @@ -14,4 +19,157 @@ namespace NetEvolve.Pulse.Tests.Integration.Idempotency; public class SqlServerAdoNetIdempotencyTests( IServiceFixture databaseServiceFixture, IServiceInitializer databaseInitializer -) : IdempotencyTestsBase(databaseServiceFixture, databaseInitializer); +) : IdempotencyTestsBase(databaseServiceFixture, databaseInitializer) +{ + [Test] + public async Task Should_Upgrade_Legacy_Key_Column_When_Script_Is_Rerun(CancellationToken cancellationToken) => + await RunAndVerify( + async (services, token) => + { + var options = services.GetRequiredService>().Value; + var connectionString = options.ConnectionString!; + var table = $"[{options.Schema}].[{options.TableName}]"; + + // Recreate the table in the shape shipped before the key column was fixed: + // NVARCHAR(500) in the database default collation. + await ExecuteAsync( + connectionString, + $""" + DROP TABLE {table}; + CREATE TABLE {table} + ( + [IdempotencyKey] NVARCHAR(500) NOT NULL, + [CreatedAt] DATETIMEOFFSET(7) NOT NULL, + CONSTRAINT [PK_{options.TableName}] PRIMARY KEY CLUSTERED ([IdempotencyKey]) + ); + CREATE NONCLUSTERED INDEX [IX_{options.TableName}_CreatedAt] ON {table} ([CreatedAt]); + INSERT INTO {table} ([IdempotencyKey], [CreatedAt]) VALUES (N'legacy-key', SYSDATETIMEOFFSET()); + """, + token + ) + .ConfigureAwait(false); + + await DatabaseInitializer.CreateDatabaseAsync(services, token).ConfigureAwait(false); + + var (maxLength, collation) = await GetKeyColumnAsync(connectionString, table, token) + .ConfigureAwait(false); + var store = services.GetRequiredService(); + + using (Assert.Multiple()) + { + _ = await Assert.That(maxLength).IsEqualTo(IdempotencyKeySchema.MaxLengths.IdempotencyKey * 2); + _ = await Assert.That(collation).IsEqualTo("Latin1_General_100_BIN2"); + _ = await Assert + .That(await store.ExistsAsync("legacy-key", token).ConfigureAwait(false)) + .IsTrue(); + _ = await Assert + .That(await store.ExistsAsync("LEGACY-KEY", token).ConfigureAwait(false)) + .IsFalse(); + } + }, + cancellationToken + ) + .ConfigureAwait(false); + + [Test] + public async Task Should_Roll_Back_And_Report_Failed_Upgrade_When_Primary_Key_Name_Differs( + CancellationToken cancellationToken + ) => + await RunAndVerify( + async (services, token) => + { + var options = services.GetRequiredService>().Value; + var connectionString = options.ConnectionString!; + var table = $"[{options.Schema}].[{options.TableName}]"; + + // A hand-managed legacy table whose primary key does not use the name the script expects. + await ExecuteAsync( + connectionString, + $""" + DROP TABLE {table}; + CREATE TABLE {table} + ( + [IdempotencyKey] NVARCHAR(500) NOT NULL, + [CreatedAt] DATETIMEOFFSET(7) NOT NULL, + CONSTRAINT [PK_Custom_{options.TableName}] PRIMARY KEY CLUSTERED ([IdempotencyKey]) + ); + """, + token + ) + .ConfigureAwait(false); + + var exception = await Assert + .That(async () => + await DatabaseInitializer.CreateDatabaseAsync(services, token).ConfigureAwait(false) + ) + .Throws(); + + var (maxLength, _) = await GetKeyColumnAsync(connectionString, table, token).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(exception!.Number).IsEqualTo(50001); + _ = await Assert.That(maxLength).IsEqualTo(1000); + } + }, + cancellationToken + ) + .ConfigureAwait(false); + + private static async Task ExecuteAsync(string connectionString, string sql, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var connection = new SqlConnection(connectionString); + await using (connection.ConfigureAwait(false)) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + +#pragma warning disable CA2100 // sql is built from test-controlled schema and table names + var command = new SqlCommand(sql, connection); +#pragma warning restore CA2100 + await using (command.ConfigureAwait(false)) + { + _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private static async Task<(int MaxLength, string? Collation)> GetKeyColumnAsync( + string connectionString, + string table, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var connection = new SqlConnection(connectionString); + await using (connection.ConfigureAwait(false)) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + var command = new SqlCommand( + """ + SELECT [max_length], [collation_name] + FROM sys.columns + WHERE [object_id] = OBJECT_ID(@table) AND [name] = N'IdempotencyKey' + """, + connection + ); + await using (command.ConfigureAwait(false)) + { + _ = command.Parameters.AddWithValue("@table", table); + + var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + _ = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var collation = await reader.IsDBNullAsync(1, cancellationToken).ConfigureAwait(false) + ? null + : reader.GetString(1); + return (reader.GetInt16(0), collation); + } + } + } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlScriptRunner.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlScriptRunner.cs index fc57fd84..6e84bcbb 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlScriptRunner.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlScriptRunner.cs @@ -18,7 +18,7 @@ internal static class MySqlScriptRunner /// replacing every table-name reference of with . /// /// - /// Only table references are replaced (CREATE TABLE, ON `X` and TABLE_NAME = 'X'), + /// Only table references are replaced (CREATE TABLE, ALTER TABLE `X`, ON `X` and TABLE_NAME = 'X'), /// so a column that shares the table name (for example IdempotencyKey) keeps its name. /// public static async Task ExecuteAsync( @@ -40,6 +40,7 @@ CancellationToken cancellationToken $"TABLE IF NOT EXISTS `{tableName}`", StringComparison.Ordinal ) + .Replace($"ALTER TABLE `{defaultTableName}`", $"ALTER TABLE `{tableName}`", StringComparison.Ordinal) .Replace($" ON `{defaultTableName}`", $" ON `{tableName}`", StringComparison.Ordinal) .Replace($"TABLE_NAME = '{defaultTableName}'", $"TABLE_NAME = '{tableName}'", StringComparison.Ordinal); diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Schema/EntityFrameworkIdempotencyKeyCreateScriptTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Schema/EntityFrameworkIdempotencyKeyCreateScriptTests.cs new file mode 100644 index 00000000..07895a6b --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Integration/Schema/EntityFrameworkIdempotencyKeyCreateScriptTests.cs @@ -0,0 +1,58 @@ +namespace NetEvolve.Pulse.Tests.Integration.Schema; + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Tests.Integration.Internals.Idempotency; +using TUnit.Core; + +/// +/// Checks the DDL that the EF Core providers generate for the idempotency key column. +/// Generating the create script needs no database connection, so these tests run without Docker. +/// +[TestGroup("EntityFramework")] +public sealed class EntityFrameworkIdempotencyKeyCreateScriptTests +{ + [Test] + public async Task GenerateCreateScript_WithMySqlProvider_DeclaresBinaryKeyCollation( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var options = new DbContextOptionsBuilder() + .UseMySQL("Server=localhost;Database=pulse;Uid=pulse;Pwd=pulse") + .Options; + + var script = GenerateCreateScript(options); + + _ = await Assert.That(script).Contains("`IdempotencyKey` varchar(500) COLLATE utf8mb4_bin NOT NULL"); + } + + [Test] + public async Task GenerateCreateScript_WithSqlServerProvider_DeclaresBinaryKeyCollation( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=localhost;Database=pulse;Integrated Security=true") + .Options; + + var script = GenerateCreateScript(options); + + _ = await Assert + .That(script) + .Contains("[IdempotencyKey] nvarchar(450) COLLATE Latin1_General_100_BIN2 NOT NULL"); + } + + private static string GenerateCreateScript( + DbContextOptions options + ) + { + using var context = new EntityFrameworkIdempotencyInitializer.TestIdempotencyDbContext(options); + return context.Database.GenerateCreateScript(); + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Schema/MySqlSchemaScriptTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Schema/MySqlSchemaScriptTests.cs index 958012b4..15c13051 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/Schema/MySqlSchemaScriptTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/Schema/MySqlSchemaScriptTests.cs @@ -97,6 +97,81 @@ await MySqlScriptRunner ]); } + [Test] + public async Task IdempotencyKeyScript_WhenKeyColumnIsCaseInsensitive_SwitchesToBinaryCollation( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var connectionString = await CreateDatabaseAsync(cancellationToken).ConfigureAwait(false); + + // Simulate a deployment created before the key column got a binary collation. + await ExecuteAsync( + connectionString, + """ + CREATE TABLE `IdempotencyKey` ( + `IdempotencyKey` VARCHAR(500) NOT NULL, + `CreatedAt` BIGINT NOT NULL, + CONSTRAINT `PK_IdempotencyKey` PRIMARY KEY (`IdempotencyKey`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + cancellationToken + ) + .ConfigureAwait(false); + await ExecuteAsync( + connectionString, + "INSERT INTO `IdempotencyKey` (`IdempotencyKey`, `CreatedAt`) VALUES ('aBc123', 0)", + cancellationToken + ) + .ConfigureAwait(false); + + await MySqlScriptRunner + .ExecuteAsync(connectionString, "IdempotencyKey.sql", "IdempotencyKey", "IdempotencyKey", cancellationToken) + .ConfigureAwait(false); + await ExecuteAsync( + connectionString, + "INSERT INTO `IdempotencyKey` (`IdempotencyKey`, `CreatedAt`) VALUES ('ABC123', 0)", + cancellationToken + ) + .ConfigureAwait(false); + + var collation = await GetKeyColumnCollationAsync(connectionString, "IdempotencyKey", cancellationToken) + .ConfigureAwait(false); + + _ = await Assert.That(collation).IsEqualTo("utf8mb4_bin"); + } + + private static async Task GetKeyColumnCollationAsync( + string connectionString, + string tableName, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var connection = new MySqlConnection(connectionString); + await using (connection.ConfigureAwait(false)) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + var command = new MySqlCommand( + """ + SELECT COLLATION_NAME + FROM information_schema.columns + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tableName AND COLUMN_NAME = 'IdempotencyKey' + """, + connection + ); + await using (command.ConfigureAwait(false)) + { + _ = command.Parameters.AddWithValue("@tableName", tableName); + + return await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + } + } + } + private async Task CreateDatabaseAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkIdempotencyKeyRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkIdempotencyKeyRepositoryTests.cs index 0d08632f..eda418c1 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkIdempotencyKeyRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkIdempotencyKeyRepositoryTests.cs @@ -4,6 +4,7 @@ namespace NetEvolve.Pulse.Tests.Unit.EntityFramework; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility.Idempotency; using NetEvolve.Pulse.Idempotency; using TUnit.Assertions; using TUnit.Assertions.Extensions; @@ -348,4 +349,71 @@ public async Task IsDuplicateKeyException_WithUnrelatedExceptionType_ReturnsFals _ = await Assert.That(result).IsFalse(); } + + [Test] + public async Task ExistsAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var context = CreateContext(nameof(ExistsAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException)); + await using (context.ConfigureAwait(false)) + { + var repository = CreateRepository(context); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => await repository.ExistsAsync(key, null, cancellationToken).ConfigureAwait(false)) + .Throws(); + } + } + + [Test] + public async Task StoreAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var context = CreateContext(nameof(StoreAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException)); + await using (context.ConfigureAwait(false)) + { + var repository = CreateRepository(context); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository.StoreAsync(key, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false) + ) + .Throws(); + + var count = await context.IdempotencyKeys.CountAsync(cancellationToken).ConfigureAwait(false); + _ = await Assert.That(count).IsEqualTo(0); + } + } + + [Test] + public async Task TryReserveAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var context = CreateContext(nameof(TryReserveAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException)); + await using (context.ConfigureAwait(false)) + { + var repository = CreateRepository(context); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository + .TryReserveAsync(key, DateTimeOffset.UtcNow, null, cancellationToken) + .ConfigureAwait(false) + ) + .Throws(); + + var count = await context.IdempotencyKeys.CountAsync(cancellationToken).ConfigureAwait(false); + _ = await Assert.That(count).IsEqualTo(0); + } + } } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/IdempotencyKeyConfigurationMetadataTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/IdempotencyKeyConfigurationMetadataTests.cs new file mode 100644 index 00000000..2f6f96ef --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/IdempotencyKeyConfigurationMetadataTests.cs @@ -0,0 +1,74 @@ +namespace NetEvolve.Pulse.Tests.Unit.EntityFramework; + +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Configurations; +using NetEvolve.Pulse.Extensibility.Idempotency; +using NetEvolve.Pulse.Idempotency; +using TUnit.Core; + +[TestGroup("EntityFramework")] +public sealed class IdempotencyKeyConfigurationMetadataTests +{ + [Test] + public async Task Configure_WithSqlServerConfiguration_UsesBinaryCollationWithinIndexKeyLimit() + { + var property = GetKeyProperty(new SqlServerIdempotencyKeyConfiguration()); + + using (Assert.Multiple()) + { + // NVARCHAR stores 2 bytes per character, and a clustered index key is limited to 900 bytes. + _ = await Assert.That(property.GetColumnType()).IsEqualTo("nvarchar(450)"); + _ = await Assert.That(property.GetCollation()).IsEqualTo("Latin1_General_100_BIN2"); + } + } + + [Test] + public async Task Configure_WithMySqlConfiguration_UsesBinaryCollation() + { + var property = GetKeyProperty(new MySqlIdempotencyKeyConfiguration()); + + _ = await Assert.That(property.GetCollation()).IsEqualTo("utf8mb4_bin"); + } + + [Test] + public async Task Configure_WithInMemoryConfiguration_UsesCentralMaxLength() + { + var property = GetKeyProperty(new InMemoryIdempotencyKeyConfiguration()); + + _ = await Assert.That(property.GetMaxLength()).IsEqualTo(IdempotencyKeySchema.MaxLengths.IdempotencyKey); + } + + [Test] + public async Task Configure_WithPostgreSqlConfiguration_KeepsModelMaxLengthOfExistingMigrations() + { + var property = GetKeyProperty(new PostgreSqlIdempotencyKeyConfiguration()); + + _ = await Assert.That(property.GetMaxLength()).IsEqualTo(500); + } + + [Test] + public async Task Configure_WithSqliteConfiguration_KeepsModelMaxLengthOfExistingMigrations() + { + var property = GetKeyProperty(new SqliteIdempotencyKeyConfiguration()); + + _ = await Assert.That(property.GetMaxLength()).IsEqualTo(500); + } + + [Test] + public async Task Configure_WithMySqlConfiguration_KeepsModelMaxLengthOfExistingMigrations() + { + var property = GetKeyProperty(new MySqlIdempotencyKeyConfiguration()); + + _ = await Assert.That(property.GetMaxLength()).IsEqualTo(500); + } + + private static IMutableProperty GetKeyProperty(IEntityTypeConfiguration configuration) + { + var modelBuilder = new ModelBuilder(); + _ = modelBuilder.ApplyConfiguration(configuration); + return modelBuilder.Model.FindEntityType(typeof(IdempotencyKey))!.FindProperty(nameof(IdempotencyKey.Key))!; + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Idempotency/IdempotencyStoreTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Idempotency/IdempotencyStoreTests.cs index 50b97a0f..06cb8d32 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Idempotency/IdempotencyStoreTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Idempotency/IdempotencyStoreTests.cs @@ -220,6 +220,73 @@ public async Task StoreAsync_WithTtl_RefreshesExpiredKeyThroughReserve(Cancellat } } + [Test] + public async Task ExistsAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new TrackingIdempotencyKeyRepository(); + var store = CreateStore(repository); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => await store.ExistsAsync(key, cancellationToken).ConfigureAwait(false)) + .Throws(); + _ = await Assert.That(repository.CapturedValidFrom).IsEqualTo(DateTimeOffset.MaxValue); + } + + [Test] + public async Task StoreAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new TrackingIdempotencyKeyRepository(); + var store = CreateStore(repository); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => await store.StoreAsync(key, cancellationToken).ConfigureAwait(false)) + .Throws(); + _ = await Assert.That(repository.ReserveCount).IsEqualTo(0); + } + + [Test] + public async Task TryReserveAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new TrackingIdempotencyKeyRepository(); + var store = CreateStore(repository); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => await store.TryReserveAsync(key, cancellationToken).ConfigureAwait(false)) + .Throws(); + _ = await Assert.That(repository.ReserveCount).IsEqualTo(0); + } + + [Test] + public async Task TryReserveAsync_WithKeyOfMaxLength_ReservesKey(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new TrackingIdempotencyKeyRepository(); + var store = CreateStore(repository); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey); + + var result = await store.TryReserveAsync(key, cancellationToken).ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(result).IsTrue(); + _ = await Assert.That(repository.ReserveCount).IsEqualTo(1); + } + } + private sealed class TrackingIdempotencyKeyRepository : IIdempotencyKeyRepository { public DateTimeOffset? CapturedValidFrom { get; private set; } = DateTimeOffset.MaxValue; diff --git a/tests/NetEvolve.Pulse.Tests.Unit/MySql/MySqlIdempotencyKeyRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/MySql/MySqlIdempotencyKeyRepositoryTests.cs index 9dc3a0fb..027f252f 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/MySql/MySqlIdempotencyKeyRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/MySql/MySqlIdempotencyKeyRepositoryTests.cs @@ -4,6 +4,7 @@ namespace NetEvolve.Pulse.Tests.Unit.MySql; using System.Threading.Tasks; using Microsoft.Extensions.Options; using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility.Idempotency; using NetEvolve.Pulse.Idempotency; using TUnit.Core; @@ -99,4 +100,61 @@ await repository.StoreAsync(null!, DateTimeOffset.UtcNow, cancellationToken).Con ) .Throws(); } + + [Test] + public async Task ExistsAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new MySqlIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository.ExistsAsync(key, cancellationToken: cancellationToken).ConfigureAwait(false) + ) + .Throws(); + } + + [Test] + public async Task StoreAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new MySqlIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository.StoreAsync(key, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false) + ) + .Throws(); + } + + [Test] + public async Task TryReserveAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new MySqlIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository + .TryReserveAsync(key, DateTimeOffset.UtcNow, cancellationToken: cancellationToken) + .ConfigureAwait(false) + ) + .Throws(); + } } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerIdempotencyKeyRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerIdempotencyKeyRepositoryTests.cs index a950ed65..2a210bef 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerIdempotencyKeyRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerIdempotencyKeyRepositoryTests.cs @@ -4,6 +4,7 @@ namespace NetEvolve.Pulse.Tests.Unit.SqlServer; using System.Threading.Tasks; using Microsoft.Extensions.Options; using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility.Idempotency; using NetEvolve.Pulse.Idempotency; using TUnit.Core; @@ -198,4 +199,61 @@ await repository.StoreAsync(" ", DateTimeOffset.UtcNow, cancellationToken).Con ) .Throws(); } + + [Test] + public async Task ExistsAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new SqlServerIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository.ExistsAsync(key, cancellationToken: cancellationToken).ConfigureAwait(false) + ) + .Throws(); + } + + [Test] + public async Task StoreAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new SqlServerIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository.StoreAsync(key, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false) + ) + .Throws(); + } + + [Test] + public async Task TryReserveAsync_WithKeyLongerThanMaxLength_ThrowsArgumentException( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var repository = new SqlServerIdempotencyKeyRepository( + Options.Create(new IdempotencyKeyOptions { ConnectionString = ValidConnectionString }) + ); + var key = new string('k', IdempotencyKeySchema.MaxLengths.IdempotencyKey + 1); + + _ = await Assert + .That(async () => + await repository + .TryReserveAsync(key, DateTimeOffset.UtcNow, cancellationToken: cancellationToken) + .ConfigureAwait(false) + ) + .Throws(); + } }