diff --git a/src/WidgetWorks.Application/Checkout/ReleaseStale/ReleaseStaleReservationsHandler.cs b/src/WidgetWorks.Application/Checkout/ReleaseStale/ReleaseStaleReservationsHandler.cs index 762391e..7e89c1b 100644 --- a/src/WidgetWorks.Application/Checkout/ReleaseStale/ReleaseStaleReservationsHandler.cs +++ b/src/WidgetWorks.Application/Checkout/ReleaseStale/ReleaseStaleReservationsHandler.cs @@ -14,13 +14,23 @@ public sealed class ReservationOptions /// /// This is a trade, not a tuning knob: too short and a slow but honest bank redirect loses a /// customer's basket; too long and abandoned or abusive orders hold the catalogue hostage. - /// Fifteen minutes is longer than any interactive redirect and short enough that a shopper who - /// returns to an out-of-stock item is rare. + /// + /// Held at 90 minutes so it stays longer than the sweep interval below (the sweep must run more + /// often than the window, or expired orders sit unreleased). The interval is set for cost — see + /// there — and on this demo deployment no real inventory is at stake, so the longer hold is + /// harmless. A production store with scarce stock should shorten both together, or move to + /// on-access release, rather than shorten this alone. /// - public int ExpireAfterMinutes { get; set; } = 15; + public int ExpireAfterMinutes { get; set; } = 90; - /// How often the sweep runs. - public int SweepIntervalMinutes { get; set; } = 5; + /// + /// How often the sweep runs. Held at 60 minutes on purpose: the sweep queries Postgres each + /// tick, and Neon's serverless compute auto-suspends after ~5 minutes idle, so a shorter + /// interval pins the database awake around the clock (~180 CU-hrs/month, nearly double the free + /// allowance). Hourly lets Neon sleep between passes (~15 CU-hrs/month) while still reclaiming + /// abandoned-checkout stock well within the window above. Must stay below ExpireAfterMinutes. + /// + public int SweepIntervalMinutes { get; set; } = 60; /// /// Most orders released in one pass. A backlog is worked through over several sweeps rather diff --git a/src/WidgetWorks.WebApi/appsettings.json b/src/WidgetWorks.WebApi/appsettings.json index 7bdf764..559ca5c 100644 --- a/src/WidgetWorks.WebApi/appsettings.json +++ b/src/WidgetWorks.WebApi/appsettings.json @@ -36,8 +36,8 @@ }, "Reservations": { "Enabled": true, - "ExpireAfterMinutes": 15, - "SweepIntervalMinutes": 5, + "ExpireAfterMinutes": 90, + "SweepIntervalMinutes": 60, "BatchSize": 100 } } diff --git a/tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs b/tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs index e9d6c6f..fbe8428 100644 --- a/tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs +++ b/tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs @@ -140,7 +140,9 @@ public async Task ReleaseStaleReservations_AWebhookSettlesAnOrderMidSweep_Counts h.Orders, (inner, _) => inner.MarkPaidAsync(settled.Id, "Mock", "pi_settled", Now, CancellationToken.None)); var handler = new ReleaseStaleReservationsHandler( - raced, new FakeTimeProvider(Now), new ReservationOptions(), NullLogger.Instance); + // Explicit 15-minute window so the ~30/40-min-old orders above stay sweepable + // regardless of the production default (tuned longer for Neon cost). + raced, new FakeTimeProvider(Now), new ReservationOptions { ExpireAfterMinutes = 15, SweepIntervalMinutes = 5 }, NullLogger.Instance); Assert.Equal(5, h.Widgets.Store[h.WidgetId].QuantityReserved); diff --git a/tests/WidgetWorks.UnitTests/ReleaseStaleReservationsTests.cs b/tests/WidgetWorks.UnitTests/ReleaseStaleReservationsTests.cs index 0d119f3..6dbc5ad 100644 --- a/tests/WidgetWorks.UnitTests/ReleaseStaleReservationsTests.cs +++ b/tests/WidgetWorks.UnitTests/ReleaseStaleReservationsTests.cs @@ -41,7 +41,9 @@ private static Harness Build(ReservationOptions? options = null) var handler = new ReleaseStaleReservationsHandler( orders, new FakeTimeProvider(Now), - options ?? new ReservationOptions(), + // Explicit 15-minute window so these behaviour tests stay fixed to the offsets they set + // (orders parked ~30 min ago), independent of the production default tuned for Neon cost. + options ?? new ReservationOptions { ExpireAfterMinutes = 15, SweepIntervalMinutes = 5 }, NullLogger.Instance); return new Harness(orders, widgets, handler, widgetId); @@ -135,7 +137,7 @@ public async Task A_sweep_is_safe_to_run_twice() [Fact] public async Task One_pass_takes_no_more_than_the_batch_size() { - var h = Build(new ReservationOptions { BatchSize = 2 }); + var h = Build(new ReservationOptions { BatchSize = 2, ExpireAfterMinutes = 15, SweepIntervalMinutes = 5 }); for (var i = 0; i < 5; i++) { await GivenUnsettledOrder(h, quantity: 1, updatedAt: Now.AddMinutes(-30 - i)); @@ -151,7 +153,7 @@ public async Task One_pass_takes_no_more_than_the_batch_size() [Fact] public async Task The_oldest_unsettled_orders_are_released_first() { - var h = Build(new ReservationOptions { BatchSize = 1 }); + var h = Build(new ReservationOptions { BatchSize = 1, ExpireAfterMinutes = 15, SweepIntervalMinutes = 5 }); var oldest = await GivenUnsettledOrder(h, quantity: 1, updatedAt: Now.AddHours(-3)); await GivenUnsettledOrder(h, quantity: 1, updatedAt: Now.AddMinutes(-20));