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
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
34 changes: 33 additions & 1 deletion 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 @@ -118,7 +122,35 @@ public static async Task Activities(HttpContext ctx)
pipeJson["tx"] = tx!.DeepClone();
}

await Upstream.Pipe(ApiClient.ApiRequest("usr-activity", HttpMethod.Post, null, pipeJson), ctx);
// 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.
var upstreamStarted = false;
try
{
// ApiRequest builds the auth headers eagerly and throws on a
// misconfigured deployment, so the request can fail before Pipe is
// ever entered. That is a check-in the backend never saw.
var upstream = ApiClient.ApiRequest("usr-activity", HttpMethod.Post, null, pipeJson);
upstreamStarted = true;
await Upstream.Pipe(upstream, ctx);
}
finally
{
// Pipe maps a transport failure to 504/500, so a 5xx is the "never
// reached the backend" set, as is a request that never started. An
// upstream 4xx is a deliberate rejection that a retry would not
// change, so it keeps the anchor. So does a backend answer that only
// failed on the way back to a client that went away: the check-in
// landed, and SendLikeExpress sets the upstream status before it
// writes, so the status still reports that here. The release has to
// sit in a finally, because Pipe can throw out of the write itself.
if (reservedAnchor != null && (!upstreamStarted || ctx.Response.StatusCode >= 500))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backend 5xx Releases Delivered Check-In

If the backend receives and processes a check-in but returns a 5xx response, Upstream.Pipe propagates that status and this condition treats it as non-delivery. The anchor is removed, so an immediate retry is forwarded and can duplicate a check-in the backend already received or credited.

Fix in Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and intentional. A backend that recorded the check-in and then answered 5xx does lose its anchor here.

The cost of that is one extra upstream call: the next check-in forwards early, and the backend's own per-account spacing refuses it as too close. No double credit, because this gate is not what decides credit.

The cost of the other choice is an account losing a check-in and its streak, silently, because the gate answers 201. That asymmetry is the whole reason this endpoint is being fixed, so every boundary case here resolves toward forwarding. It is written up as an invariant at the top of CheckinGate rather than left as an accident.

There is also no signal that would let the gate do better. A 5xx cannot be split into "recorded then failed" and "never recorded" from this side, and even a clean 2xx does not mean credited: the backend's verifier decides that asynchronously, after the response.

{
CheckinGate.Release(username, reservedAnchor);
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

public static async Task SubscribeNewsletter(HttpContext ctx)
Expand Down
41 changes: 39 additions & 2 deletions dotnet/EcencyApi/Infrastructure/CheckinGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ public static Decision Decide(string? recorded, long nowMs)
/// The three have to happen together. Two check-ins for one account can
/// arrive in the same instant (two tabs opening at once both fire the ping
/// their page load schedules). If both read the anchor before either writes,
/// both are forwarded, which is the burst this gate exists to collapse. Serializing them makes a concurrent duplicate behave exactly
/// like a sequential one: the second reads the anchor the first just wrote
/// both are forwarded, which is the burst this gate exists to collapse.
/// Serializing them makes a concurrent duplicate behave exactly like a
/// sequential one: the second reads the anchor the first just wrote
/// and is absorbed. That stays correct in the direction this gate cares
/// about, because a check-in milliseconds behind another is one the backend
/// refuses regardless.
Expand Down Expand Up @@ -172,6 +173,42 @@ 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
{
// Deliberately silent as well as swallowed. This runs after the
// response has been written, so letting it escape would raise an
// error the client can no longer be told about. A cache throwing
// here is already saying so from the read and the write on the way
// in. A request handler should not be adding logging of its own.
}
}
}

private const int StripeCount = 64;

private static readonly object[] Stripes =
Expand Down
Loading