Skip to content

Commit 8f04f57

Browse files
committed
Rabbit Comments and restest
1 parent 8f1612a commit 8f04f57

11 files changed

Lines changed: 546 additions & 132 deletions

File tree

.github/copilot-instructions.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
# Copilot Instructions
22

33
## Azure Guidelines
4-
- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools.
5-
- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first.
6-
- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool, ask the user to enable it.
4+
- Use Azure Tools - When handling requests related to Azure, always use your tools.
5+
- Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first.
6+
- Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool, ask the user to enable it.
77

88
## Cosmos DB Guidelines
99
- Avoid introducing new indexes on existing collections in Cosmos DB for MongoDB API; use existing/default index paths instead.
10+
- For Cosmos DB/Mongo index management, index creation/modification must be best-effort and must not fail or block application startup if indexes cannot be created in deployed environments with existing data.
1011

1112
## Automation Guidelines
1213
- 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
1414

1515
## 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.
1916
- 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.
2017
- 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/Configuration/AutomationConfig.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ public class FhirGenerationSettings
6767
/// <summary>
6868
/// Maximum number of patients processed concurrently by the streaming
6969
/// generation/upload pipeline. Lower values reduce memory pressure.
70+
/// Defaults to 4 to align with direct pipeline callers that rely on
71+
/// the pipeline's built-in fallback.
7072
/// </summary>
71-
public int MaxConcurrentPatients { get; set; } = 2;
73+
public int MaxConcurrentPatients { get; set; } = 4;
7274

7375
/// <summary>
7476
/// Controls low-value optional cross-resource references in generated FHIR

DotNet/Automation.UI/Services/Persistence/AzureBlobSnapshotPayloadStore.cs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Azure.Storage.Blobs;
1+
using Azure;
2+
using Azure.Storage.Blobs;
23
using Azure.Storage.Blobs.Models;
34
using Microsoft.Extensions.Options;
45
using System.Text;
@@ -10,21 +11,34 @@ public sealed class AzureBlobSnapshotPayloadStore : ISnapshotPayloadStore
1011
private readonly ImportedBundleBlobStorageSettings _settings;
1112
private readonly BlobContainerClient _container;
1213
private readonly HashSet<string> _externalizedDomains;
14+
private readonly Func<string, CancellationToken, AsyncPageable<BlobItem>> _listBlobs;
15+
private readonly Func<string, CancellationToken, Task> _deleteBlob;
1316

1417
public AzureBlobSnapshotPayloadStore(IOptions<ImportedBundleBlobStorageSettings> settings)
18+
: this(
19+
settings.Value,
20+
CreateContainer(settings.Value),
21+
listBlobs: null,
22+
deleteBlob: null)
1523
{
16-
_settings = settings.Value;
17-
18-
if (string.IsNullOrWhiteSpace(_settings.ConnectionString))
19-
throw new InvalidOperationException("InternalBlobStorage:ConnectionString is required for snapshot payload storage.");
20-
if (string.IsNullOrWhiteSpace(_settings.BlobContainerName))
21-
throw new InvalidOperationException("InternalBlobStorage:BlobContainerName is required for snapshot payload storage.");
24+
}
2225

23-
_container = new BlobContainerClient(_settings.ConnectionString, _settings.BlobContainerName);
26+
internal AzureBlobSnapshotPayloadStore(
27+
ImportedBundleBlobStorageSettings settings,
28+
BlobContainerClient container,
29+
Func<string, CancellationToken, AsyncPageable<BlobItem>>? listBlobs,
30+
Func<string, CancellationToken, Task>? deleteBlob)
31+
{
32+
_settings = settings;
33+
_container = container;
2434
_externalizedDomains = (_settings.SnapshotPayloadExternalizedDomains ?? [])
2535
.Where(d => !string.IsNullOrWhiteSpace(d))
2636
.Select(d => d.Trim())
2737
.ToHashSet(StringComparer.OrdinalIgnoreCase);
38+
39+
_listBlobs = listBlobs ?? ((prefix, ct) => _container.GetBlobsAsync(BlobTraits.None, BlobStates.None, prefix, ct));
40+
_deleteBlob = deleteBlob ?? ((blobName, ct) =>
41+
_container.DeleteBlobIfExistsAsync(blobName, DeleteSnapshotsOption.IncludeSnapshots, cancellationToken: ct));
2842
}
2943

