fix(idempotency): store and compare idempotency keys exactly on SQL Server and MySQL - #923
Merged
Merged
Conversation
…egacy schema upgrade Adds failing tests for #850 and #858: keys longer than IdempotencyKeySchema.MaxLengths.IdempotencyKey must be rejected by the store and the SQL Server, MySQL and Entity Framework repositories, a key of maximum length must round-trip, keys that differ only by case must be distinct, the EF Core SQL Server and MySQL configurations must declare binary collations, and re-running the SQL Server and MySQL scripts must upgrade an existing key column.
…instead of truncating them IdempotencyKeySchema.MaxLengths.IdempotencyKey drops from 500 to 450, the largest NVARCHAR length that fits the 900-byte SQL Server clustered index key limit. IdempotencyStore and the Entity Framework repository reject longer keys with an ArgumentOutOfRangeException before any database call, so no provider can store a truncated key. Refs #850, #858
…ex key and compare keys by code point The key column and the stored procedure parameters are NVARCHAR(450) COLLATE Latin1_General_100_BIN2, and the repository binds the key with the central maximum length and rejects longer keys. Re-running IdempotencyKey.sql upgrades tables created by earlier releases in one transaction. The EF Core SQL Server configuration maps the same column type and collation. Closes #850
The key column uses the binary collation utf8mb4_bin, and re-running IdempotencyKey.sql switches existing case-insensitive columns. The repository replaces INSERT IGNORE, which stored truncated keys, with a plain INSERT that treats ER_DUP_ENTRY as an existing key, and rejects keys longer than the central maximum. The EF Core MySQL configuration sets the same collation. Closes #858
…te idempotency key check
…stgreSQL, SQLite and MySQL at 500
…0 for PostgreSQL, SQLite and MySQL Lowering the shared MaxLength to 450 changed the model snapshot of every EF provider and would force a migration (PendingModelChangesWarning on Migrate) even where the column type stays 500 wide. Only SQL Server and MySQL need a migration now.
…r-length key test
…able with a custom primary key name
… report it with a clear error
…omparison on the public contracts and READMEs
…es the binary collation on MySQL and SQL Server
…the Oracle MySQL provider MySql.EntityFrameworkCore ignores the relational collation from UseCollation and only reads its own MySQL:Collation annotation, so the key column was created with the case-insensitive database default.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #923 +/- ##
==========================================
+ Coverage 95.93% 95.96% +0.02%
==========================================
Files 276 276
Lines 12063 12133 +70
Branches 1150 1150
==========================================
+ Hits 11573 11643 +70
Misses 267 267
Partials 223 223 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Idempotency keys are now stored and compared exactly on SQL Server and MySQL.
Closes #850
Closes #858
Changes
IdempotencyKeySchema,IdempotencyStore)MaxLengths.IdempotencyKeydrops from 500 to 450.NVARCHARneeds 2 bytes per character, so 450 × 2 = 900 bytes.ExistsAsync,StoreAsyncandTryReserveAsyncreject a longer key withArgumentOutOfRangeException(anArgumentException) before any database call.NVARCHAR(450) COLLATE Latin1_General_100_BIN2.SqlParameters are bound with the central constant instead of the literal500. SqlClient used to cut longer values silently.IdempotencyKey.sqlupgrades an existing table. A guardedsys.columnscheck runs oneXACT_ABORTtransaction that dropsPK_<Table>, runsALTER COLUMNand re-creates the clustered PK.BEGIN TRY / BEGIN CATCH. On failure it rolls back and throws error 50001 with the table name and the original message, then resetsXACT_ABORT.nvarchar(450), derived from the constant, withUseCollation("Latin1_General_100_BIN2").utf8mb4_bin._bincollations "ordering is based on numeric character code values"._cicolumn. This uses theinformation_schema.columns+PREPAREguard from the accepted re-runnable MySQL scripts ADR. There is noDELIMITERand no;inside literals.INSERT IGNOREis replaced by a plainINSERT, andER_DUP_ENTRY(1062,MySqlErrorCode.DuplicateKeyEntry) counts as an existing key.IGNORE, invalid values are adjusted to the closest values and inserted".ON DUPLICATE KEY UPDATEwas rejected on purpose. MySql.Data reports found rows by default, so a duplicate also returns 1 affected row, andTryReserveAsyncfrom feat(idempotency): refresh expired idempotency keys atomically on reserve and store #909 would then report every duplicate as reserved.VARCHAR(500). Existing rows may be longer than 450 characters, and the core guard enforces the limit anyway.UseCollation("utf8mb4_bin")and the Oracle provider's ownMySQL:Collationannotation.MySql.EntityFrameworkCoreignores the relational collation, so without the annotation the column got the case-insensitive database default (found in CI).HasMaxLength(500)in the model, so their existing migrations stay in sync. Only SQL Server (type and PK) and MySQL (collation) need a migration.IdempotencyKeyentity docs are updated.Idempotency Keyssubsection (450-character limit,ArgumentOutOfRangeException, case-sensitive comparison). The Redis and PostgreSql READMEs link to it.IIdempotentCommand<TResponse>.IdempotencyKeyand theIIdempotencyStoremembers now state the limit and the case-sensitive comparison.decisions/2026-09-29-exact-idempotency-key-storage.md(state: proposed).IdempotencyTestsBase, which run for every provider:ArgumentOutOfRangeException.MySqlScriptRunnernow also rewritesALTER TABLE `X`.Bug hunt
IdempotencyTestsBase.Should_Store_And_Find_Key_Of_Max_Length(SqlServer ADO + EF),IdempotencyKeyConfigurationMetadataTests.Configure_WithSqlServerConfiguration_*(red locally)SqlServerIdempotencyKeyRepositoryTests.*_WithKeyLongerThanMaxLength_*INSERT IGNOREstores a truncated key and hides data errorsMySqlIdempotencyKeyRepositoryTests.*_WithKeyLongerThanMaxLength_*IdempotencyStoreTests.*_WithKeyLongerThanMaxLength_*,IdempotencyTestsBase.Should_Reject_Key_Longer_Than_Max_LengthEntityFrameworkIdempotencyKeyRepositoryTests.*_WithKeyLongerThanMaxLength_*utf8mb4_unicode_ciand the SQL Server default collation treataBc123=ABC123and cause false conflictsIdempotencyTestsBase.Should_Treat_Keys_Differing_Only_By_Case_As_Distinct(MySql/SqlServer, ADO + EF),Configure_WithMySqlConfiguration_UsesBinaryCollation(red locally)SqlServerAdoNetIdempotencyTests.Should_Upgrade_Legacy_Key_Column_When_Script_Is_Rerun,MySqlSchemaScriptTests.IdempotencyKeyScript_WhenKeyColumnIsCaseInsensitive_SwitchesToBinaryCollationINSERT IGNOREwithON DUPLICATE KEY UPDATE(as the issue suggested) would breakTryReserveAsync, because found-rows reports 1 for a duplicateINSERT+ 1062 is used insteadShould_Reserve_New_Key_Once/Should_Reserve_Once_When_Reserving_Same_Key_Concurrently(MySQL, CI)HasMaxLengthto 450 changes the model snapshot of PostgreSQL, SQLite and MySQL too, soMigrate()on EF 9+ reports pending model changes although their column types are unchangedIdempotencyKeyConfigurationMetadataTests.Configure_With{PostgreSql,Sqlite,MySql}Configuration_KeepsModelMaxLengthOfExistingMigrations(3 red, then green)Should_Reject_Key_Longer_Than_Max_Length(450-character prefix not stored) could never failArgumentOutOfRangeExceptionIdempotencyTestsBase.Should_Reject_Key_Longer_Than_Max_LengthTRY/CATCHXACT_ABORTalready rolled back. Before, the batch failed with Msg 3728; now it rolls back explicitly and throws 50001 with context. Runs in CI (Docker)SqlServerAdoNetIdempotencyTests.Should_Roll_Back_And_Report_Failed_Upgrade_When_Primary_Key_Name_DiffersMySql.EntityFrameworkCoreprovider does not emitCOLLATEfromUseCollationMySqlEntityFrameworkIdempotencyTests.Should_Treat_Keys_Differing_Only_By_Case_As_Distinctfailed on all TFMs) and locally:GenerateCreateScriptproducedvarchar(500) NOT NULL. Fixed with theMySQL:CollationannotationEntityFrameworkIdempotencyKeyCreateScriptTests.GenerateCreateScript_WithMySqlProvider_DeclaresBinaryKeyCollation(red, then green; no Docker needed), plus a SQL Server counterpart (green)Impact
ArgumentOutOfRangeException. Before, it worked on PostgreSQL, SQLite, Redis and InMemory, and failed or truncated on SQL Server and MySQL.IdempotencyKeySchema.MaxLengths.IdempotencyKeychanges from 500 to 450. It is aconst, so code compiled against the old package keeps 500 until it is recompiled.IdempotencyKey.sql. It upgrades existing tables in place.Migrate()reports pending model changes until they add it.MaxLength500, so they need no migration.=comparison, whatever the collation (= (String comparison)).utf8mb4_binis aPAD SPACEcollation.utf8mb4_0900_bin(NO PAD) would still differ from SQL Server, and MariaDB (Pomelo) lacks it.MySQL:Collationannotation, and the generated DDL is checked without Docker.IIdempotencyKeyRepositoryorIIdempotencyStoreimplementers. Only the XML docs now state the 450-character limit and the case-sensitive comparison.Test evidence
dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings.test(idempotency): ...before the fix:Should_Reject_Key_Longer_Than_Max_Lengthfailed on SQLite ADO and EF.dotnet test --project tests/NetEvolve.Pulse.Tests.Unit(net8.0, net9.0, net10.0): 7542 passed, 0 failed.SQLite*|InMemory*|EntityFrameworkIdempotencyKeyCreateScriptTests(net8.0, net9.0, net10.0): 915 passed, 27 skipped, 0 failed.KeepsModelMaxLengthOfExistingMigrationstests failed beforefix(entityframework): keep the idempotency key model max length at 500 ...and pass after it.csharpier check .is clean.