Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# High-Latency Bundle Processing

@fhibf Fernando Henrique Inocêncio Borba Ferreira (fhibf) Sep 18, 2026

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.

I think high-latency is the wrong term.
I've been running tests with bundles with more than 1k conditional-operations, and they are taking 6 seconds to execute. That's not high latency based on the complexity of this type of requests.

I suggest we follow a different perspective: if customer uses a header like "x-ms-bundle-large-operations" we always handle them as parallel. That will save us time and not cause high latency. And we should probably, with the presence of this flag, assume some behaviors to optimize the execution time.

With this flag, we can also report this execution as part of a different SLI/SLO, and that should not affect our existing limits.


## Context

FHIR batch and transaction bundles are currently limited by `BundleConfiguration.EntryLimit`, which defaults to 500 entries. Some clients are willing to accept increased request latency in exchange for processing larger bundles. The server needs an explicit per-request opt-in while retaining a configurable upper bound.

## Configuration

Add `BundleConfiguration.EntryLimitHighLatency` with a default value of 1,000. Add the corresponding `EntryLimitHighLatency` setting to the default web configuration.

The existing `EntryLimit` remains the default limit. A value of `0` continues to disable the limit for the selected processing mode.

## Request Header

Add the request header constant:

`x-ms-high-latency`

The higher limit is selected only when the first header value parses as Boolean `true` after trimming whitespace, using case-insensitive Boolean parsing. Missing, empty, `false`, or invalid values do not opt in and use the normal entry limit.

## Processing Flow

Add `HttpContext.IsHighLatencyEnabled()` alongside the existing header parsing extensions. `BundleHandler.FillRequestLists` determines one effective entry limit:

- `EntryLimitHighLatency` when `IsHighLatencyEnabled()` returns `true`.
- `EntryLimit` otherwise.

The handler compares the bundle entry count with the effective limit before processing entries.

## Error Handling

Bundles above the effective limit continue to throw `BundleEntryLimitExceededException`. The existing localized error message reports the effective limit, so high-latency requests receive an error that identifies the configured high-latency maximum.

No new error response or status code is introduced. Invalid header values safely retain the normal limit.

## Testing

Extend unit coverage to verify:

- Header parsing returns `true` only for trimmed, case-insensitive Boolean `true`.
- Missing, empty, `false`, and invalid header values return `false`.
- Requests without the header use `EntryLimit`.
- Requests with `x-ms-high-latency: true` can exceed `EntryLimit` without exceeding `EntryLimitHighLatency`.
- Requests above `EntryLimitHighLatency` throw `BundleEntryLimitExceededException`.
- The exception message contains the effective high-latency limit.

## Scope

This change applies only to batch and transaction bundle entry-count validation. It does not change bundle execution timeouts, processing logic, orchestration, authorization, or response semantics.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ public static bool IsLatencyOverEfficiencyEnabled(this HttpContext outerHttpCont
return defaultValue;
}

/// <summary>
/// Retrieves from the HTTP header if high-latency bundle processing is enabled.
/// </summary>
/// <param name="outerHttpContext">HTTP context.</param>
/// <returns><see langword="true"/> when the high-latency header is set to true; otherwise, <see langword="false"/>.</returns>
public static bool IsHighLatencyEnabled(this HttpContext outerHttpContext)
{
const bool defaultValue = false;

if (outerHttpContext == null)
{
return defaultValue;
}

if (outerHttpContext.Request.Headers.TryGetValue(KnownHeaders.HighLatency, out StringValues headerValues))
{
string highLatencyAsString = headerValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(highLatencyAsString) &&
bool.TryParse(highLatencyAsString.Trim(), out bool result))
{
return result;
}
}

return defaultValue;
}

