Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.

Commit 1fecdbd

Browse files
SamTyrrellNHSianfnelsonalex-clayton-1
authored
feat: retry function (#10)
Signed-off-by: Tyrrellion <[email protected]> Co-authored-by: Ian Nelson <[email protected]> Co-authored-by: alex-clayton-1 <[email protected]>
1 parent 9d0e5d8 commit 1fecdbd

File tree

7 files changed

+312
-2
lines changed

7 files changed

+312
-2
lines changed

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ MeshPassword=password
1212
NbssMailboxId=X26ABC1
1313
MeshApiBaseUrl=http://localhost:8700/messageexchange
1414
ASPNETCORE_ENVIRONMENT=Development
15-
FileDiscoveryTimerExpression=*/5 * * * *
15+
FileDiscoveryTimerExpression=0 */5 * * * *
1616
MeshHandshakeTimerExpression=0 0 0 * * * # Midnight
17+
FileRetryTimerExpression=0 0 * * * *
1718
QueueUrl=http://127.0.0.1:10001
1819
FileExtractQueueName=file-extract
1920
FileTransformQueueName=file-transform
21+
StaleHours=12
2022

2123
# API Configuration
2224
API_PORT=7071

src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public class AppConfiguration :
55
IFileExtractFunctionConfiguration,
66
IFileExtractQueueClientConfiguration,
77
IFileTransformQueueClientConfiguration,
8+
IFileRetryFunctionConfiguration,
89
IMeshHandshakeFunctionConfiguration
910
{
1011
public string NbssMeshMailboxId => GetRequired("NbssMailboxId");
@@ -13,6 +14,8 @@ public class AppConfiguration :
1314

1415
public string FileTransformQueueName => GetRequired("FileTransformQueueName");
1516

17+
public int StaleHours => GetRequiredInt("StaleHours");
18+
1619
private static string GetRequired(string key)
1720
{
1821
var value = Environment.GetEnvironmentVariable(key);
@@ -24,4 +27,16 @@ private static string GetRequired(string key)
2427

2528
return value;
2629
}
30+
31+
private static int GetRequiredInt(string key)
32+
{
33+
var value = GetRequired(key);
34+
35+
if (!int.TryParse(value, out var intValue))
36+
{
37+
throw new InvalidOperationException($"Environment variable '{key}' is not a valid integer");
38+
}
39+
40+
return intValue;
41+
}
2742
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace ServiceLayer.Mesh.Configuration;
2+
3+
public interface IFileRetryFunctionConfiguration
4+
{
5+
int StaleHours { get; }
6+
}
Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,67 @@
1+
using Microsoft.Azure.Functions.Worker;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.Extensions.Logging;
4+
using ServiceLayer.Mesh.Data;
5+
using ServiceLayer.Mesh.Messaging;
6+
using ServiceLayer.Mesh.Models;
7+
using ServiceLayer.Mesh.Configuration;
8+
19
namespace ServiceLayer.Mesh.Functions;
210

3-
public class FileRetryFunction
11+
public class FileRetryFunction(
12+
ILogger<FileRetryFunction> logger,
13+
ServiceLayerDbContext serviceLayerDbContext,
14+
IFileExtractQueueClient fileExtractQueueClient,
15+
IFileTransformQueueClient fileTransformQueueClient,
16+
IFileRetryFunctionConfiguration configuration)
417
{
18+
[Function("FileRetryFunction")]
19+
public async Task Run([TimerTrigger("%FileRetryTimerExpression%")] TimerInfo myTimer)
20+
{
21+
logger.LogInformation("FileRetryFunction started");
22+
23+
var staleDateTimeUtc = DateTime.UtcNow.AddHours(-configuration.StaleHours);
24+
25+
await Task.WhenAll(
26+
RetryStaleExtractions(staleDateTimeUtc),
27+
RetryStaleTransformations(staleDateTimeUtc));
28+
}
29+
30+
private async Task RetryStaleExtractions(DateTime staleDateTimeUtc)
31+
{
32+
var staleFiles = await serviceLayerDbContext.MeshFiles
33+
.Where(f =>
34+
(f.Status == MeshFileStatus.Discovered || f.Status == MeshFileStatus.Extracting)
35+
&& f.LastUpdatedUtc <= staleDateTimeUtc)
36+
.ToListAsync();
37+
38+
logger.LogInformation($"FileRetryFunction: {staleFiles.Count} stale files found for extraction retry");
39+
40+
foreach (var file in staleFiles)
41+
{
42+
await fileExtractQueueClient.EnqueueFileExtractAsync(file);
43+
file.LastUpdatedUtc = DateTime.UtcNow;
44+
await serviceLayerDbContext.SaveChangesAsync();
45+
logger.LogInformation($"FileRetryFunction: File {file.FileId} enqueued to Extract queue");
46+
}
47+
}
48+
49+
private async Task RetryStaleTransformations(DateTime staleDateTimeUtc)
50+
{
51+
var staleFiles = await serviceLayerDbContext.MeshFiles
52+
.Where(f =>
53+
(f.Status == MeshFileStatus.Extracted || f.Status == MeshFileStatus.Transforming)
54+
&& f.LastUpdatedUtc <= staleDateTimeUtc)
55+
.ToListAsync();
56+
57+
logger.LogInformation($"FileRetryFunction: {staleFiles.Count} stale files found for transforming retry");
558

59+
foreach (var file in staleFiles)
60+
{
61+
await fileTransformQueueClient.EnqueueFileTransformAsync(file);
62+
file.LastUpdatedUtc = DateTime.UtcNow;
63+
await serviceLayerDbContext.SaveChangesAsync();
64+
logger.LogInformation($"FileRetryFunction: File {file.FileId} enqueued to Transform queue");
65+
}
66+
}
667
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"version": "2.0",
3+
"extensionBundle": {
4+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
5+
"version": "[4.*, 5.0.0)"
6+
}
7+
}

src/ServiceLayer.Mesh/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
services.AddTransient<IFileExtractQueueClientConfiguration, AppConfiguration>();
6767
services.AddTransient<IFileTransformQueueClientConfiguration, AppConfiguration>();
6868
services.AddTransient<IMeshHandshakeFunctionConfiguration, AppConfiguration>();
69+
services.AddTransient<IFileRetryFunctionConfiguration, AppConfiguration>();
6970
});
7071

