Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
83e0f79
Add Mesh client to sln file
ianfnelson May 12, 2025
1950834
wip
ianfnelson May 12, 2025
5009f6c
feat: ExtractFunction attempts to upload file to blob storage
alex-clayton-1 May 12, 2025
f5adb31
Queue client implementations
ianfnelson May 13, 2025
939f9ee
renames
ianfnelson May 13, 2025
e14e3f8
sketching out storage impl
ianfnelson May 13, 2025
71c2f53
tweak
ianfnelson May 13, 2025
d682007
fix namespace
ianfnelson May 13, 2025
0a4ce6b
fix file name
ianfnelson May 13, 2025
4460867
fix compilation errors
ianfnelson May 13, 2025
5044497
fix: fixing unit tests for discovery function
SamTyrrellNHS May 13, 2025
da5d59b
comment
ianfnelson May 13, 2025
0d3a21d
feat: adding unit tests for file Extract function
SamTyrrellNHS May 13, 2025
4f64ffd
feat: channging env var name
SamTyrrellNHS May 13, 2025
29ea6ff
feat: adding Retry function
SamTyrrellNHS May 13, 2025
e70e497
feat: Implemented FileExtract Function
alex-clayton-1 May 13, 2025
26cddd0
introduce app configuration
ianfnelson May 13, 2025
9317192
fix test compilation
ianfnelson May 13, 2025
c609736
configuration of queue clients and extract function
ianfnelson May 13, 2025
f4e9ccb
extract function configuration
ianfnelson May 13, 2025
239523e
fix
ianfnelson May 13, 2025
f7f61fc
feat: added tests for File Retry function
SamTyrrellNHS May 13, 2025
317fd0f
Merge branch 'feat/DTOSS-8739-extract-function' into feat/RetryFunction
SamTyrrellNHS May 13, 2025
7e9de12
feat: Improved logging in FileExtractFunction
alex-clayton-1 May 13, 2025
b503cff
refactor: Split out functionality in FileExtractFunction into separat…
alex-clayton-1 May 13, 2025
ddd700d
style: Fixed formatting issues
alex-clayton-1 May 13, 2025
f464619
test: Fixed two of the FileExtractFunctionTests
alex-clayton-1 May 13, 2025
f2bb922
merging with feat/DTOSS-8739-extract-function
SamTyrrellNHS May 14, 2025
5e3e932
feat: added configuration
SamTyrrellNHS May 14, 2025
b9c63f4
feat: fixing unit tests
SamTyrrellNHS May 14, 2025
a98b312
merging with main
SamTyrrellNHS May 15, 2025
5f9fc5a
feat: removing merge issues
SamTyrrellNHS May 15, 2025
5d31f45
feat: removing merge issues
SamTyrrellNHS May 15, 2025
d2efe6b
fix: line spacing
SamTyrrellNHS May 15, 2025
ba700a9
feat: responding to feedback, adding theory tests
SamTyrrellNHS May 16, 2025
763d7a4
feat: adding test for when no files found
SamTyrrellNHS May 16, 2025
4327eac
merging with main
SamTyrrellNHS May 16, 2025
453bb58
Update host.json
SamTyrrellNHS May 16, 2025
7ab6582
feat: env file update
SamTyrrellNHS May 16, 2025
8817a92
Merge branch 'feat/RetryFunction' of https://github.com/NHSDigital/dt…
SamTyrrellNHS May 16, 2025
6e60b52
feat: namespace change
SamTyrrellNHS May 16, 2025
df740df
feat: env change
SamTyrrellNHS May 16, 2025
130ff2a
feat: tidying code
SamTyrrellNHS May 16, 2025
c9e3bc1
feat: fixing tests
SamTyrrellNHS May 16, 2025
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ MeshPassword=password
NbssMailboxId=X26ABC1
MeshApiBaseUrl=http://localhost:8700/messageexchange
ASPNETCORE_ENVIRONMENT=Development
FileDiscoveryTimerExpression=*/5 * * * *
FileDiscoveryTimerExpression=0 */5 * * * *
MeshHandshakeTimerExpression=0 0 0 * * * # Midnight
FileRetryTimerExpression=0 0 * * * *
QueueUrl=http://127.0.0.1:10001
FileExtractQueueName=file-extract
FileTransformQueueName=file-transform
StaleHours=12

