Status: Shipped (behind ASYNC_EXECUTION_ENABLED, default off)
Decision record: D62, D66 (gated async)
Supersedes nothing. Extends D56 (call timeouts).
POST /v1/actions/call is synchronous end to end. D56 made the wait
configurable but clamped it at CALL_TIMEOUT_MAX_MS (110000), because Cloud
Run cuts every request at request_timeout_seconds = 120. Raising that is
possible but does not scale: a 10-minute Metabase export is not a
request-shaped thing, and no proxy timeout is the right place to express it.
D56 deliberately refused to auto-promote an over-ceiling call to a background one, because that would change the response shape based on a number in a template the caller never saw. It returns a 400 naming the ceiling instead. This document describes what that 400 points at.
The obvious reading of the infrastructure is wrong, and the correction matters enough to record.
infra/modules/cloud-run/main.tf never sets cpu_idle, which looks like the
API is on the Cloud Run v2 throttled default — CPU allocated only during a
request, and any detached task starved. It is not. The Terraform provider
defaults cpu_idle to true only when the resources block is absent;
this service specifies resources, so an omitted cpu_idle is sent as false
(provider #17246).
The sibling cloud-run-shortener and cloud-run-overfwd modules set
cpu_idle = true explicitly to opt into throttling, which is what lifts
Cloud Run's 512Mi floor and lets them run at 256Mi — both say so in comments.
This API sits at 512Mi/1Gi precisely because it is not throttled.
Consequences: background work on the API instance already works, the 60s
maintenance loop and events::emit_all and spawn_auto_call were never
silently throttled, and no Terraform apply gates this feature.
What does bind is scale-in. cpu_idle = false guarantees CPU while the
instance exists; it does not keep the instance alive.
- Cloud Run's autoscaler is request-driven. A container doing background work
with no in-flight requests still reads as idle, and a
pendingrow in Postgres creates no scale-out pressure — Cloud Run cannot see the queue. - Prod runs
min_instances = 1, so one instance persists and background work there is a supported pattern. - Dev ran
min_instances = 0. With no instance, nothing drains the queue and a job would sit until unrelated traffic warmed one. Dev moves to 1. max_instances = 3means three replicas sweeping concurrently.- Scale-in and every revision rollout send SIGTERM, then SIGKILL ~10s later.
So instance death is the normal case, not the exceptional one. That single
fact rules out fire-and-forget tokio::spawn and forces the work into a
durable row under a renewable lease, so a killed job is late, never lost.
execution: "sync" | "async" on CallRequest. Named execution rather than
async because async is a Rust keyword and reserved in JS/TS, which would
make the field unnameable in generated clients.
Async returns 202 {"status": "accepted"} with an execution_id.
CallResponse is already #[serde(tag = "status")], so this is an additive
variant. Note 202 is also what pending_approval returns — two bodies under
one status code, disambiguated by status, which is the documented contract
everywhere else in this API.
After the entire validation and authorisation pipeline, and before every
dispatch fork. That position is the whole argument for a field on CallRequest
rather than a new endpoint: an async call inherits alias resolution, instance
config, coercion, validate_args, the D42 SQL policy, owner impersonation, the
group ceiling, the deny screen, the permission-chain walk, and D56 timeout
resolution — unchanged, and with no possibility of drift. Placing it above the
gate would let an agent launch an ungated background call; placing it below
resolve_request means a fumbled parameter is a synchronous 400 rather than an
async row that fails 200ms later out of band.
A gated async call returns the ordinary pending_approval envelope, not a 202.
The gate fires before the fork, and the two are different axes: an agent must
be able to tell "queued" from "waiting on a human" without a second field.
What the caller asked for is stamped on approvals.execution_mode, and read
back when the replay is triggered — by POST /v1/approvals/{id}/call or by
spawn_auto_call. Triggering an async approval enqueues it: an UPDATE lifts
approvals.replay_payload into executions.request on the row that was already
created when the approval was allowed, and the worker claims it on its next
tick. /call answers 202 with the ordinary ApprovalResponse, carrying
execution_mode, poll_after_ms, and the queued execution — not the accepted
envelope, because the dashboard, MCP and the CLI all already parse this shape
and a second body under one route would be a silent client break.
Trigger time rather than approve time, because auto_call_on_approve = false
means "nothing runs until the agent says so", and queueing at approve time would
run it anyway. It also keeps services::inbox's ready_to_call honest: a queued
row sits in pending, so the inbox would otherwise tell an agent to dispatch a
row the worker already owns. ExecutionSummary grew a queued flag for exactly
that distinction, and the inbox, the approval page and the queue row all branch
on it.
The load-bearing detail is one predicate. The enqueue leaves the row pending,
which is precisely what claim_for_execution accepts, so the synchronous claim
gained AND request IS NULL. Without it a manual /call could dial inline
while a worker dialled the same row — two upstream calls, and there are no
idempotency keys. The two triggers are now mutually exclusive by predicate rather
than by timing.
An approval whose replay_payload predates the column has nothing to hand the
worker: the enqueue matches no row, and /call falls back to the inline
synchronous replay. A deployment that has since turned the flag off does the
same — the stamp records intent, the flag decides.
On the worker, an approval-backed row runs the same post-execution tail the
inline replay runs (routes::approvals::tail): the "Allow & Remember" rules, the
cascade they unblock, the approval.executed audit row, and the approval event.
Its action.executed row is stamped AuditSource::Replay, so it names the
approval that authorised it exactly as the inline path does, and its execution
metric is recorded under mode = "replay" with the template key recovered from
the live registry. An approved call must not owe different things depending on
which trigger dialled it, and one copy of the tail is the only way to make that
a fact.
Cancellation has three windows. A queued-but-unclaimed row flips to cancelled
immediately from either POST /v1/approvals/{id}/cancel or
POST /v1/executions/{id}/cancel. A row a worker already owns can only be
asked to stop: the approval cancel falls through to request_cancel, and the
terminal event is emitted by the worker when it observes the flag on its next
heartbeat — announcing it at request time would show a cancelled row that keeps
running. A terminal row is a 409 either way.
The budget is the async one: the gated path resolves its timeout above the gate,
so ASYNC_CALL_TIMEOUT_MAX_MS applies and the stored value is re-clamped against
today's org maximum when the worker picks it up.
executions, extended — not a sibling table. That reuses the six-state CHECK,
the expiry sweep, ExecutionSummary, result_viewed_at, tags, MCP
get_result and CLI get-result.
approval_id becomes nullable and request IS NOT NULL marks a row as
worker-run. The two axes are deliberately orthogonal, giving three legal shapes:
approval_id |
request |
meaning |
|---|---|---|
| NULL | NOT NULL | direct async call |
| NOT NULL | NOT NULL | gated call, approved, run async |
| NOT NULL | NULL | every row that existed before this change |
A fourth is meaningless and executions_has_origin forbids it.
Keeping them independent is what lets the new lease sweeps say
AND request IS NOT NULL while the pre-existing orphan sweep gains
AND request IS NULL. Neither can reach the other's rows, so the old sweep's
semantics are unchanged rather than merely untouched — and that is provable
from the predicates rather than argued from call sites.
A claim-and-lease sweeper, not fire-and-forget.
- Claim is
FOR UPDATE SKIP LOCKEDoverstatus='pending' AND request IS NOT NULL. This is new to the codebase: the only prior cross-replica claim iswebhook_digest_run::try_claim'sINSERT … ON CONFLICT, which works because its key is(org, date)and known in advance. Here the key is "whatever is next", which that idiom cannot express. Notewebhook_dispatcher's retry loop has no claim at all and has every replica retrying the same rows — that is the wrong precedent to copy. - Lease (60s) with a heartbeat at TTL/3. The heartbeat's
RETURNING cancel_requesteddoubles as the cancel poll, so "I still own this row" and "I should stop" are a single atomic observation. attemptscounts attempts that lost a lease, and is incremented by the reclaim sweep, never by the claim. That makes a clean hand-back at shutdown free — release is a plainstatus='pending'with no arithmetic and nothing to decrement. It defaults to 1: an action call is not idempotent and there is no idempotency-key concept, so aPOSTthat already reached the upstream must not be replayed because a worker died.- Requeue and release extend
expires_at. Without this, a row claimed at T+14m of a 15m queue TTL whose worker dies is requeued already-expired and silently killed byexpire_stale, losing the retry.
A SIGTERM listener flips a watch channel; the worker stops claiming and
releases in-flight leases within a 3s budget. It never waits for the job — an
async job may legitimately have minutes left.
Deliberately not wired into axum::serve(...).with_graceful_shutdown(...).
Draining HTTP means waiting on /v1/events/stream responses designed to stay
open 30s, which cannot finish inside Cloud Run's ~10s window — so it would end
in SIGKILL anyway, while changing request-path semantics for every endpoint and
adding a new hang mode to tests that build the router many times per process.
The only thing that genuinely benefits from advance notice is the worker.
Residual risk, accepted and documented: if SIGTERM lands after the upstream
request was sent but before it returned, a released row re-runs a call that
already had its effect. Unavoidable without idempotency keys, and the reason
ASYNC_MAX_ATTEMPTS defaults to 1.
- A new endpoint (
POST /v1/actions/call-async). Rejected: it would have to re-implement or delegate every step of the pipeline, and the delegating version is just this design with extra routing. download_tokensfor results. Rejected:GET /v1/downloads/{token}re-dials the upstream, so routing an async result through it would re-run the query on fetch — the opposite of the point.- Binary results. Deferred and rejected at the boundary with a 400.
http_caller::callruns bodies throughString::from_utf8_lossybefore they reach the row, so binary is already corrupted on the buffered path; pretending otherwise would be worse than refusing. The real fix — worker writes to GCS, row stores{result_url, mime, size_bytes}— needs a bucket and IAM in Terraform and is a follow-up. This does not solveexport_query. NOTIFY-driven wake-up. Deferred. TheLISTENbridge already exists inservices/events/bus.rs, so enqueue could wake the worker instantly instead of waiting up to one 2s tick. Not worth the coupling for v1.executiononoverslash_read.Deferred— shipped with the action wait-mode rung (DECISIONS D73). The forwarder was never the blocker:dispatch_readalready calledinsert_execution_mode, so only the schema was missing, and the tool'sadditionalProperties: falseis what made the omission binding. It stopped being optional once an action could declarehybrid: without the key a read-class agent had no way to refuse a 202 on a tool whose contract is "return the data".- A dashboard trigger for a queued call. Not needed and deliberately absent: a queued row has nothing to trigger, so the approval page and the queue row drop "Call now" rather than offering a button whose only outcome is a 409.
orgs.max_async_call_timeout_ms. Deferred. Async is currently clamped by the existingorgs.max_call_timeout_ms, which means an org that set 60000 to bound connection-holding has also bounded its async jobs. Kept knowingly: governance that binds only the cheap path is not governance. Revisit if a real org complains.