Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 35 additions & 0 deletions dotnet/EcencyApi.Tests/CheckinGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,41 @@ public void AReservedAnchorAbsorbsTheNextCheckinAndThenReleases()
Assert.True(CheckinGate.DecideAndReserve(username, nowMs + ClientPollIntervalMs).Forward);
}

[Fact]
public void AnUndeliveredCheckinGivesTheAnchorBack()
{
// The anchor is claimed before the upstream call. If the check-in never
// landed, holding it would absorb the account's next attempt on the
// strength of one that never happened.
var username = "release-" + Guid.NewGuid().ToString("n");
var nowMs = 1_700_000_000_000;

var reserved = CheckinGate.DecideAndReserve(username, nowMs);
Assert.NotNull(reserved.StampToStore);

CheckinGate.Release(username, reserved.StampToStore!);

Assert.True(CheckinGate.DecideAndReserve(username, nowMs + 1).Forward);
}

[Fact]
public void AReleaseCannotDiscardALaterAccountsAnchor()
{
// A release names the exact anchor it claimed, so a stale one arriving
// after the account has checked in again is a no-op.
var username = "stale-" + Guid.NewGuid().ToString("n");
var nowMs = 1_700_000_000_000;

var stale = CheckinGate.DecideAndReserve(username, nowMs).StampToStore!;
var current = CheckinGate.DecideAndReserve(username, nowMs + ClientPollIntervalMs).StampToStore!;
Assert.NotEqual(stale, current);

CheckinGate.Release(username, stale);

// The live anchor survives, so the window it opened still holds.
Assert.False(CheckinGate.DecideAndReserve(username, nowMs + ClientPollIntervalMs + 1).Forward);
}

[Fact]
public void ABurstFromOneAccountStillCollapsesToOneUpstreamCall()
{
Expand Down
15 changes: 15 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public static async Task Activities(HttpContext ctx)
// ty === 10 (strict: JSON number equal to 10)
var tyIsTen = ty is JsonValue tyVal && tyVal.TryGetValue<double>(out var tyNum) && tyNum == 10;

string? reservedAnchor = null;

if (tyIsTen)
{
// Keyed on the account, not the caller's address: see CheckinGate for why
Expand All @@ -96,6 +98,8 @@ public static async Task Activities(HttpContext ctx)
await ctx.SendJson(201, new JsonObject());
return;
}

reservedAnchor = decision.StampToStore;
}

var pipeJson = new JsonObject
Expand All @@ -119,6 +123,17 @@ public static async Task Activities(HttpContext ctx)
}

await Upstream.Pipe(ApiClient.ApiRequest("usr-activity", HttpMethod.Post, null, pipeJson), ctx);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

// The anchor is claimed before the call, which is what closes the burst
// race. If the check-in then never reached the backend, give it back
// rather than absorb this account's next attempt on the strength of one
// that never landed. Pipe turns a transport failure into 504/500, so a
// 5xx here is exactly the "not delivered" set: an upstream 4xx is a
// deliberate rejection that a retry would not change.
if (reservedAnchor != null && ctx.Response.StatusCode >= 500)
{
CheckinGate.Release(username, reservedAnchor);
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}

public static async Task SubscribeNewsletter(HttpContext ctx)
Expand Down
33 changes: 33 additions & 0 deletions dotnet/EcencyApi/Infrastructure/CheckinGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,39 @@ public static Decision DecideAndReserve(string username, long nowMs)
}
}

/// <summary>
/// Gives up an anchor whose check-in never reached the backend.
///
/// The anchor is reserved before the upstream call, because that is what
/// closes the burst race. If the call then fails to deliver, holding the
/// anchor would absorb the account's next attempt on the strength of a
/// check-in that never happened, which is the failure this whole gate is
/// being fixed for. Releasing puts the account back where it started.
///
/// Only an anchor still holding <paramref name="stamp"/> is removed, so this
/// can never discard one a later check-in established.
/// </summary>
public static void Release(string username, string stamp)
{
var key = CacheKey(username);

lock (StripeFor(key))
{
try
{
if (MemCache.Get<string>(key) == stamp)
{
MemCache.Del(key);
}
}
catch (Exception e)
{
Console.Error.WriteLine(e);
Console.Error.WriteLine("Cache release failed.");
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
}
}

private const int StripeCount = 64;

private static readonly object[] Stripes =
Expand Down
Loading