Skip to content

Fix thread-safety issue with SerializationFilter when reusing JsonSerializerOptions #3218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 24, 2025

Conversation

Copilot
Copy link
Contributor

@Copilot Copilot AI commented Jul 16, 2025

Problem

The Firely documentation recommends reusing JsonSerializerOptions instances for performance, but this caused thread-safety issues when using SerializationFilter.ForElements(), ForText(), and ForCount() in concurrent scenarios. Multiple threads would interfere with each other's filter state, leading to:

  • Missing Bundle.entry elements in serialized output
  • Incorrect filtering (unfiltered fields appearing or filtered fields missing)
  • Inconsistent results across concurrent operations

Reproduction case:

var options = new JsonSerializerOptions()
    .ForFhir(new FhirJsonPocoSerializerSettings { 
        SummaryFilter = SerializationFilter.ForElements(["id", "active"]) 
    });

// Concurrent serialization produces inconsistent results
Parallel.For(0, 100, i => {
    var json = JsonSerializer.Serialize(bundle, options);
    // Sometimes missing entries, sometimes unfiltered fields present
});

Root Cause

SerializationFilter instances contain mutable state variables (_nestingDepth, _childrenFromDepth, etc.) that track filtering context during serialization. When the same JsonSerializerOptions instance is shared across threads, these filter instances create race conditions.

Solution

Implemented a ThreadLocal-based factory pattern that ensures thread safety while maintaining performance:

  1. New thread-safe factory methods:

    • SerializationFilter.CreateElementsFactory(string[] elements)
    • SerializationFilter.CreateTextFactory()
    • SerializationFilter.CreateCountFactory()
    • SerializationFilter.CreateSummaryFactory()
    • SerializationFilter.CreateDataFactory()
  2. New SummaryFilterFactory property in FhirJsonPocoSerializerSettings

  3. Backward compatibility: Existing SummaryFilter property still works (marked as obsolete)

New thread-safe usage:

var options = new JsonSerializerOptions()
    .ForFhir(new FhirJsonPocoSerializerSettings { 
        SummaryFilterFactory = SerializationFilter.CreateElementsFactory(["id", "active"]) 
    });

// Now safe for concurrent use - each thread gets its own filter instance
Parallel.For(0, 100, i => {
    var json = JsonSerializer.Serialize(bundle, options); // ✅ Consistent results
});

Technical Details

The factory functions use ThreadLocal<T> to ensure:

  • Thread isolation: Each thread gets its own filter instance
  • State consistency: Same instance used throughout a single serialization operation
  • Performance: No overhead from excessive object creation

Testing

  • Added comprehensive thread-safety tests covering all filter types
  • Verified 100+ concurrent operations produce identical results
  • All existing tests continue to pass
  • Maintains full backward compatibility

Fixes #3214.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits July 16, 2025 21:15
Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com>
Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com>
@Copilot Copilot AI changed the title [WIP] Serializing with a reused JsonSerializerOptions instance with SerializationFilter.ForElements produces incorrect results in high concurrency Fix thread-safety issue with SerializationFilter when reusing JsonSerializerOptions Jul 16, 2025
@Copilot Copilot AI requested a review from ewoutkramer July 16, 2025 21:35
Copilot finished work on behalf of ewoutkramer July 16, 2025 21:35
ewoutkramer and others added 2 commits July 22, 2025 11:06
…them immediately

Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com>
@Copilot Copilot AI requested a review from ewoutkramer July 22, 2025 09:16
Copilot finished work on behalf of ewoutkramer July 22, 2025 09:16
@ewoutkramer
Copy link
Member

@copilot: Yesterday, @almostchristian made the next remarks:

From what I saw in copilot's pull request, the SerializationFilter will still be reused which brings about some concerns like:

  • Can we guarantee the filter's state is reset properly after/before use? Maybe there should be a SerializationFilter.Reset() method, similar to the IEnumerator.Reset()?
  • Do we need to reuse the filter instance at all, why not have the factory create a new instance every time serializer needs one?
  • If we don't reuse, will memory allocations be a concern without resorting object pooling? From what I know, dotnet has some optimizations that avoids heap allocations for short-lived reference types, but not sure if it would apply without benchmarking to solution.

I think these make sense. If you look at FhirJsonConverterFactory, you'll see that we're

  • Still calling the old SummaryFilter property, so this needs to be fixed
  • When calling a new serialization on the same thread, we'll get the same filter instance for the same thread. Now I know that this exactly mirrors the existing behaviour (although there this was true for ALL threads), but it does worry me that we reuse the instance for a new serialization run while using the same filter and thus state.