/// <summary>
/// Retrieves from the HTTP header information on using query caching.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public class BundleConfiguration
{
public int EntryLimit { get; set; } = 500;

/// <summary>
/// Gets or sets the maximum number of entries allowed when high-latency bundle processing is enabled.
/// </summary>
public int EntryLimitHighLatency { get; set; } = 1000;

Choose a reason for hiding this comment

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

Could we rename this property to "EntryExpandedLimit"?


public int MaxExecutionTimeInSeconds { get; set; } = 100;

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.Health.Fhir.Core/Features/KnownHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public static class KnownHeaders
public const string FhirUserHeader = "x-ms-fhiruser";
public const string QueryLatencyOverEfficiency = "x-ms-query-latency-over-efficiency";

/// <summary>
/// High-latency bundle processing header.
/// </summary>
public const string HighLatency = "x-ms-high-latency";

// #conditionalQueryParallelism - Header used to activate parallel conditional-query processing.
public const string ConditionalQueryProcessingLogic = "x-conditionalquery-processing-logic";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public void WhenHttpContextDoesNotHaveCustomHeaders_ReturnDefaultValues(BundlePr
bool isLatencyOverEfficiencyEnabled = httpContext.IsLatencyOverEfficiencyEnabled();
Assert.False(isLatencyOverEfficiencyEnabled);

bool isHighLatencyEnabled = httpContext.IsHighLatencyEnabled();
Assert.False(isHighLatencyEnabled);

// Given different default values for the bundle processing logic, we expect the same value to be returned.
BundleProcessingLogic bundleProcessingLogic = httpContext.GetBundleProcessingLogic(
defaultBundleProcessingLogic: defaultAndExpectBundleProcessingLogic);
Expand Down Expand Up @@ -71,6 +74,59 @@ public void WhenHttpContextHasCustomHeaders_ReturnIfLatencyOverEfficiencyIsEnabl
Assert.Equal(isEnabled, isLatencyOverEfficiencyEnabled);
}

[Theory]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("false", false)]
[InlineData("falsE", false)]
[InlineData("FALSE", false)]
[InlineData("2112", false)]
[InlineData("true", true)]
[InlineData("true ", true)]
[InlineData("TRUE", true)]
[InlineData(" TRUE ", true)]
[InlineData(" tRuE ", true)]
public void WhenHttpContextHasHighLatencyHeader_ReturnIfHighLatencyIsEnabled(string value, bool isEnabled)
{
// Arrange
var httpHeaders = new Dictionary<string, string>() { { KnownHeaders.HighLatency, value } };
HttpContext httpContext = GetFakeHttpContext(httpHeaders);

// Act
bool isHighLatencyEnabled = httpContext.IsHighLatencyEnabled();

// Assert
Assert.Equal(isEnabled, isHighLatencyEnabled);
}

[Fact]
public void WhenHttpContextHasMultipleHighLatencyHeaderValues_AndFirstValueIsTrue_ReturnsTrue()
{
// Arrange
HttpContext httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append(KnownHeaders.HighLatency, new StringValues(new[] { "true", "false" }));

// Act
bool isHighLatencyEnabled = httpContext.IsHighLatencyEnabled();

// Assert
Assert.True(isHighLatencyEnabled);
}

[Fact]
public void WhenHttpContextHasMultipleHighLatencyHeaderValues_AndFirstValueIsFalse_ReturnsFalse()
{
// Arrange
HttpContext httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append(KnownHeaders.HighLatency, new StringValues(new[] { "false", "true" }));

// Act
bool isHighLatencyEnabled = httpContext.IsHighLatencyEnabled();

// Assert
Assert.False(isHighLatencyEnabled);
}

