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));