So, as suggested, I think the converter factory should make sure we really create a fresh instance of the filter per serialization run (as you have discovered, we need to hold on to the same instance of the filter within a run). I don't know whether CreateConverter() from the System.Text.Json framework will be called for each serialization run, or that it is also reused, but we need to make sure we're getting a fresh instance per serialization.

…n run to prevent state reuse across operations

Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jul 22, 2025

@copilot: Yesterday, @almostchristian made the next remarks:

From what I saw in copilot's pull request, the SerializationFilter will still be reused which brings about some concerns like:
...

Fixed the thread-safety implementation as requested. The factory methods now create fresh filter instances for each serialization run instead of using ThreadLocal to cache instances per thread.

Key changes in commit 4b28378:

  • Removed ThreadLocal approach that was reusing filter instances across multiple serialization operations on the same thread
  • Modified factory methods to return new instances on each call
  • Updated serialization logic to pass filter instances through recursive calls to maintain state consistency within a single serialization operation
  • Updated tests to verify fresh instances are created per call

This ensures no filter state is shared between serialization operations while maintaining proper state consistency within each operation.

Copilot finished work on behalf of ewoutkramer July 22, 2025 10:08
@ewoutkramer
Copy link
Member

Updated serialization logic to pass filter instances through recursive calls to maintain state consistency within a single serialization operatio

Interestingly, this is the approach I took in SDK6 as well, so when we're merging SDK5->SDK6, we'll have to handle the merge manually.

@ewoutkramer
Copy link
Member

@copilot, this needs to be done on the XmlSerializer too, however it turns out that (since we don't have these re-used JsonOptions) the problem is less severe there: the public serialization functions on BaseFhirXmlPocoSerializer already have a SummaryFilter as argument, and this is passed down to all internal serialization functions, fixing the problem we had in the json serializer.

However, it is probably unclear to the user that he/she needs to pass in a fresh instance on every serialize. So, we should make sure these public serialization methods also take a FilterFactory as argument, and obsolete the original serialization methods (we need to stay backwards compatible). Avoid code duplication between the obsoleted and the new methods.

@ewoutkramer ewoutkramer marked this pull request as ready for review July 22, 2025 10:46
- Add new overloads to BaseFhirXmlPocoSerializer.Serialize() and SerializeToString() that take Func<SerializationFilter> factory parameter
- Mark existing methods taking SerializationFilter as obsolete with clear migration guidance
- Update internal calls in PocoSerializationEngine_Xml to use new thread-safe methods
- Add comprehensive tests verifying factory methods work correctly and produce identical output to obsolete methods
- Ensure filter factories create fresh instances per call for thread safety

Addresses XML serialization thread-safety concerns similar to JSON serialization fixes

Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jul 22, 2025

@copilot, this needs to be done on the XmlSerializer too, however it turns out that (since we don't have these re-used JsonOptions) the problem is less severe there: the public serialization functions...

Implemented the XML serialization changes as requested. Added new overloads to BaseFhirXmlPocoSerializer methods that take Func<SerializationFilter> factory parameters for thread-safety, marked existing methods as obsolete with clear migration guidance, and ensured no code duplication between old and new methods. All existing tests pass and new tests verify the factory functionality works correctly. Commit 8624c25.

Updated serialization logic to pass filter instances through recursive calls to maintain state consistency within a single serialization operatio

Interestingly, this is the approach I took in SDK...

Good to know about the SDK6 approach. The pattern of passing filter instances through recursive calls ensures state consistency within each serialization operation while avoiding shared state between operations.

Copilot finished work on behalf of ewoutkramer July 22, 2025 10:55
@mmsmits mmsmits requested review from Kasdejong and removed request for andrzejskowronski July 23, 2025 08:08
Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com>
@Copilot Copilot AI requested a review from alexzautke July 23, 2025 19:59
Copilot finished work on behalf of alexzautke July 23, 2025 19:59
@Kasdejong
Copy link
Member

I wonder how this will merge into SDK-6...

@ewoutkramer
Copy link
Member

That's exactly why I created #3223, which specifically mentions this to make sure we do the right thing there ;-)

@ewoutkramer ewoutkramer enabled auto-merge July 24, 2025 10:07
@ewoutkramer ewoutkramer merged commit af8e210 into develop Jul 24, 2025
17 checks passed
@ewoutkramer ewoutkramer deleted the copilot/fix-3214 branch July 24, 2025 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Serializing with a reused JsonSerializerOptions instance with SerializationFilter.ForElements produces incorrect results in high concurrency
4 participants