Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions Common/OpenShockDb/EmailOutboxQueries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using OpenShock.Common.Models;

namespace OpenShock.Common.OpenShockDb;

/// <summary>
/// Reusable queries over the <see cref="EmailOutboxMessage"/> table. Kept here (rather than inline in the
/// Cron delivery job) so the raw SQL is a single source of truth that integration tests can execute
/// directly - a table or column rename then breaks the test rather than surfacing only in production.
/// </summary>
public static class EmailOutboxQueries
{
/// <summary>How many due rows the delivery job claims per batch.</summary>
public const int ClaimBatchSize = 50;

/// <summary>
/// The delivery job's claim query: due rows - <see cref="EmailStatus.Pending"/> past their
/// next-attempt time, or <see cref="EmailStatus.Sending"/> whose lease has lapsed - oldest first,
/// capped at <paramref name="batchSize"/>, locked with <c>FOR UPDATE SKIP LOCKED</c> so concurrent
/// runs take disjoint batches. Run it inside an explicit transaction to hold the locks for the claim.
/// </summary>
public static IQueryable<EmailOutboxMessage> DueForDelivery(this DbSet<EmailOutboxMessage> outbox, int batchSize)
{
if (batchSize <= 0) throw new ArgumentOutOfRangeException(nameof(batchSize));

return outbox.FromSql(
$"""
SELECT * FROM email_outbox
WHERE next_attempt_at <= now()
AND (status = {EmailStatus.Pending} OR status = {EmailStatus.Sending})
ORDER BY next_attempt_at
LIMIT {batchSize}
FOR UPDATE SKIP LOCKED
""");
}
}
47 changes: 47 additions & 0 deletions Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;

namespace OpenShock.Cron.IntegrationTests.Tests;

/// <summary>
/// Runs the delivery job's actual claim query (<see cref="EmailOutboxQueries.DueForDelivery"/>, the single
/// source of truth the job uses) against real Postgres via the Cron host's own pooled context - the same
/// registration + enum-mapping path that runs in production. Guards the raw SQL (table/column names, the
/// email_status enum, LIMIT, FOR UPDATE SKIP LOCKED, SELECT * materialization) so a schema rename breaks
/// this test rather than only surfacing in the Cron host at runtime.
/// </summary>
public sealed class EmailOutboxQueryTests
{
[ClassDataSource<CronApplicationFactory>(Shared = SharedType.PerTestSession)]
public required CronApplicationFactory Factory { get; init; }

[Test]
public async Task DueForDelivery_RunsTheJobsRawSql_AgainstTheRealSchema()
{
var recipient = $"outbox-claim-sql-{Guid.CreateVersion7():N}@test.org";

Guid id;
await using (var db = await Factory.DbContextFactory.CreateDbContextAsync())
{
var message = EmailOutboxMessage.Create(
EmailType.PasswordReset, recipient, "Claim",
new Dictionary<string, string> { ["k"] = "v" });
// Dated well into the past so it sorts first and the global LIMIT can never exclude it.
message.NextAttemptAt = DateTime.UtcNow - TimeSpan.FromDays(1);
db.EmailOutbox.Add(message);
await db.SaveChangesAsync();
id = message.Id;
}

// Resolve the pooled OpenShockContext exactly as the Cron delivery job does (DI-injected) so this
// covers the same registration + enum-mapping path that runs in production.
using var scope = Factory.Services.CreateScope();
var db2 = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var claimed = await db2.EmailOutbox.DueForDelivery(1).ToListAsync();

await Assert.That(claimed.Any(m => m.Id == id)).IsTrue();
}
}
12 changes: 2 additions & 10 deletions Cron/Jobs/EmailOutboxDeliveryJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ namespace OpenShock.Cron.Jobs;
[CronJob("* * * * *")] // Every minute (https://crontab.guru/)
public sealed class EmailOutboxDeliveryJob
{
private const int BatchSize = 50;
private const int BatchSize = EmailOutboxQueries.ClaimBatchSize;

private readonly OpenShockContext _db;
private readonly IEmailOutboxDispatcher _dispatcher;
Expand Down Expand Up @@ -88,15 +88,7 @@ private async Task<List<Guid>> ClaimDueBatchAsync()

await using var transaction = await _db.Database.BeginTransactionAsync();

var due = await _db.EmailOutbox.FromSql(
$"""
SELECT * FROM email_outbox
WHERE next_attempt_at <= now()
AND (status = {EmailStatus.Pending} OR status = {EmailStatus.Sending})
ORDER BY next_attempt_at
LIMIT {BatchSize}
FOR UPDATE SKIP LOCKED
""").ToListAsync();
var due = await _db.EmailOutbox.DueForDelivery(BatchSize).ToListAsync();

if (due.Count == 0)
{
Expand Down
Loading