Skip to content

Commit e7e2fcd

Browse files
perf(artifact-delivery): answer Resolution polls when the build moves (#497)
* perf(artifact-delivery): answer Resolution polls when the build moves A run waited for its build in one second steps, so the CLI saw a finished build up to a second after it landed. A read of a building Resolution now waits up to 8 seconds for its next state, checking every 250 ms, and the poll interval drops to 250 ms. The queue send took 446 to 500 ms of each Resolution request in three production traces. The CLI polls for the result anyway, so the send now runs after the response. A send that fails settles the Resolution as a retryable SERVICE_UNAVAILABLE, so the CLI requests a new one. * fix(artifact-delivery): schedule the resolution queue send past the response --------- Co-authored-by: Harlan GitHub Agent <harlan-github-agent[bot]@users.noreply.github.com>
1 parent 19e9840 commit e7e2fcd

6 files changed

Lines changed: 382 additions & 5 deletions

File tree

‎layers/artifact-delivery/server/api/v1/resolutions/[id].get.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../../../utils/github-app'
1111
import { canReadPrivateResolution } from '../../../utils/private-access'
1212
import { privateArtifactAccessEnabled } from '../../../utils/private-feature'
13+
import { waitForResolutionChange } from '../../../utils/resolution-wait'
1314
import { setSkillPageUrlHeader } from '../../../utils/skill-page'
1415
import { getResolution } from '../../../utils/state'
1516

@@ -50,8 +51,15 @@ export default withArtifactProblems(defineApiHandler({
5051
throw createError({ statusCode: 404, message: 'Resolution not found' })
5152
}
5253
}
53-
await setSkillPageUrlHeader(event, row)
54-
return row
54+
// A building Resolution answers when its state changes, so the CLI sees
55+
// each stage, and the finished build, within one check of it landing.
56+
const current = await waitForResolutionChange({
57+
load: () => getResolution(platform.db, row.id),
58+
sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
59+
now: Date.now,
60+
}, row)
61+
await setSkillPageUrlHeader(event, current)
62+
return current
5563
},
5664
presenter: presentArtifactResolution,
5765
}))

‎layers/artifact-delivery/server/api/v1/resolutions/index.post.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { H3Event } from 'h3'
12
import { getHeader, setHeader, setResponseStatus } from 'h3'
23
import { defineApiHandler } from '#shared/server/handler'
34
import { presentArtifactResolution } from '../../../presenters/resolution'
@@ -7,11 +8,13 @@ import {
78
problemCodeSchema,
89
} from '../../../schemas/contracts'
910
import { withArtifactProblems } from '../../../utils/artifact-problem'
11+
import { failResolution } from '../../../utils/build'
1012
import { findPrivateRepositoryAccess } from '../../../utils/private-access'
1113
import { privateArtifactAccessEnabled } from '../../../utils/private-feature'
1214
import { enqueueArtifactBuild } from '../../../utils/queue'
13-
import { fetchAdmittedSkillIdentity, requestResolution } from '../../../utils/request-resolution'
15+
import { enqueueAfterResponse, fetchAdmittedSkillIdentity, requestResolution } from '../../../utils/request-resolution'
1416
import { setSkillPageUrlHeader } from '../../../utils/skill-page'
17+
import { getResolution } from '../../../utils/state'
1518

