fix(webapp): bump applyMetadataMutation retry count + add jittered backoff
Default maxRetries was 3, matching the PG-side UpdateMetadataService. That's fine when the only writer is the executing task itself, but under high external-API concurrency on a single buffered run it exhausts fast — the Phase F challenge suite saw 50-way concurrent metadata.increment landing only 21/50 deltas with the default. Bumps the default to 12 (covers ~50-way concurrency with sub-percent failure) and adds small jittered backoff between retries so a thundering herd of N retriers doesn't all re-read + re-CAS in lockstep. Each retry is one Redis Lua call (~1ms), so the worst-case budget is bounded. Verified via challenge script 09: 50 concurrent increments now land all 50 deltas, counter ends at exactly 50.
This commit is contained in:
@@ -26,7 +26,14 @@ export async function applyMetadataMutationToBufferedRun(input: {
|
|||||||
const buffer = input.buffer ?? getMollifierBuffer();
|
const buffer = input.buffer ?? getMollifierBuffer();
|
||||||
if (!buffer) return { kind: "not_found" };
|
if (!buffer) return { kind: "not_found" };
|
||||||
|
|
||||||
const maxRetries = input.maxRetries ?? 3;
|
// Default retry budget tuned for buffered-window concurrency. The
|
||||||
|
// PG-side `UpdateMetadataService` uses 3, which is fine when the only
|
||||||
|
// writer is the executing task itself. For a buffered run the writers
|
||||||
|
// are external API callers, and N parallel writers exhaust 3 retries
|
||||||
|
// quickly under contention. Bumping to 12 covers ~50-way concurrency
|
||||||
|
// with sub-percent failure probability; the cost is bounded (each
|
||||||
|
// retry is one Redis Lua call ~1ms).
|
||||||
|
const maxRetries = input.maxRetries ?? 12;
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
const entry = await buffer.getEntry(input.runId);
|
const entry = await buffer.getEntry(input.runId);
|
||||||
if (!entry) return { kind: "not_found" };
|
if (!entry) return { kind: "not_found" };
|
||||||
@@ -73,13 +80,16 @@ export async function applyMetadataMutationToBufferedRun(input: {
|
|||||||
if (cas.kind === "not_found") return { kind: "not_found" };
|
if (cas.kind === "not_found") return { kind: "not_found" };
|
||||||
if (cas.kind === "busy") return { kind: "busy" };
|
if (cas.kind === "busy") return { kind: "busy" };
|
||||||
// version_conflict — another caller wrote between our read + CAS.
|
// version_conflict — another caller wrote between our read + CAS.
|
||||||
// Loop to re-read and retry.
|
// Small jittered backoff so a thundering herd of N retriers doesn't
|
||||||
|
// all re-read + re-CAS at exactly the same moment.
|
||||||
logger.debug("applyMetadataMutationToBufferedRun: version_conflict, retrying", {
|
logger.debug("applyMetadataMutationToBufferedRun: version_conflict, retrying", {
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
attempt,
|
attempt,
|
||||||
observedVersion: entry.metadataVersion,
|
observedVersion: entry.metadataVersion,
|
||||||
currentVersion: cas.currentVersion,
|
currentVersion: cas.currentVersion,
|
||||||
});
|
});
|
||||||
|
const backoffMs = Math.floor(Math.random() * (5 + attempt * 5));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn("applyMetadataMutationToBufferedRun: retries exhausted", {
|
logger.warn("applyMetadataMutationToBufferedRun: retries exhausted", {
|
||||||
|
|||||||
Reference in New Issue
Block a user