From 8bd4d0ecc29c8f06a4f379d3d720a544e49fdc48 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:43:37 -0500 Subject: [PATCH 1/3] fix(cost): widen reservation sweep to hourly so Neon can auto-suspend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ReservationSweeper queried Postgres every SweepIntervalMinutes=5. Neon's compute auto-suspends after ~5 minutes idle, so a query every 5 minutes reset the idle timer just before it could sleep — holding the database awake ~24/7. At Neon's 0.25 CU floor that is ~180 CU-hrs/month, nearly double the 100 CU-hr free allowance (the project hit 80% mid-month). The sweep only reclaims stock from orders left in AwaitingPayment past ExpireAfterMinutes (15) — pure housekeeping, not latency-sensitive. Widening it to hourly lets Neon sleep between passes (~5 min awake per sweep, ~15 CU-hrs/mo, a ~12x cut) while still returning abandoned-checkout stock within ~75 minutes. No code change — config only. keep-warm still pings /health (no DB query), so the API stays warm without holding the database open. Co-Authored-By: Claude Opus 5 --- src/WidgetWorks.WebApi/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WidgetWorks.WebApi/appsettings.json b/src/WidgetWorks.WebApi/appsettings.json index 7bdf764..6ad7e26 100644 --- a/src/WidgetWorks.WebApi/appsettings.json +++ b/src/WidgetWorks.WebApi/appsettings.json @@ -37,7 +37,7 @@ "Reservations": { "Enabled": true, "ExpireAfterMinutes": 15, - "SweepIntervalMinutes": 5, + "SweepIntervalMinutes": 60, "BatchSize": 100 } } From db95d217545e14aef7fd120ad18493e36ac10054 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:53:13 -0500 Subject: [PATCH 2/3] fix(cost): sync code defaults and lengthen window to satisfy the invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass changed only appsettings, which broke two guard tests: ShippedConfigurationTests requires the file to match the code defaults, and another test requires ExpireAfterMinutes > SweepIntervalMinutes (stock must be reclaimed faster than the window or expired orders sit unreleased). So the code defaults move with the file: SweepIntervalMinutes 5 -> 60 and ExpireAfterMinutes 15 -> 90 (window stays above the interval). Comments updated to explain both — the interval is set by Neon's ~5-min auto-suspend (a shorter one pins the DB awake ~24/7), and the longer hold is harmless on this demo where no real inventory is at stake. Verified locally: ShippedConfigurationTests and ReservationSweeperTests pass (7/7). Co-Authored-By: Claude Opus 5 --- .../ReleaseStaleReservationsHandler.cs | 20 ++++++++++++++----- src/WidgetWorks.WebApi/appsettings.json | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) 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 6ad7e26..559ca5c 100644 --- a/src/WidgetWorks.WebApi/appsettings.json +++ b/src/WidgetWorks.WebApi/appsettings.json @@ -36,7 +36,7 @@ }, "Reservations": { "Enabled": true, - "ExpireAfterMinutes": 15, + "ExpireAfterMinutes": 90, "SweepIntervalMinutes": 60, "BatchSize": 100 } From c5c4043be68dcd25721620b77831f6bed803fd15 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:01:32 -0500 Subject: [PATCH 3/3] test: pin handler tests to an explicit window, not the prod default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising the default ExpireAfterMinutes to 90 (for Neon cost) broke handler tests that park orders ~30 min ago and expect a release — correct against the old 15-min default, stale-but-inside-window against 90. These are behaviour tests of "past the threshold -> released", so they should fix their own window rather than ride on the production default. Each now sets ExpireAfterMinutes = 15 explicitly. Verified: WidgetWorks.UnitTests 369/369. (The reservation/config guard tests in ApiTests already pass, 7/7.) Co-Authored-By: Claude Opus 5 --- tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs | 4 +++- .../ReleaseStaleReservationsTests.cs | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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));