Fix duplicate SearchParameter URL creation - #5710
Conversation
… SQL error 2627 ICM-833659983 / AB#198804 Root cause: TryGetValue + Remove on the shared HTTP request-context Properties dictionary were not atomic. Under parallel bundle processing, two concurrent threads could both read the same PendingSearchParameterStatus before either removed it, causing pendingStatuses = [URI, URI] in MergeAsync. This produced a duplicate row in the @searchParams TVP, violating the UNIQUE (Uri) constraint on dbo.SearchParamList and returning SQL error 2627 / HTTP 500. Fix: wrap TryGetValue + Remove in lock(properties) so only one thread can claim the pending status; subsequent threads see the key already removed. Tests added: - Unit: SetAndClearPendingSearchParameterStatus_WhenCalledConcurrently_Only OneResourceReceivesStatus (SqlServerFhirDataStoreUnitTests) — deterministic, proves race with Parallel.For, fails before fix / passes after. - E2E regression guard: GivenParallelTransactionBundleWithSearchParamAndPatients _WhenPosted_ShouldNotThrowUniqueKeyConstraint (ReindexTests) — probabilistic, runs in CI/EUAP.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5710 +/- ##
==========================================
- Coverage 77.74% 77.15% -0.60%
==========================================
Files 1007 1007
Lines 37173 37169 -4
Branches 5651 5657 +6
==========================================
- Hits 28902 28677 -225
- Misses 6896 7121 +225
+ Partials 1375 1371 -4 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR addresses a concurrency bug in the SQL Server data store where parallel bundle processing could race while claiming/clearing a pending SearchParameter status from the shared request-context Properties bag, leading to duplicate entries in the SQL TVP and unique constraint violations (SQL 2627) surfaced as HTTP 500s.
Changes:
- Make
SetAndClearPendingSearchParameterStatusthread-safe by locking around theTryGetValue+Removesequence. - Add a SQL Server unit test intended to validate that only one concurrent caller receives the pending status.
- Add an E2E regression test covering a parallel transaction bundle containing a SearchParameter plus many Patients.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirDataStore.cs |
Locks request-context properties to atomically claim and clear the pending SearchParameter status under parallel bundle execution. |
src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Storage/SqlServerFhirDataStoreUnitTests.cs |
Adds a concurrency-focused unit regression test for the pending status claim/clear behavior. |
test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Reindex/ReindexTests.cs |
Adds an E2E regression guard using a parallel transaction bundle scenario to detect the historical SQL unique constraint failure mode. |
- catch (Exception) instead of bare catch (CodeQL SA1025) - Assert bundle response NotNull + correct entry count before checking failures - Fail fast on reflection field-not-found instead of silent ?.SetValue - Remove DistinctBy from E2E XML doc comment (lock-only fix was applied) - Soften 'DETERMINISTIC' claim in unit test summary comment
…rallel coverage Finding 1 (medium): add code comment to SetAndClearPendingSearchParameterStatus documenting the pre-existing single-slot limitation — bundles with multiple SearchParameter entries can lose earlier statuses due to key overwrite before consume. Pre-existing behavior, tracked separately. Finding 2 (medium): update E2E test XML doc to explain why Transaction was chosen over Batch and confirm that both parallel paths are protected by the same lock fix via the shared SetAndClearPendingSearchParameterStatus code path. Finding 3 (low): no code change needed — the lock prevents duplicates regardless of URL length; the 128-char boundary is a TVP schema constraint unrelated to the concurrency fix.
…different resource A PUT with a new unique resource ID but an existing URL violates the 1-URL-per-resource invariant. Two resources sharing a URL causes SQL 2627 errors in bundle operations: DELETEs operate by resource ID and derive SearchParameter URLs at runtime — if two IDs share a URL, MergeAsync receives @searchParams TVP rows [URL, URL] → UNIQUE constraint violation. Fix: in CreateOrUpdateSearchParameterBehavior, when prevSearchParamResource is null (brand-new resource being PUT), reject if the URL is already registered in the SearchParameter definition manager. Note: this fix is complementary to the ICM-833659983 lock fix (concurrent SetAndClearPendingSearchParameterStatus race) committed earlier in this PR. With Bug 187119 fixed, duplicate URLs cannot enter the system via PUT, eliminating the DELETE-derived duplicate scenario. The race-condition lock remains as defence-in-depth for the parallel-bundle scenario.
…dingHardDelete) TryGetSearchParameter without excludePendingDelete returns ALL states including PendingHardDelete. The previous check would block legitimate recreation of a SearchParameter after hard-delete (the URL is still in the definition manager as PendingHardDelete until a reindex cleans it up). Only reject when the existing SP is in an active state (Supported, Disabled). Allow the PUT when the existing holder is PendingDelete or PendingHardDelete. This preserves the GivenBulkDeleteRequest_WhenSearchParametersDeleted test flow: HardDelete -> URL enters PendingHardDelete state -> PUT recreates the same SP.
PUT was already guarded. POST (CreateAsync) had no URL-uniqueness check, so posting a SearchParameter with a URL already owned by an active resource silently created a second resource, accumulating duplicate-URL pairs over repeated EUAP test runs. Root cause of the aaaa URL errors on EUAP: GivenAnExistingSearchParameter_WhenUpdatingWithUrlLongerThan128 PUTs a SearchParameter with Url = prefix + 88 * 'a' (128 chars, always the same) on every run but does not clean it up. After N runs, N resources share URL=aaaa. Any parallel delete bundle (e.g. ReindexTests.InitializeAsync DeleteResourcesAsync) derives URL=aaaa for each, hitting the TVP UNIQUE constraint (SQL 2627). Same guard as PUT: reject only when existing SP is in an active state (Supported / Disabled), allow when PendingDelete / PendingHardDelete.
…ated unit test The lock was added to prevent SQL 2627 caused by duplicate URIs in the @searchParams TVP. The actual root cause is Bug 187119: PUT/POST allowed creating a new SearchParameter resource with a URL already owned by a different active resource. With that fix in place, duplicate-URL resources can no longer accumulate, so the parallel-delete scenario that triggered the TVP violation cannot arise. Revert the lock and remove its unit test. The E2E regression guard in ReindexTests remains.
…rlLongerThan128 test Without cleanup, each test run left an active SearchParameter with URL=aaaa (128 chars, always the same) in the database. Accumulation over repeated EUAP runs meant N resources shared the same URL — the parallel delete bundles in ReindexTests.InitializeAsync would then fail with SQL 2627. Add a finally block to hard-delete the created SP so each run is self-contained.
…actor test helpers
…l form with char 'c'
… cleanup in URL-too-long test
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
src/Microsoft.Health.Fhir.Core/Features/Search/Parameters/CreateOrUpdateSearchParameterBehavior.cs:120
- The URL conflict check logic is duplicated in three places (create, upsert when URL changes, upsert when no previous version). Extracting this into a single private helper (e.g.,
ValidateUrlNotOwnedAsync(url, cancellationToken)) would reduce repetition and make future behavior changes (like excluding the current resource ID, if needed) safer and easier.
// Reject if an active resource already owns the new URL.
if (!string.IsNullOrWhiteSpace(newUrl))
{
var existingByNewUrl = await _searchParameterOperations.GetSearchParametersByUrlsAsync(new[] { newUrl }, cancellationToken);
if (existingByNewUrl.ContainsKey(newUrl))
{
throw new BadRequestException(string.Format(Core.Resources.SearchParameterDefinitionDuplicatedEntry, newUrl));
}
}
test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/CustomSearchParamTests.cs:90
- This comment is misleading:
CreateCustomSearchParametersetsIdexplicitly (client-generated), so it’s not “auto-generated” here. Consider rewording to something like “Different ID” / “Different resource” to reflect what the test is actually doing.
// Different auto-generated ID, same URL — must be rejected.
SearchParameter sp2 = CreateCustomSearchParameter(repeatChar: 'a');
src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchParameters/SearchParameterBehaviorTests.cs:74
- This returns the same mutable
Dictionaryinstance for every call. Returning a fresh dictionary per invocation (via a lambda-basedReturns) avoids accidental cross-test coupling if any test or code path ever mutates the returned dictionary.
_searchParameterOperations.GetSearchParametersByUrlsAsync(Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
.Returns(System.Threading.Tasks.Task.FromResult(new Dictionary<string, ITypedElement>()));
Description
CreateOrUpdateSearchParameterBehaviordid not prevent creating a newSearchParameterresource whose URL was already owned by a different active resource. This allowed duplicate URLs to accumulate across test runs, which caused SQL error 2627 (unique key constraint violation) when a DELETE bundle processed multiple resources sharing the same URL in parallel.Tests added:
Related issues
Addresses AB198804, AB187119
Testing
Describe how this change was tested.
FHIR Team Checklist
Semver Change (docs)
Patch|Skip|Feature|Breaking (reason)