3044
public bool ShouldExternalize(string domain, int payloadUtf8Bytes)
@@ -97,12 +111,29 @@ public async Task DeleteRunPayloadsAsync(Guid runId, CancellationToken ct = defa
97111
{
98112
var prefix = BuildRunPrefix(runId);
99113

100-
await foreach (var blob in _container.GetBlobsAsync(BlobTraits.None, BlobStates.None, prefix, ct))
114+
try
115+
{
116+
await foreach (var blob in _listBlobs(prefix, ct))
117+
{
118+
await _deleteBlob(blob.Name, ct);
119+
}
120+
}
121+
catch (RequestFailedException ex) when (ex.Status == 404)
101122
{
102-
await _container.DeleteBlobIfExistsAsync(blob.Name, DeleteSnapshotsOption.IncludeSnapshots, cancellationToken: ct);
123+
// Missing container is treated as an empty cleanup result.
103124
}
104125
}
105126

127+
private static BlobContainerClient CreateContainer(ImportedBundleBlobStorageSettings settings)
128+
{
129+
if (string.IsNullOrWhiteSpace(settings.ConnectionString))
130+
throw new InvalidOperationException("InternalBlobStorage:ConnectionString is required for snapshot payload storage.");
131+
if (string.IsNullOrWhiteSpace(settings.BlobContainerName))
132+
throw new InvalidOperationException("InternalBlobStorage:BlobContainerName is required for snapshot payload storage.");
133+
134+
return new BlobContainerClient(settings.ConnectionString, settings.BlobContainerName);
135+
}
136+
106137
private string BuildBlobName(Guid runId, string domain)
107138
{
108139
var sanitizedDomain = string.Join("-", domain

DotNet/Automation.UI/Services/Persistence/GeneratedTemplateCacheVersionStore.cs

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,19 @@ public sealed class GeneratedTemplateCacheVersionStore
3131
{
3232
private readonly IMongoCollection<GeneratedTemplateCacheVersionDocument> _versions;
3333
private const string ScenarioHashUniqueIndexName = "ux_generated_template_versions_scenario_hash";
34+
private const string ScenarioKeyField = "ScenarioKey";
35+
private const string TemplateSetHashField = "TemplateSetHash";
3436

3537
public GeneratedTemplateCacheVersionStore(IMongoDatabase database)
3638
{
3739
_versions = database.GetCollection<GeneratedTemplateCacheVersionDocument>("automation_generated_template_versions");
3840

39-
if (HasIndexWithKeys(_versions, new BsonDocument { { "ScenarioKey", 1 }, { "TemplateSetHash", 1 } }))
41+
var indexes = _versions.Indexes.List().ToList();
42+
var indexInvariant = GetScenarioHashIndexInvariant(indexes);
43+
if (indexInvariant == ScenarioHashIndexInvariant.Satisfied)
44+
return;
45+
46+
if (indexInvariant == ScenarioHashIndexInvariant.NonUniqueKeyShapePresent)
4047
return;
4148

4249
var uniqueScenarioHashIndex = new CreateIndexModel<GeneratedTemplateCacheVersionDocument>(
@@ -49,56 +56,85 @@ public GeneratedTemplateCacheVersionStore(IMongoDatabase database)
4956
{
5057
_versions.Indexes.CreateOne(uniqueScenarioHashIndex);
5158
}
52-
catch (MongoCommandException ex)
53-
when (ex.Message.Contains("already exists with different options", StringComparison.OrdinalIgnoreCase)
54-
|| ex.Message.Contains("Cannot create unique index when collection contains documents", StringComparison.OrdinalIgnoreCase)
55-
|| (ex.Code == 13 && ex.Message.Contains("unique index cannot be modified", StringComparison.OrdinalIgnoreCase)))
59+
catch
5660
{
57-
// Deployed environments may already have an index with this name/options
58-
// drift or contain pre-existing duplicate rows. Do not block app startup.
61+
// Best-effort only: startup must not be blocked by index creation limitations
62+
// in deployed environments (e.g., Cosmos DB collections with existing data).
5963
}
6064
}
6165

62-
private static bool HasIndexWithKeys(
63-
IMongoCollection<GeneratedTemplateCacheVersionDocument> collection,
64-
BsonDocument targetKeys)
66+
private static ScenarioHashIndexInvariant GetScenarioHashIndexInvariant(IReadOnlyList<BsonDocument> indexes)
6567
{
66-
var indexes = collection.Indexes.List().ToList();
67-
6868
foreach (var index in indexes)
6969
{
7070
if (index.TryGetValue("key", out var keyValue)
7171
&& keyValue.IsBsonDocument
72-
&& KeysEqual(keyValue.AsBsonDocument, targetKeys))
72+
&& IsScenarioHashKeyShape(keyValue.AsBsonDocument))
7373
{
74-
return true;
74+
var unique = index.TryGetValue("unique", out var uniqueValue)
75+
&& uniqueValue.IsBoolean
76+
&& uniqueValue.AsBoolean;
77+
78+
return unique
79+
? ScenarioHashIndexInvariant.Satisfied
80+
: ScenarioHashIndexInvariant.NonUniqueKeyShapePresent;
7581
}
7682
}
7783

78-
return false;
84+
return ScenarioHashIndexInvariant.Missing;
85+
}
86+
87+
private static bool IsScenarioHashKeyShape(BsonDocument key)
88+
{
89+
if (key.ElementCount != 2)
90+
return false;
91+
92+
var elements = key.Elements.ToList();
93+
94+
if (!string.Equals(elements[0].Name, ScenarioKeyField, StringComparison.Ordinal))
95+
return false;
96+
if (!string.Equals(elements[1].Name, TemplateSetHashField, StringComparison.Ordinal))
97+
return false;
98+
99+
return IsAscendingDirection(elements[0].Value)
100+
&& IsAscendingDirection(elements[1].Value);
79101
}
80102

81-
private static bool KeysEqual(BsonDocument existing, BsonDocument target)
103+
private static bool IsAscendingDirection(BsonValue value)
82104
{
83-
if (existing.ElementCount != target.ElementCount)
105+
if (!TryGetIntegralDirection(value, out var direction))
84106
return false;
85107

86-
var existingElements = existing.Elements.ToList();
87-
var targetElements = target.Elements.ToList();
108+
return direction == 1;
109+
}
110+
111+
private static bool TryGetIntegralDirection(BsonValue value, out int direction)
112+
{
113+
direction = 0;
88114

89-
for (var i = 0; i < existingElements.Count; i++)
115+
if (value is BsonInt32 int32)
90116
{
91-
var left = existingElements[i];
92-
var right = targetElements[i];
117+
direction = int32.Value;
118+
return true;
119+
}
93120

94-
if (!string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase))
121+
if (value is BsonInt64 int64)
122+
{
123+
if (int64.Value is < int.MinValue or > int.MaxValue)
95124
return false;
96125

97-
if (left.Value.ToInt32() != right.Value.ToInt32())
98-
return false;
126+
direction = (int)int64.Value;
127+
return true;
99128
}
100129

101-
return true;
130+
return false;
131+
}
132+
133+
private enum ScenarioHashIndexInvariant
134+
{
135+
Missing,
136+
Satisfied,
137+
NonUniqueKeyShapePresent
102138
}
103139

104140
public async Task<GeneratedTemplateCacheVersionBinding?> BindRunAsync(

DotNet/Automation.UI/Services/Persistence/MongoIndexManager.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ private void EnsureGeneratedTemplateCacheVersionIndexes()
157157
// Supports latest-version lookup per scenario key (SortByDescending VersionNumber).
158158
CreateIndexSafe(collection, new BsonDocument { { "ScenarioKey", 1 }, { "VersionNumber", -1 } }, unique: false, "idx_scenarioKey_versionNumber_desc");
159159

160-
// Supports exact lookup by scenario + template hash.
161-
CreateIndexSafe(collection, new BsonDocument { { "ScenarioKey", 1 }, { "TemplateSetHash", 1 } }, unique: false, "idx_scenarioKey_templateSetHash");
160+
// Supports exact lookup by scenario + template hash and must be UNIQUE to
161+
// preserve GeneratedTemplateCacheVersionStore's schema invariant.
162+
CreateIndexSafe(collection, new BsonDocument { { "ScenarioKey", 1 }, { "TemplateSetHash", 1 } }, unique: true, "ux_generated_template_versions_scenario_hash");
162163
}
163164

164165
// --- automation_query_plan_templates ---
@@ -273,7 +274,7 @@ private static bool KeysEqual(BsonDocument existing, BsonDocument target)
273274
if (!string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase))
274275
return false;
275276

276-
if (left.Value.ToInt32() != right.Value.ToInt32())
277+
if (!left.Value.Equals(right.Value))
277278
return false;
278279
}
279280

DotNet/Automation.UI/Services/Persistence/MongoSnapshotStore.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using MongoDB.Driver;
2+
using LantanaGroup.Link.Shared.Application.Services.Security;
23
using System.Text;
34
using System.Text.Json;
45
using System.Text.Json.Nodes;
@@ -23,6 +24,7 @@ public sealed class MongoSnapshotStore : ISnapshotStore
2324
private const int MaxLogChunkEstimatedBsonBytes = 12 * 1024 * 1024;
2425
private const int EstimatedBsonBytesPerLineOverhead = 64;
2526
private const string OversizedLogLineSuffix = " [truncated: exceeded log chunk byte budget]";
27+
private const string SnapshotPayloadPointerEnvelopeProperty = "__externalSnapshotPayloadPointer";
2628

2729
private readonly IMongoCollection<AutomationRunDocument> _runs;
2830
private readonly IMongoCollection<AutomationRunInputDocument> _runInputs;
@@ -263,12 +265,15 @@ public async Task<IReadOnlyList<AutomationRunSummary>> GetAllRunSummariesAsync(D
263265

264266
public async Task DeleteRunAsync(Guid runId, CancellationToken ct = default)
265267
{
266-
await _snapshotPayloadStore.DeleteRunPayloadsAsync(runId, ct);
267268
await _runs.DeleteOneAsync(r => r.RunId == runId, ct);
268269
await _runInputs.DeleteOneAsync(r => r.RunId == runId, ct);
269270
await _snapshots.DeleteManyAsync(s => s.RunId == runId, ct);
270271
await _logs.DeleteManyAsync(CreateLogChunkFilter(runId), ct);
271272
await _logs.DeleteOneAsync(l => l.Id == runId.ToString(), ct);
273+
274+
// Only remove externalized payload blobs after Mongo cleanup succeeds so
275+
// a DB failure cannot orphan pointer records that still reference payload data.
276+
await _snapshotPayloadStore.DeleteRunPayloadsAsync(runId, ct);
272277
}
273278

274279
private async Task<string?> BuildHydratedRunConfigurationJsonAsync(AutomationRunInputSnapshot input, CancellationToken ct)
@@ -392,7 +397,10 @@ public async Task SetDomainAsync<T>(Guid runId, string domain, T data, Cancellat
392397
if (_snapshotPayloadStore.ShouldExternalize(domain, payloadUtf8Bytes))
393398
{
394399
newPointer = await _snapshotPayloadStore.StoreAsync(runId, domain, json, ct);
395-
storedJson = JsonSerializer.Serialize(newPointer);
400+
storedJson = JsonSerializer.Serialize(new Dictionary<string, SnapshotPayloadPointer?>
401+
{
402+
[SnapshotPayloadPointerEnvelopeProperty] = newPointer
403+
});
396404
}
397405

398406
var update = Builders<DomainSnapshotDocument>.Update
@@ -430,7 +438,10 @@ public async Task SetDomainAsync<T>(Guid runId, string domain, T data, Cancellat
430438
payloadJson = await _snapshotPayloadStore.ReadAsync(pointer, ct);
431439
if (string.IsNullOrWhiteSpace(payloadJson))
432440
{
433-
_logger.LogWarning("[Store] GetDomain: externalized payload missing for run={RunId} domain={Domain} blob={Blob}", runId, domain, pointer.BlobName);
441+
var sanitizedRunId = runId.ToString().SanitizeForLog();
442+
var sanitizedDomain = domain.SanitizeForLog();
443+
var sanitizedBlobName = pointer.BlobName.SanitizeForLog();
444+
_logger.LogWarning("[Store] GetDomain: externalized payload missing for run={RunId} domain={Domain} blob={Blob}", sanitizedRunId, sanitizedDomain, sanitizedBlobName);
434445
return null;
435446
}
436447
}
@@ -458,7 +469,17 @@ public async Task SetDomainAsync<T>(Guid runId, string domain, T data, Cancellat
458469

459470
try
460471
{
461-
var pointer = JsonSerializer.Deserialize<SnapshotPayloadPointer>(payload);
472+
using var doc = JsonDocument.Parse(payload);
473+
if (doc.RootElement.ValueKind != JsonValueKind.Object)
474+
return null;
475+
476+
if (!doc.RootElement.TryGetProperty(SnapshotPayloadPointerEnvelopeProperty, out var pointerElement)
477+
|| pointerElement.ValueKind != JsonValueKind.Object)
478+
{
479+
return null;
480+
}
481+
482+
var pointer = pointerElement.Deserialize<SnapshotPayloadPointer>();
462483
if (pointer == null)
463484
return null;
464485

0 commit comments

Comments
 (0)