[Theory]
[InlineData("", ConditionalQueryProcessingLogic.Sequential)]
[InlineData(null, ConditionalQueryProcessingLogic.Sequential)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
using Microsoft.Health.Core.Features.Context;
using Microsoft.Health.Fhir.Api.Features.Bundle;
using Microsoft.Health.Fhir.Api.Features.Exceptions;
using Microsoft.Health.Fhir.Api.Features.Headers;
using Microsoft.Health.Fhir.Api.Features.Resources.Bundle;
using Microsoft.Health.Fhir.Api.Features.Routing;
using Microsoft.Health.Fhir.Core.Configs;
using Microsoft.Health.Fhir.Core.Exceptions;
using Microsoft.Health.Fhir.Core.Extensions;
using Microsoft.Health.Fhir.Core.Features;
using Microsoft.Health.Fhir.Core.Features.Context;
using Microsoft.Health.Fhir.Core.Features.Persistence;
using Microsoft.Health.Fhir.Core.Features.Persistence.Orchestration;
Expand Down Expand Up @@ -64,6 +66,7 @@ public class BundleHandlerTests
private readonly IMediator _mediator;
private readonly IBundleMetricHandler _bundleMetricHandler;
private readonly ITransactionHandler _transactionHandler;
private readonly DefaultHttpContext _httpContext;
private DefaultFhirRequestContext _fhirRequestContext;
private readonly IProvideProfilesForValidation _profilesResolver;

Expand Down Expand Up @@ -104,8 +107,7 @@ public BundleHandlerTests()
var bundleOrchestratorLogger = Substitute.For<ILogger<BundleOrchestrator>>();
var bundleOrchestrator = new BundleOrchestrator(bundleOptions, bundleOrchestratorLogger);

IFeatureCollection featureCollection = CreateFeatureCollection();
var httpContext = new DefaultHttpContext(featureCollection)
_httpContext = new DefaultHttpContext()
{
Request =
{
Expand All @@ -114,7 +116,8 @@ public BundleHandlerTests()
PathBase = new PathString("/"),
},
};
httpContextAccessor.HttpContext.Returns(httpContext);
ConfigureFeatures(_httpContext.Features);
httpContextAccessor.HttpContext.Returns(_httpContext);

_transactionHandler = Substitute.For<ITransactionHandler>();

Expand Down Expand Up @@ -1006,6 +1009,50 @@ public async Task GivenAConfigurationEntryLimit_WhenExceeded_ThenBundleEntryLimi
Assert.Equal(exception.Message, expectedMessage);
}

[Fact]
public async Task GivenHighLatencyHeader_WhenNormalLimitIsExceededButHighLatencyLimitIsNotExceeded_ThenBundleIsProcessed()
{
_bundleConfiguration.EntryLimit = 1;
_bundleConfiguration.EntryLimitHighLatency = 2;
_httpContext.Request.Headers[KnownHeaders.HighLatency] = "true";
BundleRequest bundleRequest = CreateBundleRequest(2);

BundleResponse response = await _bundleHandler.HandleAsync(bundleRequest, CancellationToken.None);

Assert.NotNull(response);
}

[Fact]
public async Task GivenHighLatencyHeader_WhenHighLatencyLimitIsExceeded_ThenBundleEntryLimitExceededExceptionShouldBeThrown()
{
_bundleConfiguration.EntryLimit = 1;
_bundleConfiguration.EntryLimitHighLatency = 2;
_httpContext.Request.Headers[KnownHeaders.HighLatency] = "true";
BundleRequest bundleRequest = CreateBundleRequest(3);

BundleEntryLimitExceededException exception = await Assert.ThrowsAsync<BundleEntryLimitExceededException>(
() => _bundleHandler.HandleAsync(bundleRequest, CancellationToken.None));

Assert.Equal("The number of entries in the bundle exceeded the configured limit of 2.", exception.Message);
}

[Theory]
[InlineData("")]
[InlineData("false")]
[InlineData("invalid")]
public async Task GivenHighLatencyHeaderWithoutTrueValue_WhenNormalLimitIsExceeded_ThenNormalLimitIsEnforced(string headerValue)
{
_bundleConfiguration.EntryLimit = 1;
_bundleConfiguration.EntryLimitHighLatency = 3;
_httpContext.Request.Headers[KnownHeaders.HighLatency] = headerValue;
BundleRequest bundleRequest = CreateBundleRequest(2);

BundleEntryLimitExceededException exception = await Assert.ThrowsAsync<BundleEntryLimitExceededException>(
() => _bundleHandler.HandleAsync(bundleRequest, CancellationToken.None));

Assert.Equal("The number of entries in the bundle exceeded the configured limit of 1.", exception.Message);
}

