Skip to content

Commit 9de498e

Browse files
committed
Merge remote-tracking branch 'origin/develop' into feature/pg-enum-refactor
2 parents f57cda6 + bb177cc commit 9de498e

5 files changed

Lines changed: 147 additions & 10 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using OpenShock.Common.Models;
3+
4+
namespace OpenShock.Common.OpenShockDb;
5+
6+
/// <summary>
7+
/// Reusable queries over the <see cref="EmailOutboxMessage"/> table. Kept here (rather than inline in the
8+
/// Cron delivery job) so the raw SQL is a single source of truth that integration tests can execute
9+
/// directly - a table or column rename then breaks the test rather than surfacing only in production.
10+
/// </summary>
11+
public static class EmailOutboxQueries
12+
{
13+
/// <summary>How many due rows the delivery job claims per batch.</summary>
14+
public const int ClaimBatchSize = 50;
15+
16+
/// <summary>
17+
/// The delivery job's claim query: due rows - <see cref="EmailStatus.Pending"/> past their
18+
/// next-attempt time, or <see cref="EmailStatus.Sending"/> whose lease has lapsed - oldest first,
19+
/// capped at <paramref name="batchSize"/>, locked with <c>FOR UPDATE SKIP LOCKED</c> so concurrent
20+
/// runs take disjoint batches. Run it inside an explicit transaction to hold the locks for the claim.
21+
/// </summary>
22+
public static IQueryable<EmailOutboxMessage> DueForDelivery(this DbSet<EmailOutboxMessage> outbox, int batchSize)
23+
{
24+
if (batchSize <= 0) throw new ArgumentOutOfRangeException(nameof(batchSize));
25+
26+
return outbox.FromSql(
27+
$"""
28+
SELECT * FROM email_outbox
29+
WHERE next_attempt_at <= now()
30+
AND (status = {EmailStatus.Pending} OR status = {EmailStatus.Sending})
31+
ORDER BY next_attempt_at
32+
LIMIT {batchSize}
33+
FOR UPDATE SKIP LOCKED
34+
""");
35+
}
36+
}