1619
export default withArtifactProblems(defineApiHandler({
1720
schema: createResolutionRequestSchema,
@@ -36,7 +39,21 @@ export default withArtifactProblems(defineApiHandler({
3639
const result = await requestResolution({
3740
db: platform.db,
3841
lookupAdmitted: fetchAdmittedSkillIdentity(event.context),
39-
enqueue: resolutionId => enqueueArtifactBuild(platform.env, resolutionId),
42+
enqueue: enqueueAfterResponse({
43+
schedule: work => runAfterResponse(event, work),
44+
enqueue: resolutionId => enqueueArtifactBuild(platform.env, resolutionId),
45+
failUnqueued: async (resolutionId, error) => {
46+
console.error(JSON.stringify({
47+
operation: 'artifact-build',
48+
outcome: 'enqueue-failed',
49+
resolutionId,
50+
error: error instanceof Error ? error.message : String(error),
51+
}))
52+
const row = await getResolution(platform.db, resolutionId)
53+
if (row)
54+
await failResolution({ db: platform.db, now: () => Math.floor(Date.now() / 1000) }, row, 'SERVICE_UNAVAILABLE', true)
55+
},
56+
}),
4057
now: () => Math.floor(Date.now() / 1000),
4158
}, {
4259
source: body.source,
@@ -89,3 +106,20 @@ export default withArtifactProblems(defineApiHandler({
89106
},
90107
presenter: presentArtifactResolution,
91108
}))
109+
110+
/**
111+
* Keep the queue send alive past the response on Workers.
112+
*
113+
* H3Event has no `waitUntil` on h3 1.15, so the send goes through the
114+
* Cloudflare context Nitro mounts. Off Workers (local dev, tests) it does not
115+
* block the response. The registry layer keeps the same helper, and ADR-0001
116+
* keeps utilities out of other layers, so this route holds its own copy.
117+
*/
118+
function runAfterResponse(event: H3Event, promise: Promise<unknown>): void {
119+
const ctx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
120+
if (ctx?.waitUntil) {
121+
ctx.waitUntil(promise)
122+
return
123+
}
124+
void promise
125+
}

‎layers/artifact-delivery/server/utils/request-resolution.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,31 @@ export async function requestResolution(
7777
await dependencies.enqueue(result.row.id)
7878
return result
7979
}
80+
81+
export interface AfterResponseEnqueueDependencies {
82+
/** Keep work alive after the response, such as the Worker's `waitUntil`. */
83+
schedule: (work: Promise<unknown>) => void
84+
enqueue: (resolutionId: string) => Promise<void>
85+
/** Settle a Resolution whose build never reached the queue. */
86+
failUnqueued: (resolutionId: string, error: unknown) => Promise<void>
87+
}
88+
89+
/**
90+
* An `enqueue` that sends the build message after the response.
91+
*
92+
* The queue send took 446 to 500 ms of a 590 to 650 ms request in three
93+
* production traces from Sydney and Bangkok on 2026-10-07. The CLI polls
94+
* for the result anyway, so the send need not hold the first answer. A send
95+
* that fails settles the Resolution as a retryable failure, so the polling
96+
* CLI requests a new Resolution instead of waiting out its deadline.
97+
*/
98+
export function enqueueAfterResponse(
99+
dependencies: AfterResponseEnqueueDependencies,
100+
): (resolutionId: string) => Promise<void> {
101+
return async (resolutionId) => {
102+
dependencies.schedule(
103+
dependencies.enqueue(resolutionId)
104+
.catch(error => dependencies.failUnqueued(resolutionId, error)),
105+
)
106+
}
107+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { ResolutionRow } from './state'
2+
import { ACTIVE_BUILD_STATES } from './state'
3+
4+
/** The longest one Resolution read waits for its build to move. */
5+
export const RESOLUTION_WAIT_MS = 8_000
6+
7+
/** How often a waiting read loads the Resolution again. */
8+
export const RESOLUTION_CHECK_MS = 250
9+
10+
export interface ResolutionWaitDependencies {
11+
/** Load the Resolution as D1 holds it now. */
12+
load: () => Promise<ResolutionRow | null>
13+
sleep: (milliseconds: number) => Promise<void>
14+
/** Milliseconds since the epoch. */
15+
now: () => number
16+
}
17+
18+
/**
19+
* The Resolution once its build leaves the state it had, or as it stands
20+
* when the wait runs out.
21+
*
22+
* A read used to answer at once, and the CLI came back a second later. A
23+
* build that finished just after a read cost the run up to that second, and a
24+
* Resolution passes through seven states. A held read answers within one
25+
* check of each change, so the CLI sees `ready` about 250 ms after it lands.
26+
* A settled Resolution answers at once.
27+
*/
28+
export async function waitForResolutionChange(
29+
dependencies: ResolutionWaitDependencies,
30+
row: ResolutionRow,
31+
budgetMs: number = RESOLUTION_WAIT_MS,
32+
): Promise<ResolutionRow> {
33+
if (!isBuilding(row))
34+
return row
35+
const deadline = dependencies.now() + budgetMs
36+
let current = row
37+
while (dependencies.now() + RESOLUTION_CHECK_MS <= deadline) {
38+
await dependencies.sleep(RESOLUTION_CHECK_MS)
39+
const next = await dependencies.load()
40+
if (!next)
41+
return current
42+
current = next
43+
if (current.state !== row.state)
44+
return current
45+
}
46+
return current
47+
}
48+
49+
function isBuilding(row: ResolutionRow): boolean {
50+
return (ACTIVE_BUILD_STATES as readonly string[]).includes(row.state)
51+
}

‎layers/artifact-delivery/server/utils/state.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,9 @@ export function presentResolution(row: ResolutionRow): ResolutionResponse {
461461
state: 'pending',
462462
resolutionId: row.id,
463463
stage: row.state as ActiveBuildState,
464-
pollAfterMs: 1000,
464+
// A read of a building Resolution waits for its next state, so the
465+
// CLI may come back at once. See `waitForResolutionChange`.
466+
pollAfterMs: 250,
465467
}
466468
}
467469
if (row.state === 'blocked') {

0 commit comments

Comments
 (0)