Skip to content
Draft
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
Expand Up @@ -2171,6 +2171,143 @@ private async Task RunTypeFilterTest(IList<ExportJobFilter> filters, string reso
await _exportJobTask.ExecuteAsync(_exportJobRecord, _weakETag, _cancellationToken);
}

[Fact]
public async Task GivenAJobWithSubRanges_WhenExecuted_ThenAllRangesAreProcessedAndWrittenToSingleFile()
{
// Create a job record with 3 sub-ranges, each containing resources
var exportJobRecord = new ExportJobRecord(
new Uri("https://localhost/ExportJob/"),
ExportJobType.All,
ExportFormatTags.ResourceName,
resourceType: "Patient",
filters: null,
hash: "hash",
rollingFileSizeInMB: _exportJobConfiguration.RollingFileSizeInMB,
storageAccountConnectionHash: string.Empty,
storageAccountUri: _exportJobConfiguration.StorageAccountUri,
maximumNumberOfResourcesPerQuery: 10,
numberOfPagesPerCommit: _exportJobConfiguration.NumberOfPagesPerCommit,
startSurrogateId: "100",
endSurrogateId: "400",
globalStartSurrogateId: "1",
globalEndSurrogateId: "1000");

exportJobRecord.SurrogateIdRanges = new List<ExportSurrogateIdRange>
{
new ExportSurrogateIdRange("100", "200"),
new ExportSurrogateIdRange("201", "300"),
new ExportSurrogateIdRange("301", "400"),
};

SetupExportJobRecordAndOperationDataStore(exportJobRecord);
var exportJobTask = CreateExportJobTask();

int searchCallCount = 0;

// Each search call returns 2 resources, verifying that all 3 ranges are searched
_searchService.SearchAsync(
Arg.Any<string>(),
Arg.Any<IReadOnlyList<Tuple<string, string>>>(),
_cancellationToken,
true,
Arg.Any<ResourceVersionType>())
.Returns(x =>
{
searchCallCount++;
return CreateSearchResult(
new[]
{
CreateSearchResultEntry($"patient-{searchCallCount}a", KnownResourceTypes.Patient),
CreateSearchResultEntry($"patient-{searchCallCount}b", KnownResourceTypes.Patient),
});
});

await exportJobTask.ExecuteAsync(_exportJobRecord, _weakETag, _cancellationToken);

// Verify all 3 sub-ranges were searched
Assert.Equal(3, searchCallCount);

// Verify the job completed successfully
Assert.Equal(OperationStatus.Completed, _exportJobRecord.Status);

// Verify all 6 resources were written (2 per range * 3 ranges)
Assert.True(_exportJobRecord.Output.ContainsKey(KnownResourceTypes.Patient));
var totalCount = _exportJobRecord.Output[KnownResourceTypes.Patient].Sum(f => f.Count);
Assert.Equal(6, totalCount);

// Verify only 1 file was created (single file manager across all ranges)
Assert.Single(_exportJobRecord.Output[KnownResourceTypes.Patient]);

// Verify the sub-ranges list was cleared after processing
Assert.Empty(_exportJobRecord.SurrogateIdRanges);
}

[Fact]
public async Task GivenAJobWithSubRangesAndEmptyRange_WhenExecuted_ThenEmptyRangeIsSkippedAndOthersProcessed()
{
var exportJobRecord = new ExportJobRecord(
new Uri("https://localhost/ExportJob/"),
ExportJobType.All,
ExportFormatTags.ResourceName,
resourceType: "Patient",
filters: null,
hash: "hash",
rollingFileSizeInMB: _exportJobConfiguration.RollingFileSizeInMB,
storageAccountConnectionHash: string.Empty,
storageAccountUri: _exportJobConfiguration.StorageAccountUri,
maximumNumberOfResourcesPerQuery: 10,
numberOfPagesPerCommit: _exportJobConfiguration.NumberOfPagesPerCommit,
startSurrogateId: "100",
endSurrogateId: "400",
globalStartSurrogateId: "1",
globalEndSurrogateId: "1000");

exportJobRecord.SurrogateIdRanges = new List<ExportSurrogateIdRange>
{
new ExportSurrogateIdRange("100", "200"),
new ExportSurrogateIdRange("201", "300"), // This range will be empty
new ExportSurrogateIdRange("301", "400"),
};

SetupExportJobRecordAndOperationDataStore(exportJobRecord);
var exportJobTask = CreateExportJobTask();

int searchCallCount = 0;

_searchService.SearchAsync(
Arg.Any<string>(),
Arg.Any<IReadOnlyList<Tuple<string, string>>>(),
_cancellationToken,
true,
Arg.Any<ResourceVersionType>())
.Returns(x =>
{
searchCallCount++;

// Second range returns no results
if (searchCallCount == 2)
{
return CreateSearchResult();
}

return CreateSearchResult(
new[]
{
CreateSearchResultEntry($"patient-{searchCallCount}", KnownResourceTypes.Patient),
});
});

await exportJobTask.ExecuteAsync(_exportJobRecord, _weakETag, _cancellationToken);

// All 3 ranges were searched even though one was empty
Assert.Equal(3, searchCallCount);
Assert.Equal(OperationStatus.Completed, _exportJobRecord.Status);

// Only 2 resources exported (range 2 was empty)
var totalCount = _exportJobRecord.Output[KnownResourceTypes.Patient].Sum(f => f.Count);
Assert.Equal(2, totalCount);
}

