Skip to content

Fix duplicate SearchParameter URL creation - #5710

Merged
apurvabhaleMS merged 19 commits into
mainfrom
personal/abhale/duplicate-key-error
Aug 10, 2026
Merged

Fix duplicate SearchParameter URL creation#5710
apurvabhaleMS merged 19 commits into
mainfrom
personal/abhale/duplicate-key-error

Conversation

@apurvabhaleMS

@apurvabhaleMS apurvabhaleMS commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Description

CreateOrUpdateSearchParameterBehavior did not prevent creating a new SearchParameter resource 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:

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

Related issues

Addresses AB198804, AB187119

Testing

Describe how this change was tested.

FHIR Team Checklist

  • Update the title of the PR to be succinct and less than 65 characters
  • Add a milestone to the PR for the sprint that it is merged (i.e. add S47)
  • Tag the PR with the type of update: Bug, Build, Dependencies, Enhancement, New-Feature or Documentation
  • Tag the PR with Open source, Azure API for FHIR (CosmosDB or common code) or Azure Healthcare APIs (SQL or common code) to specify where this change is intended to be released.
  • Tag the PR with Schema Version backward compatible or Schema Version backward incompatible or Schema Version unchanged if this adds or updates Sql script which is/is not backward compatible with the code.
  • When changing or adding behavior, if your code modifies the system design or changes design assumptions, please create and include an ADR.
  • CI is green before merge Build Status
  • Review squash-merge requirements

Semver Change (docs)

Patch|Skip|Feature|Breaking (reason)

… 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.
@apurvabhaleMS apurvabhaleMS added Bug Bug bug bug. Area-SQL Area related to the SQL Server data provider Azure Healthcare APIs Label denotes that the issue or PR is relevant to the FHIR service in the Azure Healthcare APIs No-PaaS-breaking-change No-ADR ADR not needed labels Jul 31, 2026
@apurvabhaleMS apurvabhaleMS added this to the FY27\Q1\2wk\2wk03 milestone Jul 31, 2026
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.15%. Comparing base (ccd2246) to head (1690372).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

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

see 22 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@apurvabhaleMS
apurvabhaleMS marked this pull request as ready for review August 3, 2026 18:23
@apurvabhaleMS
apurvabhaleMS requested a review from a team as a code owner August 3, 2026 18:23
@apurvabhaleMS
apurvabhaleMS requested a review from Copilot August 3, 2026 18:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SetAndClearPendingSearchParameterStatus thread-safe by locking around the TryGetValue + Remove sequence.
  • 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.

Comment thread test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Reindex/ReindexTests.cs Outdated
Comment thread test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Reindex/ReindexTests.cs Outdated
- 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.
@apurvabhaleMS
apurvabhaleMS requested a lite review from Copilot August 6, 2026 16:08
@apurvabhaleMS apurvabhaleMS changed the title Fix race condition in SetAndClearPendingSearchParameterStatus Fix duplicate SearchParameter URL creation Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

@apurvabhaleMS
apurvabhaleMS requested a lite review from Copilot August 6, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@apurvabhaleMS
apurvabhaleMS requested review from SergeyGaluzo and a lite review from Copilot August 6, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: CreateCustomSearchParameter sets Id explicitly (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 Dictionary instance for every call. Returning a fresh dictionary per invocation (via a lambda-based Returns) 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>()));

@apurvabhaleMS
apurvabhaleMS merged commit 7e78105 into main Aug 10, 2026
48 checks passed
@apurvabhaleMS
apurvabhaleMS deleted the personal/abhale/duplicate-key-error branch August 10, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-SQL Area related to the SQL Server data provider Azure Healthcare APIs Label denotes that the issue or PR is relevant to the FHIR service in the Azure Healthcare APIs Bug Bug bug bug. No-ADR ADR not needed No-PaaS-breaking-change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants