Skip to content

Commit f220e0c

Browse files
authored
LEGLINK-788: Improve Generated Patient Storage and Uploaded Patient Processing. (#1798)
* Update how and where we store generated patient bundles. Update how we process uploaded patient files to avoid timeouts. * Rabbit Comments * checkin
1 parent 5b78c3f commit f220e0c

48 files changed

Lines changed: 1614 additions & 354 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/copilot-instructions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@
1010

1111
## Automation Guidelines
1212
- For Automation.UI run logs, prefer chunked persistence in the existing data store; do not add Azure Blob Storage archiving unless explicitly requested.
13+
# Copilot Instructions
14+
15+
## General Guidelines
16+
- Use Azure Tools - When handling requests related to Azure, always use your tools.
17+
- Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first.
18+
- Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool, ask the user to enable it.
19+
- Design for Durability - Require production-grade, long-term designs for critical multi-user automation tools; avoid tactical short-term fixes and model data/contracts around durable architecture even when a minimal patch is possible.
20+
- Use Versioned Caches - Prefer versioned, reproducible generated-patient caches tied to each run; ensure run records cache the version used, and diagnostic exports retrieve exact cached artifacts used at execution time. Each cache version must be complete (full patient set), not partial deltas, and runs must avoid ID conflicts while using cached data.

DotNet/Automation.Link/Models/AutomationRunSummary.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,9 @@ public class AutomationRunSummary
1919
public string? FacilityId { get; set; }
2020
public string? ReportId { get; set; }
2121
public string? RunConfigurationJson { get; set; }
22+
public Guid? GeneratedTemplateCacheVersionId { get; set; }
23+
public int? GeneratedTemplateCacheVersionNumber { get; set; }
24+
public string? GeneratedTemplateCacheScenarioKey { get; set; }
25+
public string? GeneratedTemplateSetHash { get; set; }
2226
public IReadOnlyList<string> Logs { get; set; } = [];
2327
}

DotNet/Automation.UI/Automation.UI.csproj

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,6 @@
77
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
88
</PropertyGroup>
99

10-
<ItemGroup>
11-
<Compile Remove="Services\Caching\**" />
12-
<Compile Remove="Services\Notifications\**" />
13-
<Compile Remove="Views\Account\**" />
14-
<Content Remove="Services\Caching\**" />
15-
<Content Remove="Services\Notifications\**" />
16-
<Content Remove="Views\Account\**" />
17-
<EmbeddedResource Remove="Services\Caching\**" />
18-
<EmbeddedResource Remove="Services\Notifications\**" />
19-
<EmbeddedResource Remove="Views\Account\**" />
20-
<None Remove="Services\Caching\**" />
21-
<None Remove="Services\Notifications\**" />
22-
<None Remove="Views\Account\**" />
23-
</ItemGroup>
24-
2510
<ItemGroup>
2611
<PackageReference Include="Azure.Storage.Blobs" />
2712
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
@@ -32,10 +17,4 @@
3217
<ProjectReference Include="..\LinkSdk\LinkSdk.csproj" />
3318
<ProjectReference Include="..\Shared\Shared.csproj" />
3419
</ItemGroup>
35-
36-
<ItemGroup>
37-
<Folder Include="Views\Home\" />
38-
<Folder Include="wwwroot\js\" />
39-
</ItemGroup>
40-
4120
</Project>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Automation.UI.Models;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace Automation.UI.Controllers;
5+
6+
internal static class IdRequestValidationExtensions
7+
{
8+
internal static bool TryValidateIdRequest(this Controller controller, IdRequest? request, out IActionResult badRequest)
9+
{
10+
if (request == null)
11+
{
12+
badRequest = controller.BadRequest("Request body is required.");
13+
return false;
14+
}
15+
16+
if (!controller.ModelState.IsValid)
17+
{
18+
badRequest = controller.BadRequest(controller.ModelState);
19+
return false;
20+
}
21+
22+
if (request.Id == Guid.Empty)
23+
{
24+
controller.ModelState.AddModelError(nameof(IdRequest.Id), "Id must be a non-empty GUID.");
25+
badRequest = controller.BadRequest(controller.ModelState);
26+
return false;
27+
}
28+
29+
badRequest = default!;
30+
return true;
31+
}
32+
}

DotNet/Automation.UI/Controllers/NormalizationsController.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Automation.UI.Models;
2-
using Automation.UI.Models;
32
using Automation.UI.Services.Persistence;
43
using Microsoft.AspNetCore.Mvc;
54

