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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public int ExpireAfterMinutes { get; set; } = 15;
public int ExpireAfterMinutes { get; set; } = 90;

/// <summary>How often the sweep runs.</summary>
public int SweepIntervalMinutes { get; set; } = 5;
/// <summary>
/// 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.
/// </summary>
public int SweepIntervalMinutes { get; set; } = 60;

/// <summary>
/// Most orders released in one pass. A backlog is worked through over several sweeps rather
Expand Down
4 changes: 2 additions & 2 deletions src/WidgetWorks.WebApi/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
},
"Reservations": {
"Enabled": true,
"ExpireAfterMinutes": 15,
"SweepIntervalMinutes": 5,
"ExpireAfterMinutes": 90,
"SweepIntervalMinutes": 60,
"BatchSize": 100
}
}
4 changes: 3 additions & 1 deletion tests/WidgetWorks.UnitTests/ConcurrentSettlementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReleaseStaleReservationsHandler>.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<ReleaseStaleReservationsHandler>.Instance);

Assert.Equal(5, h.Widgets.Store[h.WidgetId].QuantityReserved);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReleaseStaleReservationsHandler>.Instance);

return new Harness(orders, widgets, handler, widgetId);
Expand Down Expand Up @@ -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));
Expand All @@ -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));

Expand Down