# API Configuration
API_PORT=7071
Expand Down
15 changes: 15 additions & 0 deletions src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public class AppConfiguration :
IFileExtractFunctionConfiguration,
IFileExtractQueueClientConfiguration,
IFileTransformQueueClientConfiguration,
IFileRetryFunctionConfiguration,
IMeshHandshakeFunctionConfiguration
{
public string NbssMeshMailboxId => GetRequired("NbssMailboxId");
Expand All @@ -13,6 +14,8 @@ public class AppConfiguration :

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

public int StaleHours => GetRequiredInt("StaleHours");

private static string GetRequired(string key)
{
var value = Environment.GetEnvironmentVariable(key);
Expand All @@ -24,4 +27,16 @@ private static string GetRequired(string key)

return value;
}

private static int GetRequiredInt(string key)
{
var value = GetRequired(key);

if (!int.TryParse(value, out var intValue))
{
throw new InvalidOperationException($"Environment variable '{key}' is not a valid integer");
}

return intValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ServiceLayer.Mesh.Configuration;

public interface IFileRetryFunctionConfiguration
{
int StaleHours { get; }
}
63 changes: 62 additions & 1 deletion src/ServiceLayer.Mesh/Functions/FileRetryFunction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,67 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ServiceLayer.Mesh.Data;
using ServiceLayer.Mesh.Messaging;
using ServiceLayer.Mesh.Models;
using ServiceLayer.Mesh.Configuration;

namespace ServiceLayer.Mesh.Functions;

public class FileRetryFunction
public class FileRetryFunction(
ILogger<FileRetryFunction> logger,
ServiceLayerDbContext serviceLayerDbContext,
IFileExtractQueueClient fileExtractQueueClient,
IFileTransformQueueClient fileTransformQueueClient,
IFileRetryFunctionConfiguration configuration)
{
[Function("FileRetryFunction")]
public async Task Run([TimerTrigger("%FileRetryTimerExpression%")] TimerInfo myTimer)
{
logger.LogInformation("FileRetryFunction started");

var staleDateTimeUtc = DateTime.UtcNow.AddHours(-configuration.StaleHours);

await Task.WhenAll(
RetryStaleExtractions(staleDateTimeUtc),
RetryStaleTransformations(staleDateTimeUtc));
}

private async Task RetryStaleExtractions(DateTime staleDateTimeUtc)
{
var staleFiles = await serviceLayerDbContext.MeshFiles
.Where(f =>
(f.Status == MeshFileStatus.Discovered || f.Status == MeshFileStatus.Extracting)
&& f.LastUpdatedUtc <= staleDateTimeUtc)
.ToListAsync();

logger.LogInformation($"FileRetryFunction: {staleFiles.Count} stale files found for extraction retry");

foreach (var file in staleFiles)
{
await fileExtractQueueClient.EnqueueFileExtractAsync(file);
file.LastUpdatedUtc = DateTime.UtcNow;
await serviceLayerDbContext.SaveChangesAsync();
logger.LogInformation($"FileRetryFunction: File {file.FileId} enqueued to Extract queue");
}
}

private async Task RetryStaleTransformations(DateTime staleDateTimeUtc)
{
var staleFiles = await serviceLayerDbContext.MeshFiles
.Where(f =>
(f.Status == MeshFileStatus.Extracted || f.Status == MeshFileStatus.Transforming)
&& f.LastUpdatedUtc <= staleDateTimeUtc)
.ToListAsync();

logger.LogInformation($"FileRetryFunction: {staleFiles.Count} stale files found for transforming retry");

foreach (var file in staleFiles)
{
await fileTransformQueueClient.EnqueueFileTransformAsync(file);
file.LastUpdatedUtc = DateTime.UtcNow;
await serviceLayerDbContext.SaveChangesAsync();
logger.LogInformation($"FileRetryFunction: File {file.FileId} enqueued to Transform queue");
}
}
}
7 changes: 7 additions & 0 deletions src/ServiceLayer.Mesh/Functions/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
1 change: 1 addition & 0 deletions src/ServiceLayer.Mesh/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
services.AddTransient<IFileExtractQueueClientConfiguration, AppConfiguration>();
services.AddTransient<IFileTransformQueueClientConfiguration, AppConfiguration>();
services.AddTransient<IMeshHandshakeFunctionConfiguration, AppConfiguration>();
services.AddTransient<IFileRetryFunctionConfiguration, AppConfiguration>();
});


Expand Down
218 changes: 218 additions & 0 deletions tests/ServiceLayer.Mesh.Tests/Functions/FileRetryFunctionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using NHS.MESH.Client.Contracts.Services;
using ServiceLayer.Mesh.Data;
using ServiceLayer.Mesh.Functions;
using ServiceLayer.Mesh.Messaging;
using ServiceLayer.Mesh.Models;
using ServiceLayer.Mesh.Configuration;

namespace ServiceLayer.Mesh.Tests.Functions;