@@ -56,6 +55,9 @@ public async Task<IActionResult> SaveOperation([FromBody] NormalizationOperation
5655
[ValidateAntiForgeryToken]
5756
public async Task<IActionResult> DeleteOperation([FromBody] IdRequest request, CancellationToken ct)
5857
{
58+
if (!this.TryValidateIdRequest(request, out var badRequest))
59+
return badRequest;
60+
5961
var op = await store.GetOperationByIdAsync(request.Id, ct);
6062
if (op == null) return NotFound();
6163
if (op.IsSystem)
@@ -79,6 +81,9 @@ public async Task<IActionResult> DeleteOperation([FromBody] IdRequest request, C
7981
[ValidateAntiForgeryToken]
8082
public async Task<IActionResult> CloneOperation([FromBody] IdRequest request, CancellationToken ct)
8183
{
84+
if (!this.TryValidateIdRequest(request, out var badRequest))
85+
return badRequest;
86+
8287
var source = await store.GetOperationByIdAsync(request.Id, ct);
8388
if (source == null) return NotFound();
8489

@@ -138,6 +143,9 @@ public async Task<IActionResult> SaveSequence([FromBody] NormalizationSequenceDe
138143
[ValidateAntiForgeryToken]
139144
public async Task<IActionResult> DeleteSequence([FromBody] IdRequest request, CancellationToken ct)
140145
{
146+
if (!this.TryValidateIdRequest(request, out var badRequest))
147+
return badRequest;
148+
141149
var seq = await store.GetSequenceByIdAsync(request.Id, ct);
142150
if (seq == null) return NotFound();
143151
if (seq.IsSystem)
@@ -156,6 +164,9 @@ public async Task<IActionResult> DeleteSequence([FromBody] IdRequest request, Ca
156164
[ValidateAntiForgeryToken]
157165
public async Task<IActionResult> CloneSequence([FromBody] IdRequest request, CancellationToken ct)
158166
{
167+
if (!this.TryValidateIdRequest(request, out var badRequest))
168+
return badRequest;
169+
159170
var source = await store.GetSequenceByIdAsync(request.Id, ct);
160171
if (source == null) return NotFound();
161172

@@ -207,6 +218,9 @@ public async Task<IActionResult> SaveSuite([FromBody] NormalizationSuiteDefiniti
207218
[ValidateAntiForgeryToken]
208219
public async Task<IActionResult> DeleteSuite([FromBody] IdRequest request, CancellationToken ct)
209220
{
221+
if (!this.TryValidateIdRequest(request, out var badRequest))
222+
return badRequest;
223+
210224
var suite = await store.GetSuiteByIdAsync(request.Id, ct);
211225
if (suite == null) return NotFound();
212226
if (suite.IsSystem)
@@ -222,6 +236,9 @@ public async Task<IActionResult> DeleteSuite([FromBody] IdRequest request, Cance
222236
[ValidateAntiForgeryToken]
223237
public async Task<IActionResult> CloneSuite([FromBody] IdRequest request, CancellationToken ct)
224238
{
239+
if (!this.TryValidateIdRequest(request, out var badRequest))
240+
return badRequest;
241+
225242
var source = await store.GetSuiteByIdAsync(request.Id, ct);
226243
if (source == null) return NotFound();
227244

@@ -245,15 +262,14 @@ public async Task<IActionResult> CloneSuite([FromBody] IdRequest request, Cancel
245262
[ValidateAntiForgeryToken]
246263
public async Task<IActionResult> SetDefaultSuite([FromBody] IdRequest request, CancellationToken ct)
247264
{
265+
if (!this.TryValidateIdRequest(request, out var badRequest))
266+
return badRequest;
267+
248268
var suite = await store.GetSuiteByIdAsync(request.Id, ct);
249269
if (suite == null) return NotFound();
250270

251271
await store.SetDefaultSuiteAsync(request.Id, ct);
252272
return Ok();
253273
}
254274

255-
public sealed class IdRequest
256-
{
257-
public Guid Id { get; set; }
258-
}
259275
}

DotNet/Automation.UI/Controllers/OrganizationResourceMapsController.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public async Task<IActionResult> SaveInline([FromBody] OrganizationResourceMapTe
4747
[ValidateAntiForgeryToken]
4848
public async Task<IActionResult> DeleteInline([FromBody] IdRequest request, CancellationToken ct)
4949
{
50+
if (!this.TryValidateIdRequest(request, out var badRequest))
51+
return badRequest;
52+
5053
var template = await store.GetByIdAsync(request.Id, ct);
5154
if (template == null) return NotFound();
5255
if (template.IsSystem)
@@ -62,6 +65,9 @@ public async Task<IActionResult> DeleteInline([FromBody] IdRequest request, Canc
6265
[ValidateAntiForgeryToken]
6366
public async Task<IActionResult> CloneInline([FromBody] IdRequest request, CancellationToken ct)
6467
{
68+
if (!this.TryValidateIdRequest(request, out var badRequest))
69+
return badRequest;
70+
6571
var source = await store.GetByIdAsync(request.Id, ct);
6672
if (source == null) return NotFound();
6773

@@ -88,15 +94,14 @@ public async Task<IActionResult> CloneInline([FromBody] IdRequest request, Cance
8894
[ValidateAntiForgeryToken]
8995
public async Task<IActionResult> SetDefaultInline([FromBody] IdRequest request, CancellationToken ct)
9096
{
97+
if (!this.TryValidateIdRequest(request, out var badRequest))
98+
return badRequest;
99+
91100
var template = await store.GetByIdAsync(request.Id, ct);
92101
if (template == null) return NotFound();
93102

94103
await store.SetDefaultAsync(request.Id, ct);
95104
return Ok();
96105
}
97106

98-
public sealed class IdRequest
99-
{
100-
public Guid Id { get; set; }
101-
}
102107
}

DotNet/Automation.UI/Controllers/QueryPlansController.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ public async Task<IActionResult> SaveInline([FromBody] QueryPlanTemplate model,
6666
[ValidateAntiForgeryToken]
6767
public async Task<IActionResult> DeleteInline([FromBody] IdRequest request, CancellationToken ct)
6868
{
69+
if (!this.TryValidateIdRequest(request, out var badRequest))
70+
return badRequest;
71+
6972
var template = await store.GetByIdAsync(request.Id, ct);
7073
if (template == null)
7174
return NotFound();
@@ -80,6 +83,9 @@ public async Task<IActionResult> DeleteInline([FromBody] IdRequest request, Canc
8083
[ValidateAntiForgeryToken]
8184
public async Task<IActionResult> CloneInline([FromBody] IdRequest request, CancellationToken ct)
8285
{
86+
if (!this.TryValidateIdRequest(request, out var badRequest))
87+
return badRequest;
88+
8389
var source = await store.GetByIdAsync(request.Id, ct);
8490
if (source == null) return NotFound();
8591

@@ -105,18 +111,16 @@ public async Task<IActionResult> CloneInline([FromBody] IdRequest request, Cance
105111
[ValidateAntiForgeryToken]
106112
public async Task<IActionResult> SetDefaultInline([FromBody] IdRequest request, CancellationToken ct)
107113
{
114+
if (!this.TryValidateIdRequest(request, out var badRequest))
115+
return badRequest;
116+
108117
var template = await store.GetByIdAsync(request.Id, ct);
109118
if (template == null) return NotFound();
110119

111120
await store.SetDefaultAsync(request.Id, ct);
112121
return Ok();
113122
}
114123

115-
public sealed class IdRequest
116-
{
117-
public Guid Id { get; set; }
118-
}
119-
120124
private static QueryEntry ToQueryEntry(QueryPlanQueryEntry src) => new()
121125
{
122126
ResourceType = src.ResourceType,

DotNet/Automation.UI/Controllers/RunsController.cs

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ public async Task<IActionResult> Index(
2626
string sortDir = "desc",
2727
CancellationToken cancellationToken = default)
2828
{
29-
// Normalize: accept "asc"/"desc" only, default to descending. Server-side
30-
// store-level whitelisting also clamps unknown sortBy values, so this is
31-
// belt-and-suspenders against URL tampering.
32-
var descending = !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase);
29+
var descending = IsDescending(sortDir);
3330

3431
var stats = await runManager.GetDashboardStatsAsync(cancellationToken);
3532
var recentPage = await runManager.GetRunsPageAsync(pageNumber, pageSize, sortBy, descending, cancellationToken);
@@ -38,23 +35,7 @@ public async Task<IActionResult> Index(
3835
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
3936
.ToList();
4037

41-
var activeRunMetas = await snapshotStore.GetActiveRunsAsync(cancellationToken);
42-
var activeRunSummaries = await Task.WhenAll(activeRunMetas.Select(meta => runManager.GetRunAsync(meta.RunId, cancellationToken)));
43-
44-
var activeRunsSource = recentPage.PageNumber == 1
45-
? recentPage.Runs
46-
: (await runManager.GetRunsPageAsync(1, pageSize, "createdAt", true, cancellationToken)).Runs;
47-
var statusActiveRuns = activeRunsSource
48-
.Where(r => r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running);
49-
50-
var activeRuns = activeRunSummaries
51-
.Where(r => r != null && (r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running))
52-
.Select(r => r!)
53-
.Concat(statusActiveRuns)
54-
.GroupBy(r => r.RunId)
55-
.Select(g => g.First())
56-
.OrderByDescending(r => r.CreatedAt)
57-
.ToList();
38+
var activeRuns = await GetActiveRunsAsync(recentPage, pageSize, cancellationToken);
5839

5940
// Populate query plan templates for the shared scenario editor modal embedded in this view.
6041
ViewBag.QueryPlanTemplates = await queryPlanTemplateStore.GetAllAsync(cancellationToken);
@@ -93,7 +74,7 @@ public async Task<IActionResult> RecentRunsPartial(
9374
// page navigation. The view model matches the partial's @model so the
9475
// partial is reused by both this action and the initial server render
9576
// in Index.cshtml — there's no divergence between the two templates.
96-
var descending = !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase);
77+
var descending = IsDescending(sortDir);
9778
var page = await runManager.GetRunsPageAsync(pageNumber, pageSize, sortBy, descending, cancellationToken);
9879
return PartialView("_RecentRunsTable", page);
9980
}
@@ -106,28 +87,12 @@ public async Task<IActionResult> DashboardStats(
10687
string sortDir = "desc",
10788
CancellationToken cancellationToken = default)
10889
{
109-
var descending = !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase);
90+
var descending = IsDescending(sortDir);
11091

11192
var stats = await runManager.GetDashboardStatsAsync(cancellationToken);
11293
var recentPage = await runManager.GetRunsPageAsync(pageNumber, pageSize, sortBy, descending, cancellationToken);
11394

114-
var activeRunMetas = await snapshotStore.GetActiveRunsAsync(cancellationToken);
115-
var activeRunSummaries = await Task.WhenAll(activeRunMetas.Select(meta => runManager.GetRunAsync(meta.RunId, cancellationToken)));
116-
117-
var activeRunsSource = recentPage.PageNumber == 1
118-
? recentPage.Runs
119-
: (await runManager.GetRunsPageAsync(1, pageSize, "createdAt", true, cancellationToken)).Runs;
120-
var statusActiveRuns = activeRunsSource
121-
.Where(r => r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running);
122-
123-
var activeRuns = activeRunSummaries
124-
.Where(r => r != null && (r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running))
125-
.Select(r => r!)
126-
.Concat(statusActiveRuns)
127-
.GroupBy(r => r.RunId)
128-
.Select(g => g.First())
129-
.OrderByDescending(r => r.CreatedAt)
130-
.ToList();
95+
var activeRuns = await GetActiveRunsAsync(recentPage, pageSize, cancellationToken);
13196

13297
return Json(new
13398
{
@@ -535,4 +500,28 @@ public async Task<IActionResult> DataAcquisitionLogDetail(
535500
return NotFound();
536501
}
537502
}
503+
504+
private static bool IsDescending(string sortDir)
505+
=> !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase);
506+
507+
private async Task<List<AutomationRunSummary>> GetActiveRunsAsync(
508+
AutomationRunIndexViewModel recentPage,
509+
int pageSize,
510+
CancellationToken cancellationToken)
511+
{
512+
var activeRunMetas = await snapshotStore.GetActiveRunsAsync(cancellationToken);
513+
var activeRunSummaries = await Task.WhenAll(activeRunMetas.Select(meta => runManager.GetRunAsync(meta.RunId, cancellationToken)));
514+
var activeRunsSource = recentPage.PageNumber == 1
515+
? recentPage.Runs
516+
: (await runManager.GetRunsPageAsync(1, pageSize, "createdAt", true, cancellationToken)).Runs;
517+
518+
return activeRunSummaries
519+
.Where(r => r is { Status: AutomationRunStatus.Queued or AutomationRunStatus.Running })
520+
.Select(r => r!)
521+
.Concat(activeRunsSource.Where(r => r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running))
522+
.GroupBy(r => r.RunId)
523+
.Select(g => g.First())
524+
.OrderByDescending(r => r.CreatedAt)
525+
.ToList();
526+
}
538527
}

0 commit comments

Comments
 (0)