Skip to content

Commit 3caeeeb

Browse files
committed
Propagate cancellation tokens through ingestion and body storage persistence
This change continues the effort to consistently apply optional `CancellationToken` parameters to asynchronous methods within the `IBodyStorage`, `IFailedErrorImportDataStore`, and ingestion unit of work interfaces, along with their EFCore and RavenDB implementations. Cancellation tokens are now propagated to underlying asynchronous database calls, ensuring proper cancellation of long-running operations. In the error processor, specific handling for `OperationCanceledException` was added to ensure message contexts are released during shutdown so that the ingestion loop can terminate rather than awaiting indefinitely.
1 parent 81555f2 commit 3caeeeb

32 files changed

Lines changed: 96 additions & 68 deletions

‎src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/BodyStorage.cs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage;
1919
/// </remarks>
2020
public class BodyStorage(IServiceScopeFactory scopeFactory, IBodyStoragePersistence storagePersistence) : DataStoreBase(scopeFactory), IBodyStorage
2121
{
22-
public async Task<MessageBodyStreamResult?> TryFetch(string bodyId)
22+
public async Task<MessageBodyStreamResult?> TryFetch(string bodyId, CancellationToken cancellationToken = default)
2323
{
24-
var row = await ExecuteWithDbContext(dbContext => ResolveBody(dbContext, bodyId));
24+
var row = await ExecuteWithDbContext(dbContext => ResolveBody(dbContext, bodyId, cancellationToken));
2525

2626
if (row == null)
2727
{
@@ -33,7 +33,7 @@ public class BodyStorage(IServiceScopeFactory scopeFactory, IBodyStoragePersiste
3333

3434
if (row.BodyStoredExternally)
3535
{
36-
var external = await storagePersistence.ReadBody(uniqueMessageId);
36+
var external = await storagePersistence.ReadBody(uniqueMessageId, cancellationToken);
3737

3838
return external == null
3939
? new MessageBodyStreamResult { HasResult = false }
@@ -64,21 +64,21 @@ public class BodyStorage(IServiceScopeFactory scopeFactory, IBodyStoragePersiste
6464
return new MessageBodyStreamResult { HasResult = false }; // Message exists but carries no body.
6565
}
6666

67-
static async Task<BodyRow?> ResolveBody(ServiceControlDbContext dbContext, string bodyId)
67+
static async Task<BodyRow?> ResolveBody(ServiceControlDbContext dbContext, string bodyId, CancellationToken cancellationToken)
6868
{
6969
if (Guid.TryParse(bodyId, out var uniqueMessageId))
7070
{
71-
var byUniqueId = await Query(dbContext, message => message.UniqueMessageId == uniqueMessageId);
71+
var byUniqueId = await Query(dbContext, message => message.UniqueMessageId == uniqueMessageId, cancellationToken);
7272
if (byUniqueId != null)
7373
{
7474
return byUniqueId;
7575
}
7676
}
7777

78-
return await Query(dbContext, message => message.MessageId == bodyId);
78+
return await Query(dbContext, message => message.MessageId == bodyId, cancellationToken);
7979
}
8080

81-
static Task<BodyRow?> Query(ServiceControlDbContext dbContext, Expression<Func<FailedMessageEntity, bool>> predicate) =>
81+
static Task<BodyRow?> Query(ServiceControlDbContext dbContext, Expression<Func<FailedMessageEntity, bool>> predicate, CancellationToken cancellationToken) =>
8282
dbContext.FailedMessages
8383
.AsNoTracking()
8484
.Where(predicate)
@@ -90,7 +90,7 @@ public class BodyStorage(IServiceScopeFactory scopeFactory, IBodyStoragePersiste
9090
BodyStoredExternally = message.BodyStoredExternally,
9191
BodyContentType = message.BodyContentType
9292
})
93-
.FirstOrDefaultAsync();
93+
.FirstOrDefaultAsync(cancellationToken);
9494

9595
sealed class BodyRow
9696
{

‎src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/FileSystemBodyStoragePersistence.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ public async Task WriteBody(string bodyId, ReadOnlyMemory<byte> body, string con
7272
TryDelete(tempFilePath);
7373
}
7474
}
75+
#pragma warning disable PS0019 // The catch rethrows everything, cancellation included, so splitting
76+
// out OperationCanceledException would duplicate the cleanup for no change in behaviour.
7577
catch
7678
{
7779
TryDelete(tempFilePath);
7880
throw;
7981
}
82+
#pragma warning restore PS0019
8083
}
8184

8285
public Task<MessageBodyFileResult?> ReadBody(string bodyId, CancellationToken cancellationToken = default)

‎src/ServiceControl.Persistence.EFCore/Implementation/FailedErrorImportDataStore.cs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ public class FailedErrorImportDataStore(
2222
{
2323
const int BatchSize = 100;
2424

25-
public Task<bool> QueryContainsFailedImports() =>
25+
public Task<bool> QueryContainsFailedImports(CancellationToken cancellationToken = default) =>
2626
ExecuteWithDbContext(dbContext => dbContext.FailedErrorImports.AsNoTracking().AnyAsync());
2727

2828
// Update-first, then insert. The dedupe key is deterministic, so a repeat failure updates the
2929
// existing row and concurrent writers that both miss it race only on the insert. The loser of
3030
// that race confirms the row is now present (the winner stored the same logical failure) and
3131
// otherwise rethrows, so the caller never treats a message as stored when it is not.
32-
public Task StoreFailedErrorImport(FailedErrorImport failure) =>
32+
public Task StoreFailedErrorImport(FailedErrorImport failure, CancellationToken cancellationToken = default) =>
3333
ExecuteWithDbContext(async dbContext =>
3434
{
3535
var uniqueMessageId = FailedErrorImport.DeriveKey(failure.Message.Headers, failure.Message.Id);
@@ -69,7 +69,7 @@ public Task StoreFailedErrorImport(FailedErrorImport failure) =>
6969
// Replays oldest-first. Successful imports delete their row; failures are left in place, so the
7070
// count of failures so far is exactly the offset to the next unseen row. This walks the whole
7171
// set once without retrying a failure within the same run.
72-
public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, Task> processMessage, CancellationToken cancellationToken)
72+
public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, CancellationToken, Task> processMessage, CancellationToken cancellationToken = default)
7373
{
7474
var succeeded = 0;
7575
var failed = 0;
@@ -94,7 +94,7 @@ public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, Task> p
9494
{
9595
var transportMessage = await ToTransportMessage(import, cancellationToken);
9696

97-
await processMessage(transportMessage);
97+
await processMessage(transportMessage, cancellationToken);
9898

9999
await DeleteImport(import, cancellationToken);
100100

@@ -192,10 +192,13 @@ async Task DeleteExternalBody(Guid uniqueMessageId, CancellationToken cancellati
192192
{
193193
await bodyStorage.DeleteBody(FailedErrorImportEntity.ExternalBodyId(uniqueMessageId), cancellationToken);
194194
}
195+
#pragma warning disable PS0019 // The filter already excludes OperationCanceledException, so cancellation
196+
// propagates; PS0019 only recognises a cancellationToken.IsCancellationRequested guard.
195197
catch (Exception ex) when (ex is not OperationCanceledException)
196198
{
197199
// Re-import must not stall on a missing or unavailable body.
198200
logger.LogWarning(ex, "Could not delete the external body for re-imported failed error {UniqueMessageId}", uniqueMessageId);
199201
}
202+
#pragma warning restore PS0019
200203
}
201204
}

‎src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFIngestionUnitOfWork.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public EFIngestionUnitOfWork(IAsyncDisposable scope, ServiceControlDbContext dbC
4141

4242
internal void RecordConfirmedRetry(Guid uniqueMessageId) => confirmedRetries.Enqueue(uniqueMessageId);
4343

44-
public async Task Complete(CancellationToken cancellationToken)
44+
public async Task Complete(CancellationToken cancellationToken = default)
4545
{
4646
// External bodies are written before the rows that point at them
4747
await Task.WhenAll(bodyWrites);

‎src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFIngestionUnitOfWorkFactory.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class EFIngestionUnitOfWorkFactory(
1313
IFailedMessageIngestionSqlDialect dialect,
1414
TimeProvider timeProvider) : IIngestionUnitOfWorkFactory
1515
{
16-
public ValueTask<IIngestionUnitOfWork> StartNew()
16+
public ValueTask<IIngestionUnitOfWork> StartNew(CancellationToken cancellationToken = default)
1717
{
1818
var scope = serviceProvider.CreateAsyncScope();
1919
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();

‎src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFMonitoringIngestionUnitOfWork.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation.UnitOfWork;
44

55
public class EFMonitoringIngestionUnitOfWork(EFIngestionUnitOfWork parentUnitOfWork) : IMonitoringIngestionUnitOfWork
66
{
7-
public Task RecordKnownEndpoint(KnownEndpoint knownEndpoint)
7+
public Task RecordKnownEndpoint(KnownEndpoint knownEndpoint, CancellationToken cancellationToken = default)
88
{
99
parentUnitOfWork.Record(knownEndpoint);
1010

‎src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFRecoverabilityIngestionUnitOfWork.cs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ public class EFRecoverabilityIngestionUnitOfWork(EFIngestionUnitOfWork parentUni
1414
{
1515
public Task RecordFailedProcessingAttempt(MessageContext context,
1616
FailedMessage.ProcessingAttempt processingAttempt,
17-
List<FailedMessage.FailureGroup> groups)
17+
List<FailedMessage.FailureGroup> groups,
18+
CancellationToken cancellationToken = default)
1819
{
1920
var uniqueMessageId = context.Headers.UniqueId();
2021
var contentType = context.Headers.GetValueOrDefault(Headers.ContentType, "text/plain");
@@ -24,7 +25,7 @@ public Task RecordFailedProcessingAttempt(MessageContext context,
2425
if (storeExternally)
2526
{
2627
parentUnitOfWork.RecordBodyWrite(
27-
storagePersistence.WriteBody(uniqueMessageId, context.Body, contentType));
28+
storagePersistence.WriteBody(uniqueMessageId, context.Body, contentType, cancellationToken));
2829
}
2930

3031
var sendingEndpoint = GetMetadata<EndpointDetails>(processingAttempt, "SendingEndpoint");
@@ -60,7 +61,7 @@ public Task RecordFailedProcessingAttempt(MessageContext context,
6061
return Task.CompletedTask;
6162
}
6263

63-
public Task RecordSuccessfulRetry(string retriedMessageUniqueId)
64+
public Task RecordSuccessfulRetry(string retriedMessageUniqueId, CancellationToken cancellationToken = default)
6465
{
6566
parentUnitOfWork.RecordConfirmedRetry(Guid.Parse(retriedMessageUniqueId));
6667

‎src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/FailedMessageBatchWriter.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public async Task Write(
1717
IReadOnlyCollection<KnownEndpoint> knownEndpoints,
1818
IReadOnlyCollection<Guid> confirmedRetries,
1919
DateTime now,
20-
CancellationToken cancellationToken)
20+
CancellationToken cancellationToken = default)
2121
{
2222
var (failedMessages, groups) = Fold(attempts, now);
2323
var endpoints = BuildEndpointRows(knownEndpoints);

‎src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ record struct FailedMessageProjection(string UniqueMessageId);
511511
public async Task<byte[]> GetFailedMessageBody(string uniqueMessageId, CancellationToken cancellationToken = default)
512512
{
513513
byte[] body = null;
514-
var result = await bodyStorage.TryFetch(uniqueMessageId)
514+
var result = await bodyStorage.TryFetch(uniqueMessageId, cancellationToken)
515515
?? throw new InvalidOperationException("IBodyStorage.TryFetch result cannot be null");
516516

517517
if (result.HasResult)

‎src/ServiceControl.Persistence.RavenDB/FailedErrorImportDataStore.cs‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@
99

1010
class FailedErrorImportDataStore(IRavenSessionProvider sessionProvider, ILogger<FailedErrorImportDataStore> logger) : IFailedErrorImportDataStore
1111
{
12-
public async Task StoreFailedErrorImport(FailedErrorImport failure)
12+
public async Task StoreFailedErrorImport(FailedErrorImport failure, CancellationToken cancellationToken = default)
1313
{
14-
using var session = await sessionProvider.OpenSession();
14+
using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken);
1515
// This object's ID is generated externally, but is not in the RavenDB format
1616
// Check that's true to make sure that if it already is that it doesn't get double-formatted
1717
if (!failure.Id.StartsWith(CollectionName))
1818
{
1919
failure.Id = MakeDocumentId(failure.Id);
2020
}
21-
await session.StoreAsync(failure);
21+
await session.StoreAsync(failure, cancellationToken);
2222

23-
await session.SaveChangesAsync();
23+
await session.SaveChangesAsync(cancellationToken);
2424
}
2525

26-
public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, Task> processMessage, CancellationToken cancellationToken)
26+
public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, CancellationToken, Task> processMessage, CancellationToken cancellationToken = default)
2727
{
2828
var succeeded = 0;
2929
var failed = 0;
@@ -36,7 +36,7 @@ public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, Task> p
3636
var transportMessage = stream.Current.Document.Message;
3737
try
3838
{
39-
await processMessage(transportMessage);
39+
await processMessage(transportMessage, cancellationToken);
4040

4141
await session.Advanced.RequestExecutor.ExecuteAsync(new DeleteDocumentCommand(stream.Current.Id, null), session.Advanced.Context, token: cancellationToken);
4242

@@ -64,11 +64,11 @@ public async Task ProcessFailedErrorImports(Func<FailedTransportMessage, Task> p
6464
}
6565
}
6666

67-
public async Task<bool> QueryContainsFailedImports()
67+
public async Task<bool> QueryContainsFailedImports(CancellationToken cancellationToken = default)
6868
{
69-
using var session = await sessionProvider.OpenSession();
69+
using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken);
7070
var query = session.Query<FailedErrorImport, FailedErrorImportIndex>();
71-
await using var ie = await session.Advanced.StreamAsync(query);
71+
await using var ie = await session.Advanced.StreamAsync(query, cancellationToken);
7272
return await ie.MoveNextAsync();
7373
}
7474

0 commit comments

Comments
 (0)