Common/OpenShockMiddlewareHelper.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,59 @@ public static async Task<IApplicationBuilder> ApplyPendingOpenShockMigrations(th
118118

119119
return app;
120120
}
121+
122+
/// <summary>
123+
/// Blocks startup until the database schema is up to date (no pending migrations), for hosts that do
124+
/// <em>not</em> own migrations (e.g. the Cron worker - the API is the sole migrator). This is not just
125+
/// tidiness: <see cref="OpenShockContext"/> binds Postgres enum types (<c>email_status</c>,
126+
/// <c>email_type</c>, ...) to CLR enums <em>by name</em>, and Npgsql resolves those names against the
127+
/// type catalog it loads on the data source's first connection and caches for the life of the process.
128+
/// If that first connection happens before the migrator has created a newly-added enum, every query
129+
/// using it fails permanently ("data type name '...' could not be found") until the process restarts.
130+
/// Waiting here guarantees the schema - and its enum types - exists before anything opens the pooled
131+
/// context, closing the deploy-time race without this host performing any schema writes of its own.
132+
/// </summary>
133+
public static async Task<IApplicationBuilder> WaitForOpenShockSchemaReady(this IApplicationBuilder app,
134+
DatabaseOptions options, TimeSpan? timeout = null)
135+
{
136+
using var scope = app.ApplicationServices.CreateScope();
137+
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
138+
var logger = loggerFactory.CreateLogger("SchemaReadyGate");
139+
140+
logger.LogInformation("Waiting for database schema to be up to date (migrations owned by another host)...");
141+
142+
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromMinutes(5));
143+
var delay = TimeSpan.FromSeconds(1);
144+
var maxDelay = TimeSpan.FromSeconds(15);
145+
146+
while (true)
147+
{
148+
try
149+
{
150+
await using var migrationContext = new MigrationOpenShockContext(options.Conn, options.Debug, loggerFactory);
151+
var pending = (await migrationContext.Database.GetPendingMigrationsAsync()).ToArray();
152+
if (pending.Length == 0)
153+
{
154+
logger.LogInformation("Database schema is up to date; proceeding with startup");
155+
return app;
156+
}
157+
158+
logger.LogWarning("Schema not ready: {Count} pending migration(s) [{Migrations}] not yet applied by the migrator",
159+
pending.Length, string.Join(", ", pending));
160+
}
161+
catch (Exception ex)
162+
{
163+
// Database not reachable yet, or the migrator is mid-run. Keep waiting rather than crash-looping.
164+
logger.LogWarning(ex, "Could not determine migration state; will retry");
165+
}
166+
167+
if (DateTime.UtcNow >= deadline)
168+
throw new TimeoutException(
169+
"Timed out waiting for the database schema to be brought up to date by the migrator. " +
170+
"Ensure the API host (the sole migrator) is running and reachable.");
171+
172+
await Task.Delay(delay);
173+
delay = TimeSpan.FromSeconds(Math.Min(maxDelay.TotalSeconds, delay.TotalSeconds * 2));
174+
}
175+
}
121176
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using OpenShock.Common.Models;
4+
using OpenShock.Common.OpenShockDb;
5+
6+
namespace OpenShock.Cron.IntegrationTests.Tests;
7+
8+
/// <summary>
9+
/// Runs the delivery job's actual claim query (<see cref="EmailOutboxQueries.DueForDelivery"/>, the single
10+
/// source of truth the job uses) against real Postgres via the Cron host's own pooled context - the same
11+
/// registration + enum-mapping path that runs in production. Guards the raw SQL (table/column names, the
12+
/// email_status enum, LIMIT, FOR UPDATE SKIP LOCKED, SELECT * materialization) so a schema rename breaks
13+
/// this test rather than only surfacing in the Cron host at runtime.
14+
/// </summary>
15+
public sealed class EmailOutboxQueryTests
16+
{
17+
[ClassDataSource<CronApplicationFactory>(Shared = SharedType.PerTestSession)]
18+
public required CronApplicationFactory Factory { get; init; }
19+
20+
[Test]
21+
public async Task DueForDelivery_RunsTheJobsRawSql_AgainstTheRealSchema()
22+
{
23+
var recipient = $"outbox-claim-sql-{Guid.CreateVersion7():N}@test.org";
24+
25+
Guid id;
26+
await using (var db = await Factory.DbContextFactory.CreateDbContextAsync())
27+
{
28+
var message = EmailOutboxMessage.Create(
29+
EmailType.PasswordReset, recipient, "Claim",
30+
new Dictionary<string, string> { ["k"] = "v" });
31+
// Dated well into the past so it sorts first and the global LIMIT can never exclude it.
32+
message.NextAttemptAt = DateTime.UtcNow - TimeSpan.FromDays(1);
33+
db.EmailOutbox.Add(message);
34+
await db.SaveChangesAsync();
35+
id = message.Id;
36+
}
37+
38+
// Resolve the pooled OpenShockContext exactly as the Cron delivery job does (DI-injected) so this
39+
// covers the same registration + enum-mapping path that runs in production.
40+
using var scope = Factory.Services.CreateScope();
41+
var db2 = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
42+
43+
var claimed = await db2.EmailOutbox.DueForDelivery(1).ToListAsync();
44+
45+
await Assert.That(claimed.Any(m => m.Id == id)).IsTrue();
46+
}
47+
}

Cron/Jobs/EmailOutboxDeliveryJob.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ namespace OpenShock.Cron.Jobs;
2525
[CronJob("* * * * *")] // Every minute (https://crontab.guru/)
2626
public sealed class EmailOutboxDeliveryJob
2727
{
28-
private const int BatchSize = 50;
28+
private const int BatchSize = EmailOutboxQueries.ClaimBatchSize;
2929

3030
private readonly OpenShockContext _db;
3131
private readonly IEmailOutboxDispatcher _dispatcher;
@@ -88,15 +88,7 @@ private async Task<List<Guid>> ClaimDueBatchAsync()
8888

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

91-
var due = await _db.EmailOutbox.FromSql(
92-
$"""
93-
SELECT * FROM email_outbox
94-
WHERE next_attempt_at <= now()
95-
AND (status = {EmailStatus.Pending} OR status = {EmailStatus.Sending})
96-
ORDER BY next_attempt_at
97-
LIMIT {BatchSize}
98-
FOR UPDATE SKIP LOCKED
99-
""").ToListAsync();
91+
var due = await _db.EmailOutbox.DueForDelivery(BatchSize).ToListAsync();
10092

10193
if (due.Count == 0)
10294
{

Cron/Program.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@
4444

4545
await app.UseCommonOpenShockMiddleware();
4646

47+
// The Cron host does not own migrations (the API is the sole migrator). Its OpenShockContext binds
48+
// Postgres enum types by name at the pooled data source's first connection and caches them for the
49+
// process's life, so it must not open that context before a newly-added enum exists - otherwise every
50+
// claim query fails permanently ("data type name 'email_status' could not be found"). Block until the
51+
// migrator has applied all pending migrations, which happens before Hangfire or any job runs below.
52+
await app.WaitForOpenShockSchemaReady(databaseOptions);
53+
4754
var hangfireOptions = new DashboardOptions();
4855
if (app.Environment.IsProduction() || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true")
4956
{

0 commit comments

Comments
 (0)