public class FileRetryFunctionTests
{
private readonly Mock<ILogger<FileRetryFunction>> _loggerMock;
private readonly Mock<IFileExtractQueueClient> _fileExtractQueueClientMock;
private readonly Mock<IFileTransformQueueClient> _fileTransformQueueClientMock;
private readonly Mock<IFileRetryFunctionConfiguration> _configuration;
private readonly ServiceLayerDbContext _dbContext;
private readonly FileRetryFunction _function;

public FileRetryFunctionTests()
{
_loggerMock = new Mock<ILogger<FileRetryFunction>>();
_fileExtractQueueClientMock = new Mock<IFileExtractQueueClient>();
_fileTransformQueueClientMock = new Mock<IFileTransformQueueClient>();
_configuration = new Mock<IFileRetryFunctionConfiguration>();

var options = new DbContextOptionsBuilder<ServiceLayerDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(warnings =>
warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
.Options;

_dbContext = new ServiceLayerDbContext(options);

_configuration.Setup(c => c.StaleHours).Returns(12);

_function = new FileRetryFunction(
_loggerMock.Object,
_dbContext,
_fileExtractQueueClientMock.Object,
_fileTransformQueueClientMock.Object,
_configuration.Object
);
}

[Theory]
[InlineData(MeshFileStatus.Discovered)]
[InlineData(MeshFileStatus.Extracting)]
public async Task Run_EnqueuesDiscoveredOrExtractingFilesOlderThan12Hours(MeshFileStatus testStatus)
{
var file = new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-1",
Status = testStatus,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
};

_dbContext.MeshFiles.Add(file);
await _dbContext.SaveChangesAsync();

// Act
await _function.Run(null);

// Assert
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Once);
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Never);

var updatedFile = await _dbContext.MeshFiles.FindAsync("file-1");
Assert.True(updatedFile!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
}

[Theory]
[InlineData(MeshFileStatus.Extracted)]
[InlineData(MeshFileStatus.Transforming)]
public async Task Run_EnqueuesExtractedOrTransformingFilesOlderThan12Hours(MeshFileStatus testStatus)
{
var file = new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-1",
Status = testStatus,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
};

_dbContext.MeshFiles.Add(file);
await _dbContext.SaveChangesAsync();

// Act
await _function.Run(null);

// Assert
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Once);
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-1")), Times.Never);


var updatedFile = await _dbContext.MeshFiles.FindAsync("file-1");
Assert.True(updatedFile!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
}

[Theory]
[InlineData(MeshFileStatus.Discovered)]
[InlineData(MeshFileStatus.Extracting)]
[InlineData(MeshFileStatus.Extracted)]
[InlineData(MeshFileStatus.Transforming)]
public async Task Run_SkipsFreshFiles(MeshFileStatus testStatus)
{
// Arrange
var lastUpdatedUtc = DateTime.UtcNow.AddHours(-1);

var file = new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-2",
Status = testStatus,
LastUpdatedUtc = lastUpdatedUtc
};
_dbContext.MeshFiles.Add(file);
await _dbContext.SaveChangesAsync();

// Act
await _function.Run(null);

// Assert
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.IsAny<MeshFile>()), Times.Never);

var updatedFile = await _dbContext.MeshFiles.FindAsync("file-2");
Assert.True(updatedFile!.LastUpdatedUtc == lastUpdatedUtc);
}

[Fact]
public async Task Run_IgnoresFilesInOtherStatuses()
{
// Arrange
var file = new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-5",
Status = MeshFileStatus.Transformed,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-20)
};
_dbContext.MeshFiles.Add(file);
await _dbContext.SaveChangesAsync();

// Act
await _function.Run(null);

// Assert
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
}

[Fact]
public async Task Run_IfNoFilesFoundDoNothing()
{
// Act
await _function.Run(null);

// Assert
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Never);
_fileTransformQueueClientMock.Verify(q => q.EnqueueFileTransformAsync(It.IsAny<MeshFile>()), Times.Never);
}

[Fact]
public async Task Run_ProcessesMultipleEligibleFiles()
{
// Arrange
var files = new[]
{
new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-6",
Status = MeshFileStatus.Discovered,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
},
new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-7",
Status = MeshFileStatus.Extracted,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
},
new MeshFile
{
FileType = MeshFileType.NbssAppointmentEvents,
MailboxId = "test-mailbox",
FileId = "file-8",
Status = MeshFileStatus.Transforming,
LastUpdatedUtc = DateTime.UtcNow.AddHours(-13)
}
};

_dbContext.MeshFiles.AddRange(files);
await _dbContext.SaveChangesAsync();

// Act
await _function.Run(null);

// Assert
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.Is<MeshFile>(f => f.FileId == "file-6")), Times.Once);
_fileExtractQueueClientMock.Verify(q => q.EnqueueFileExtractAsync(It.IsAny<MeshFile>()), Times.Once);

foreach (var fileId in new[] { "file-6", "file-7", "file-8" })
{
var updated = await _dbContext.MeshFiles.FindAsync(fileId);
Assert.True(updated!.LastUpdatedUtc > DateTime.UtcNow.AddMinutes(-1));
}
}
}
Loading