c72ebf9084
## Summary `batchTriggerAndWait()` could leave a parent run waiting forever. The 2-phase batch API blocks the parent on the batch's waitpoint as soon as the batch is created, but the batch is only sealed at the end of item streaming. If streaming never completed, nothing sealed the batch, nothing completed the waitpoint, and the parent stayed suspended with no timeout and no way to recover. Supersedes #4016, which added the reaper alone. ## Fix Admission for item streaming was being decided twice. Batch creation passes its own rate limiter, which fixes `expectedCount` and blocks the parent, and then the item stream had to pass the general API limiter as well, competing with unrelated traffic. A second limiter could therefore veto work the first had already committed the parent to. Creation now mints a bounded grant that the item stream spends, so an admitted batch can finish streaming. The grant is capped per batch rather than exempting the path, and every failure mode (no grant, spent grant, unreachable store) falls back to the normal limiter. That makes stranding much rarer but not impossible, since a request timeout or a crash can still end streaming for good. So a seal-timeout reaper aborts any batch still unsealed after `BATCH_SEAL_TIMEOUT_MS` and completes the parent's waitpoint with an error, letting `batchTriggerAndWait()` reject instead of hang. It is race-safe against a late seal, and it is only scheduled for batches that actually block a parent, so fire-and-forget batches cost nothing. Finally, the batches page used to report "Batch completion checked." for these batches while doing nothing, because the completion path returns early on an unsealed batch. It now says the batch cannot be resumed. Rate limiting is no longer the reason a batch strands, so the reaper's default stays at 30 minutes, comfortably above the SDK's worst-case stream-retry budget. ## Verification Unit and container tests cover the grant cap, the bypass ordering (it runs after the authorization check, so it can never skip authentication), and the reaper's abort, seal race, idempotency, and no-waitpoint cases. Also verified end-to-end against a running stack. With the general limit exhausted, batch creation and other API calls returned 429 while a granted batch still streamed and sealed; an ungranted batch id was rate limited rather than bypassed; and the grant cut off exactly at its configured attempt count. Reproducing the stranded state on a real parent run, the batch was aborted at the timeout, the waitpoint completed with an error, and the parent resumed and finished instead of hanging. A parentless batch left unsealed was untouched well past the reaper window. ## Verified against deployed runs The reaper was proven end to end with a real deployed run (locally-run supervisor, containerised run) and a real network fault, rather than a simulated one: toxiproxy severs the phase 2 item stream mid-flight so every SDK stream retry genuinely fails, while phase 1 still succeeds. Only the batch calls traverse the fault, so control-plane traffic is untouched. The reproduction is the shape that actually strands a parent: the task catches the `BatchTriggerError` the SDK throws and carries on, so the phase 1 block outlives the thrown error and the parent hangs at its next suspension point. With the reaper disabled, the parent sat in `EXECUTING_WITH_WAITPOINTS` for over 24 minutes holding two blockers, and stayed stuck across a full infrastructure restart: ``` type | status | has_timeout BATCH | PENDING | f <- orphan, completedAfter NULL DATETIME | COMPLETED | t <- the wait already elapsed ``` With the reaper enabled the same task under the same fault completed in about 75 seconds with zero blockers left, the batch `ABORTED`, and its waitpoint completed carrying the error. Two conditions are required to observe this at all, which is worth knowing for any future test: the run must be deployed rather than `trigger dev` (dev runs execute in process and finish while still holding blocker rows), and the wait after the caught error must exceed the checkpoint threshold, or it is served in process and never suspends. ### Why completing the batch waitpoint is sufficient `batchTriggerAndWait` runs create, then stream, then wait. A phase 2 failure throws before the wait is ever reached, and the reaper only fires on an unsealed batch, so the parent is never suspended awaiting the batch when it runs. The parent therefore does not need a synthetic result, only to stop being blocked. Note this reasoning depends on that ordering: if the wait were ever reached with an unsealed batch, completing the batch waitpoint alone would not settle the caller. ## Follow-ups - Batches stranded before this ships still need a one-off recovery; the reaper only schedules at creation time. - That same property leaves a gap if the process dies between creating the batch and scheduling the job. A periodic sweep would close it, but wants a supporting index. - When a partially streamed batch aborts, children already enqueued keep running while the parent fails. Left as-is deliberately, since cancelling triggered work is a bigger semantic call.
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
import { createRedisClient, type RedisClient, type RedisWithClusterOptions } from "~/redis.server";
|
|
import { logger } from "~/services/logger.server";
|
|
|
|
export type BatchStreamGrantsOptions = {
|
|
redis: RedisWithClusterOptions;
|
|
/** How many phase 2 requests a created batch is allowed. */
|
|
attempts: number;
|
|
/** How long the grant survives, matching how long a batch may legitimately be sealing. */
|
|
ttlMs: number;
|
|
};
|
|
|
|
const KEY_PREFIX = "batch-stream-grant:";
|
|
|
|
/**
|
|
* Admission for phase 2 of the 2-phase batch API.
|
|
*
|
|
* Phase 1 (`POST /api/v3/batches`) already passes its own batch rate limiter, which fixes
|
|
* the batch's `expectedCount` and blocks the parent run on the batch's waitpoint. Phase 2
|
|
* (`POST /api/v3/batches/:id/items`) is the only thing that can seal that batch, so having
|
|
* the general API limiter reject it strands the batch and the parent with it.
|
|
*
|
|
* Phase 1 therefore mints a bounded grant, and phase 2 spends it to bypass the general
|
|
* limiter. Admission stays a single decision made in phase 1, but the bypass is capped at
|
|
* `attempts` requests per batch rather than being unconditional.
|
|
*/
|
|
export class BatchStreamGrants {
|
|
private readonly redis: RedisClient;
|
|
|
|
constructor(private readonly options: BatchStreamGrantsOptions) {
|
|
this.redis = createRedisClient("batchStreamGrants", options.redis);
|
|
this.#registerCommands();
|
|
}
|
|
|
|
/**
|
|
* Grant a newly created batch its phase 2 budget. Never throws: a batch that fails to get
|
|
* a grant still works, it just falls back to the general rate limiter for streaming.
|
|
*/
|
|
async mint(environmentId: string, batchId: string): Promise<void> {
|
|
try {
|
|
await this.redis.set(
|
|
this.#key(environmentId, batchId),
|
|
this.options.attempts,
|
|
"PX",
|
|
this.options.ttlMs
|
|
);
|
|
} catch (error) {
|
|
logger.warn("BatchStreamGrants: failed to mint grant", {
|
|
batchId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume one phase 2 request from the batch's grant.
|
|
*
|
|
* Returns false when there is no grant, when the budget is spent, or when Redis is
|
|
* unreachable, so the caller falls back to the general rate limiter rather than opening
|
|
* an unbounded bypass.
|
|
*/
|
|
async spend(environmentId: string, batchId: string): Promise<boolean> {
|
|
try {
|
|
// @ts-expect-error - Custom command defined via defineCommand
|
|
const remaining = (await this.redis.spendBatchStreamGrant(
|
|
this.#key(environmentId, batchId)
|
|
)) as number;
|
|
|
|
return remaining >= 0;
|
|
} catch (error) {
|
|
logger.warn("BatchStreamGrants: failed to spend grant", {
|
|
batchId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async quit(): Promise<void> {
|
|
await this.redis.quit();
|
|
}
|
|
|
|
/**
|
|
* Scoped to the environment as well as the batch, so a caller authenticated against a
|
|
* different environment can never spend this batch's grant even if they know its id.
|
|
*/
|
|
#key(environmentId: string, batchId: string): string {
|
|
return `${KEY_PREFIX}${environmentId}:${batchId}`;
|
|
}
|
|
|
|
#registerCommands(): void {
|
|
this.redis.defineCommand("spendBatchStreamGrant", {
|
|
numberOfKeys: 1,
|
|
lua: `
|
|
local remaining = tonumber(redis.call('GET', KEYS[1]))
|
|
|
|
if not remaining or remaining <= 0 then
|
|
return -1
|
|
end
|
|
|
|
return redis.call('DECR', KEYS[1])
|
|
`,
|
|
});
|
|
}
|
|
}
|