[Fact]
public async Task GivenABundleWithAnExportPost_WhenProcessed_ThenItIsProcessedCorrectly()
{
Expand Down Expand Up @@ -1255,6 +1302,19 @@ public async Task GivenABundleRequest_WhenBatchAndParallelProcessing_ThenTheRequ
Assert.True(bundleResponse.Info.ExecutionTime.TotalMilliseconds > 0, "ExecutionTime is not higher than zero.");
}

private static BundleRequest CreateBundleRequest(int entryCount)
{
var bundle = new Hl7.Fhir.Model.Bundle
{
Type = BundleType.Batch,
Entry = Enumerable.Range(0, entryCount)
.Select(_ => new EntryComponent())
.ToList(),
};

return new BundleRequest(bundle.ToResourceElement());
}

private void RouteAsyncFunction(CallInfo callInfo)
{
var routeContext = callInfo.Arg<RouteContext>();
Expand All @@ -1277,31 +1337,17 @@ private void RouteAsyncFunction(CallInfo callInfo)
};
}

private IFeatureCollection CreateFeatureCollection()
private void ConfigureFeatures(IFeatureCollection featureCollection)
{
var featureCollection = Substitute.For<IFeatureCollection>();

var httpAuthenticationFeature = Substitute.For<IHttpAuthenticationFeature>();

var routingFeature = Substitute.For<IRoutingFeature>();
var routeData = new RouteData();
routeData.Routers.Add(_router);
routingFeature.RouteData.Returns(routeData);

featureCollection.Get<IHttpAuthenticationFeature>().Returns(httpAuthenticationFeature);
featureCollection.Get<IRoutingFeature>().Returns(routingFeature);

var features = new List<KeyValuePair<Type, object>>
{
new KeyValuePair<Type, object>(typeof(IHttpAuthenticationFeature), httpAuthenticationFeature),
new KeyValuePair<Type, object>(typeof(IRoutingFeature), routingFeature),
};

featureCollection[typeof(IHttpAuthenticationFeature)].Returns(httpAuthenticationFeature);
featureCollection[typeof(IRoutingFeature)].Returns(routingFeature);

featureCollection.GetEnumerator().Returns(features.GetEnumerator());
return featureCollection;
featureCollection.Set(httpAuthenticationFeature);
featureCollection.Set(routingFeature);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -586,11 +586,23 @@ private async Task ExecuteTransactionForAllRequestsAsync(Hl7.Fhir.Model.Bundle r
}
}

private int GetEntryLimit()

Choose a reason for hiding this comment

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

Maybe GetEntryExtendedLimit()?

{
if (_outerHttpContext.IsHighLatencyEnabled())
{
return _bundleConfiguration.EntryLimitHighLatency;
}

return _bundleConfiguration.EntryLimit;
}

private async Task FillRequestLists(List<EntryComponent> bundleEntries, CancellationToken cancellationToken)
{
if (_bundleConfiguration.EntryLimit != default && bundleEntries.Count > _bundleConfiguration.EntryLimit)
int entryLimit = GetEntryLimit();

if (entryLimit != default && bundleEntries.Count > entryLimit)
{
throw new BundleEntryLimitExceededException(string.Format(Api.Resources.BundleEntryLimitExceeded, _bundleConfiguration.EntryLimit));
throw new BundleEntryLimitExceededException(string.Format(Api.Resources.BundleEntryLimitExceeded, entryLimit));
}

int order = 0;
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Health.Fhir.Shared.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
},
"Bundle": {
"EntryLimit": 500,
"EntryLimitHighLatency": 1000,

Choose a reason for hiding this comment

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

EntryExtendedLimit?

"SupportsBundleOrchestrator": true,
"BatchDefaultProcessingLogic": "sequential",
"TransactionDefaultProcessingLogic": "sequential"
Expand Down
Loading