7172

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.Logging;
3+
using Moq;
4+
using NHS.MESH.Client.Contracts.Services;
5+
using ServiceLayer.Mesh.Data;
6+
using ServiceLayer.Mesh.Functions;
7+
using ServiceLayer.Mesh.Messaging;
8+
using ServiceLayer.Mesh.Models;
9+
using ServiceLayer.Mesh.Configuration;
10+
11+
namespace ServiceLayer.Mesh.Tests.Functions;
12+
13+
public class FileRetryFunctionTests
14+
{
15+
private readonly Mock<ILogger<FileRetryFunction>> _loggerMock;
16+
private readonly Mock<IFileExtractQueueClient> _fileExtractQueueClientMock;
17+
private readonly Mock<IFileTransformQueueClient> _fileTransformQueueClientMock;
18+
private readonly Mock<IFileRetryFunctionConfiguration> _configuration;
19+
private readonly ServiceLayerDbContext _dbContext;
20+
private readonly FileRetryFunction _function;
21+
22+
public FileRetryFunctionTests()
23+
{
24+
_loggerMock = new Mock<ILogger<FileRetryFunction>>();
25+
_fileExtractQueueClientMock = new Mock<IFileExtractQueueClient>();
26+
_fileTransformQueueClientMock = new Mock<IFileTransformQueueClient>();
27+
_configuration = new Mock<IFileRetryFunctionConfiguration>();
28+
29+
var options = new DbContextOptionsBuilder<ServiceLayerDbContext>()
30+
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
31+
.ConfigureWarnings(warnings =>
32+
warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
33+
.Options;
34+
35+
_dbContext = new ServiceLayerDbContext(options);
36+
37+
_configuration.Setup(c => c.StaleHours).Returns(12);
38+
39+
_function = new FileRetryFunction(
40+
_loggerMock.Object,
41+
_dbContext,
42+
_fileExtractQueueClientMock.Object,
43+
_fileTransformQueueClientMock.Object,
44+
_configuration.Object
45+
);
46+
}
47+
48+
[Theory]
49+
[InlineData(MeshFileStatus.Discovered)]
50+
[InlineData(MeshFileStatus.Extracting)]
51+
public async Task Run_EnqueuesDiscoveredOrExtractingFilesOlderThan12Hours(MeshFileStatus testStatus)
52+
{
53+
var file = new MeshFile
54+
{
55+
FileType = MeshFileType.NbssAppointmentEvents,
56+
MailboxId = "test-mailbox",
57+
FileId = "file-1",
58+
Status = testStatus,
59+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
60+
};
61+
62+
_dbContext.MeshFiles.Add(file);
63+
await _dbContext.SaveChangesAsync();
64+
65+
// Act
66+
await _function.Run(null);
67+
68+
// Assert
69+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Once);
70+
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Never);
71+
72+
var updatedFile = await _dbContext.MeshFiles.FindAsync("file-1");
73+
Assert.True(updatedFile!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
74+
}
75+
76+
[Theory]
77+
[InlineData(MeshFileStatus.Extracted)]
78+
[InlineData(MeshFileStatus.Transforming)]
79+
public async Task Run_EnqueuesExtractedOrTransformingFilesOlderThan12Hours(MeshFileStatus testStatus)
80+
{
81+
var file = new MeshFile
82+
{
83+
FileType = MeshFileType.NbssAppointmentEvents,
84+
MailboxId = "test-mailbox",
85+
FileId = "file-1",
86+
Status = testStatus,
87+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
88+
};
89+
90+
_dbContext.MeshFiles.Add(file);
91+
await _dbContext.SaveChangesAsync();
92+
93+
// Act
94+
await _function.Run(null);
95+
96+
// Assert
97+
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Once);
98+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Never);
99+
100+
101+
var updatedFile = await _dbContext.MeshFiles.FindAsync("file-1");
102+
Assert.True(updatedFile!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
103+
}
104+
105+
[Theory]
106+
[InlineData(MeshFileStatus.Discovered)]
107+
[InlineData(MeshFileStatus.Extracting)]
108+
[InlineData(MeshFileStatus.Extracted)]
109+
[InlineData(MeshFileStatus.Transforming)]
110+
public async Task Run_SkipsFreshFiles(MeshFileStatus testStatus)
111+
{
112+
// Arrange
113+
var lastUpdatedUtc = DateTime.UtcNow.AddHours(-1);
114+
115+
var file = new MeshFile
116+
{
117+
FileType = MeshFileType.NbssAppointmentEvents,
118+
MailboxId = "test-mailbox",
119+
FileId = "file-2",
120+
Status = testStatus,
121+
LastUpdatedUtc = lastUpdatedUtc
122+
};
123+
_dbContext.MeshFiles.Add(file);
124+
await _dbContext.SaveChangesAsync();
125+
126+
// Act
127+
await _function.Run(null);
128+
129+
// Assert
130+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
131+
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.IsAny<MeshFile>()), Times.Never);
132+
133+
var updatedFile = await _dbContext.MeshFiles.FindAsync("file-2");
134+
Assert.True(updatedFile!.LastUpdatedUtc == lastUpdatedUtc);
135+
}
136+
137+
[Fact]
138+
public async Task Run_IgnoresFilesInOtherStatuses()
139+
{
140+
// Arrange
141+
var file = new MeshFile
142+
{
143+
FileType = MeshFileType.NbssAppointmentEvents,
144+
MailboxId = "test-mailbox",
145+
FileId = "file-5",
146+
Status = MeshFileStatus.Transformed,
147+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-20)
148+
};
149+
_dbContext.MeshFiles.Add(file);
150+
await _dbContext.SaveChangesAsync();
151+
152+
// Act
153+
await _function.Run(null);
154+
155+
// Assert
156+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
157+
}
158+
159+
[Fact]
160+
public async Task Run_IfNoFilesFoundDoNothing()
161+
{
162+
// Act
163+
await _function.Run(null);
164+
165+
// Assert
166+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
167+
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.IsAny<MeshFile>()), Times.Never);
168+
}
169+
170+
[Fact]
171+
public async Task Run_ProcessesMultipleEligibleFiles()
172+
{
173+
// Arrange
174+
var files = new[]
175+
{
176+
new MeshFile
177+
{
178+
FileType = MeshFileType.NbssAppointmentEvents,
179+
MailboxId = "test-mailbox",
180+
FileId = "file-6",
181+
Status = MeshFileStatus.Discovered,
182+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
183+
},
184+
new MeshFile
185+
{
186+
FileType = MeshFileType.NbssAppointmentEvents,
187+
MailboxId = "test-mailbox",
188+
FileId = "file-7",
189+
Status = MeshFileStatus.Extracted,
190+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
191+
},
192+
new MeshFile
193+
{
194+
FileType = MeshFileType.NbssAppointmentEvents,
195+
MailboxId = "test-mailbox",
196+
FileId = "file-8",
197+
Status = MeshFileStatus.Transforming,
198+
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
199+
}
200+
};
201+
202+
_dbContext.MeshFiles.AddRange(files);
203+
await _dbContext.SaveChangesAsync();
204+
205+
// Act
206+
await _function.Run(null);
207+
208+
// Assert
209+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-6")), Times.Once);
210+
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Once);
211+
212+
foreach (var fileId in new[] { "file-6", "file-7", "file-8" })
213+
{
214+
var updated = await _dbContext.MeshFiles.FindAsync(fileId);
215+
Assert.True(updated!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)