-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a background service for delivering webhook messages (#1699)
- Loading branch information
Showing
7 changed files
with
444 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/IWebhookSender.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
using TeachingRecordSystem.Core.DataStore.Postgres.Models; | ||
|
||
namespace TeachingRecordSystem.Core.Services.Webhooks; | ||
|
||
public interface IWebhookSender | ||
{ | ||
Task SendMessageAsync(WebhookMessage message, CancellationToken cancellationToken = default); | ||
} |
139 changes: 139 additions & 0 deletions
139
...ingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookDeliveryService.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
using Microsoft.Extensions.Hosting; | ||
using Microsoft.Extensions.Logging; | ||
using Polly; | ||
using TeachingRecordSystem.Core.DataStore.Postgres; | ||
|
||
namespace TeachingRecordSystem.Core.Services.Webhooks; | ||
|
||
public class WebhookDeliveryService( | ||
IWebhookSender webhookSender, | ||
IDbContextFactory<TrsDbContext> dbContextFactory, | ||
IClock clock, | ||
ILogger<WebhookDeliveryService> logger) : BackgroundService | ||
{ | ||
public const int BatchSize = 20; | ||
|
||
private static readonly TimeSpan _pollInterval = TimeSpan.FromMinutes(1); | ||
|
||
private static readonly ResiliencePipeline _resiliencePipeline = new ResiliencePipelineBuilder() | ||
.AddRetry(new Polly.Retry.RetryStrategyOptions() | ||
{ | ||
BackoffType = DelayBackoffType.Linear, | ||
Delay = TimeSpan.FromSeconds(30), | ||
MaxRetryAttempts = 10 | ||
}) | ||
.Build(); | ||
|
||
public static TimeSpan[] RetryInvervals { get; } = | ||
[ | ||
TimeSpan.FromSeconds(5), | ||
TimeSpan.FromMinutes(5), | ||
TimeSpan.FromMinutes(30), | ||
TimeSpan.FromHours(2), | ||
TimeSpan.FromHours(5), | ||
TimeSpan.FromHours(10), | ||
TimeSpan.FromHours(14), | ||
TimeSpan.FromHours(20), | ||
TimeSpan.FromHours(24), | ||
]; | ||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
{ | ||
using var timer = new PeriodicTimer(_pollInterval); | ||
|
||
do | ||
{ | ||
try | ||
{ | ||
await _resiliencePipeline.ExecuteAsync( | ||
async (_, ct) => | ||
{ | ||
SendMessagesResult result; | ||
do | ||
{ | ||
result = await SendMessagesAsync(ct); | ||
} | ||
while (result.MoreRecords); | ||
}, | ||
stoppingToken); | ||
} | ||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) | ||
{ | ||
} | ||
} | ||
while (await timer.WaitForNextTickAsync(stoppingToken)); | ||
} | ||
|
||
public async Task<SendMessagesResult> SendMessagesAsync(CancellationToken cancellationToken = default) | ||
{ | ||
var startedAt = clock.UtcNow; | ||
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(); | ||
var txn = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); | ||
|
||
// Get the first batch of messages that are due to be sent. | ||
// Constrain the batch to `batchSize`, but return one more record so we know if there are more that need to be processed. | ||
var messages = await dbContext.WebhookMessages | ||
.FromSql($""" | ||
select * from webhook_messages | ||
where next_delivery_attempt <= {clock.UtcNow} | ||
order by next_delivery_attempt | ||
limit {BatchSize + 1} | ||
for update skip locked | ||
""") | ||
.Include(m => m.WebhookEndpoint) | ||
.ToArrayAsync(); | ||
|
||
var moreRecords = messages.Length > BatchSize; | ||
|
||
await Parallel.ForEachAsync( | ||
messages.Take(BatchSize), | ||
cancellationToken, | ||
async (message, ct) => | ||
{ | ||
ct.ThrowIfCancellationRequested(); | ||
|
||
var now = clock.UtcNow; | ||
message.DeliveryAttempts.Add(now); | ||
|
||
try | ||
{ | ||
await webhookSender.SendMessageAsync(message); | ||
|
||
message.Delivered = now; | ||
message.NextDeliveryAttempt = null; | ||
} | ||
catch (Exception ex) | ||
{ | ||
logger.LogWarning(ex, "Failed delivering webhook message."); | ||
|
||
message.DeliveryErrors.Add(ex.Message); | ||
|
||
if (message.DeliveryAttempts.Count <= RetryInvervals.Length) | ||
{ | ||
var nextRetryInterval = RetryInvervals[message.DeliveryAttempts.Count - 1]; | ||
message.NextDeliveryAttempt = now.Add(nextRetryInterval); | ||
|
||
// If next retry is due before we'll next be polling then ensure we return 'true' for MoreRecords. | ||
// (That ensures we won't have to wait for the timer to fire again before this message is retried.) | ||
var nextRun = startedAt.Add(_pollInterval); | ||
if (message.NextDeliveryAttempt < nextRun) | ||
{ | ||
moreRecords = true; | ||
} | ||
} | ||
else | ||
{ | ||
message.NextDeliveryAttempt = null; | ||
} | ||
} | ||
}); | ||
|
||
await dbContext.SaveChangesAsync(); | ||
await txn.CommitAsync(); | ||
|
||
return new(moreRecords); | ||
} | ||
|
||
public record SendMessagesResult(bool MoreRecords); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.