private ExportJobRecord CreateExportJobRecord(
string requestEndpoint = "https://localhost/ExportJob/",
ExportJobType exportJobType = ExportJobType.All,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// -------------------------------------------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Threading;
Expand Down Expand Up @@ -154,6 +156,73 @@ public async Task GivenAnExportJob_WhenItFinishesAPageOfResultAndCanceledGroupJo
Assert.Equal(existingCanceledJob, queueClient.JobInfos[0]);
}

[Fact]
public async Task GivenAnExportJobWithSubRanges_WhenRun_ThenTaskReceivesRangesAndIsCalledOnce()
{
int callCount = 0;
IList<ExportSurrogateIdRange> capturedRanges = null;

IExportJobTask MakeMockJobTrackingRanges()
{
var mockJob = Substitute.For<IExportJobTask>();
mockJob.ExecuteAsync(Arg.Any<ExportJobRecord>(), Arg.Any<WeakETag>(), Arg.Any<CancellationToken>()).Returns(x =>
{
var record = x.ArgAt<ExportJobRecord>(0);
callCount++;
capturedRanges = record.SurrogateIdRanges?.ToList();
record.Status = OperationStatus.Completed;
return mockJob.UpdateExportJob(record, x.ArgAt<WeakETag>(1), x.ArgAt<CancellationToken>(2));
});
return mockJob;
}

var ranges = new List<ExportSurrogateIdRange>
{
new ExportSurrogateIdRange("100", "200"),
new ExportSurrogateIdRange("201", "300"),
new ExportSurrogateIdRange("301", "400"),
};

var jobRecord = new ExportJobRecord(
requestUri: new Uri("https://localhost/ExportJob/"),
exportType: ExportJobType.All,
exportFormat: ExportFormatTags.ResourceName,
resourceType: null,
filters: null,
hash: "hash",
rollingFileSizeInMB: 0,
startSurrogateId: "100",
endSurrogateId: "400");
jobRecord.SurrogateIdRanges = ranges;
jobRecord.Status = OperationStatus.Completed;
jobRecord.Id = string.Empty;

var jobInfo = GenerateJobInfo(JsonConvert.SerializeObject(jobRecord));

var processingJob = new ExportProcessingJob(MakeMockJobTrackingRanges, new TestQueueClient(), new NullLogger<ExportProcessingJob>());
await processingJob.ExecuteAsync(jobInfo, CancellationToken.None);

Assert.Equal(1, callCount);
Assert.NotNull(capturedRanges);
Assert.Equal(3, capturedRanges.Count);
Assert.Equal("100", capturedRanges[0].StartId);
Assert.Equal("200", capturedRanges[0].EndId);
Assert.Equal("201", capturedRanges[1].StartId);
Assert.Equal("300", capturedRanges[1].EndId);
Assert.Equal("301", capturedRanges[2].StartId);
Assert.Equal("400", capturedRanges[2].EndId);
}

[Fact]
public async Task GivenAnExportJobWithEmptySubRanges_WhenRun_ThenFallsBackToSingleExecution()
{
var expectedResults = GenerateJobRecord(OperationStatus.Completed);

var processingJob = new ExportProcessingJob(MakeMockJob, new TestQueueClient(), new NullLogger<ExportProcessingJob>());
var taskResult = await processingJob.ExecuteAsync(GenerateJobInfo(expectedResults), CancellationToken.None);
Assert.Equal(expectedResults, taskResult);
}

private string GenerateJobRecord(OperationStatus status, string failureReason = null, string resourceType = null, string feedRange = null)
{
var record = new ExportJobRecord(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ namespace Microsoft.Health.Fhir.Core.Configs
{
public class ExportJobConfiguration : HostingBackgroundServiceQueueItem
{
/// <summary>
/// Factor by which MaximumNumberOfResourcesPerQuery is divided to determine the per-sub-range
/// page size during export. Higher values produce smaller pages, reducing peak memory usage.
/// </summary>
public const int RangeReductionFactor = 500;

public ExportJobConfiguration()
{
Queue = QueueType.Export;
Expand Down
Loading
Loading