mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it (#28677)
* fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it A paced drain tells its last host when the window ends, so the restart-safe wait has to see an empty runtime reaching past that point. It counted a full window of quiet samples from the first empty one instead, and the runtime only empties as the window ends, so every same-cap cell waited the window twice: about 25 minutes per Asia cell at 1200 s on 2026-10-13, and 5 minutes per 300 s cell. The window is now measured from the verifier's own start, which runs after the cell accepted the drain and so ends no earlier than the cell's own window. A pass still needs two consecutive restart-safe samples, the last taken at or after that end, and any non-zero runtime counter or open migration still restarts the count. Progress lines report paceWindowRemainingMs in place of the old sample target. Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f * fix(relay): drop the same-cap drain timeout's double-wait allowance and stale quiet wording The drain step's timeout added the window's excess over 300000 only because restart-safe used to wait a second window after the cell emptied, so it is now the 15-minute migration lease plus the window for every pace (35 minutes at 1200000, unchanged at 300000 and below). Docs, workflow comments and the shadow gate's settledAfterSeconds note now describe the window-end rule. The always-quiet test starts its clock late, so a window measured from zero fails it, and the overlapping late-control test is folded away. Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f * docs(relay): state the restart-safe window-end rule exactly Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f * docs(relay): the slowest restart-safe wait is now 35 minutes Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f
This commit is contained in:
@@ -922,18 +922,14 @@ test('the workflows offer exactly the closed set and scale the drain wait with i
|
||||
assert.match(job, /\n DRAIN_PACE_WINDOW_MS: \$\{\{ inputs\.drain-pace-window-ms \}\}\n/)
|
||||
assert.doesNotMatch(job, /DRAIN_PACE_WINDOW_MS: '/)
|
||||
assert.match(job, /cell-class \\\n\s+--cell-id "\$\{TARGET_CELL_ID\}" --drain-pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/)
|
||||
// The 15-min migration lease plus the window, plus its excess over the default: restart-safe
|
||||
// waits a whole window of quiet after the last send, so a slow drain needs about twice it.
|
||||
// The 15-min migration lease plus the window: restart-safe passes once the window has ended.
|
||||
const timeout = /--timeout-ms "\$\(\((.+)\)\)"\n/.exec(job)[1]
|
||||
const timeoutMs = (pace) => Number(execFileSync(
|
||||
'bash', ['-c', `DRAIN_PACE_WINDOW_MS=${pace}; echo $((${timeout}))`], { encoding: 'utf8' }
|
||||
))
|
||||
// Exactly the timeouts every pace had before the slower ones existed.
|
||||
for (const pace of [300_000, 60_000, 30_000]) assert.equal(timeoutMs(pace), 900_000 + pace)
|
||||
for (const pace of SAME_CAP_DRAIN_PACE_WINDOWS_MS) {
|
||||
assert.ok(timeoutMs(pace) >= 2 * pace + 600_000, String(pace))
|
||||
assert.equal(timeoutMs(pace), 900_000 + pace, String(pace))
|
||||
}
|
||||
assert.equal(timeoutMs(1_200_000), 3_000_000)
|
||||
const slowest = timeoutMs(Math.max(...SAME_CAP_DRAIN_PACE_WINDOWS_MS))
|
||||
// The rest of the job keeps the 70 min it had beside the old 20-min drain step in 90.
|
||||
const jobMinutes = Number(/\n rollout:\n(?: [^\n]*\n)*? timeout-minutes: (\d+)\n/.exec(job)[1])
|
||||
|
||||
@@ -419,8 +419,8 @@ export async function evaluateShadowGate(config, {
|
||||
appliedPaceWindowMs: config.drainAppliedPaceWindowMs,
|
||||
targetHosts: config.targetHosts,
|
||||
settledAt: config.drainSettledAt || null,
|
||||
// Isolate to restart-safe, which includes the quiet the restart wait holds after the cell
|
||||
// empties: (ceil(pace / 5 s) + 1) samples of 5 s.
|
||||
// Isolate to restart-safe: the later of the pace window's end and the cell emptying, plus
|
||||
// one 5 s sample.
|
||||
settledAfterSeconds: config.drainStartedAt && config.drainSettledAt
|
||||
? Math.round((Date.parse(config.drainSettledAt) - Date.parse(config.drainStartedAt)) / 1000)
|
||||
: null
|
||||
|
||||
@@ -420,14 +420,17 @@ export async function verifyCapacityTransition(config, overrides = {}) {
|
||||
if (health.ok !== true || health.connectionCapacityProtocol !== CAPACITY_PROTOCOL) {
|
||||
throw new Error('director is not capacity-protocol compatible')
|
||||
}
|
||||
const deadline = now() + config.timeoutMs
|
||||
// A paced drain can refill the runtime until its window ends, so the quiet must outlast it.
|
||||
// Each poll waits a full interval, so consecutive samples bound the quiet time from below.
|
||||
const requiredRestartSafeSamples =
|
||||
Math.max(Math.ceil((config.paceWindowMs ?? 0) / POLL_INTERVAL_MS) + 1, 2)
|
||||
const startedAt = now()
|
||||
const deadline = startedAt + config.timeoutMs
|
||||
// A paced drain can refill the runtime until its window ends, so the second of two consecutive
|
||||
// quiet samples must be taken at or after that end. This runs after the cell accepted the drain,
|
||||
// so the cell's window ends no later than this one.
|
||||
const paceWindowEndsAt = startedAt + (config.paceWindowMs ?? 0)
|
||||
let restartSafeSamples = 0
|
||||
let lastObservation = { runtimeAvailable: false }
|
||||
for (;;) {
|
||||
// Before the read, so a sample taken inside the window cannot pass.
|
||||
const sampledAt = now()
|
||||
const runtime = await cellRuntime(fetchImpl, config, token)
|
||||
lastObservation = { runtimeAvailable: runtime !== null }
|
||||
if ((runtime === null) === (config.runtime === 'unavailable')) {
|
||||
@@ -469,7 +472,7 @@ export async function verifyCapacityTransition(config, overrides = {}) {
|
||||
if (
|
||||
matches &&
|
||||
(config.activity !== 'restart-safe' ||
|
||||
restartSafeSamples + 1 >= requiredRestartSafeSamples)
|
||||
(restartSafeSamples >= 1 && sampledAt >= paceWindowEndsAt))
|
||||
) {
|
||||
return {
|
||||
cellId: status.cellId,
|
||||
@@ -491,15 +494,16 @@ export async function verifyCapacityTransition(config, overrides = {}) {
|
||||
restartSafeSamples = 0
|
||||
}
|
||||
if (config.activity === 'restart-safe') {
|
||||
const paceWindowRemainingMs = Math.max(paceWindowEndsAt - sampledAt, 0)
|
||||
lastObservation = {
|
||||
...lastObservation,
|
||||
restartSafeSamples,
|
||||
requiredRestartSafeSamples
|
||||
paceWindowRemainingMs
|
||||
}
|
||||
progress({
|
||||
event: 'relay_capacity_transition_restart_progress',
|
||||
restartSafeSamples,
|
||||
requiredRestartSafeSamples,
|
||||
paceWindowRemainingMs,
|
||||
totalConnections: lastObservation.runtime?.totalConnections ?? null,
|
||||
preAuthConnections: lastObservation.runtime?.preAuthConnections ?? null,
|
||||
runtimeAvailable: lastObservation.runtimeAvailable,
|
||||
|
||||
@@ -856,8 +856,7 @@ test('restart timeout reports one of two safe settling samples', async () => {
|
||||
{ fetch: base, token: 'masked-token' }
|
||||
),
|
||||
(error) => {
|
||||
assert.match(error.message, /"restartSafeSamples":1/)
|
||||
assert.match(error.message, /"requiredRestartSafeSamples":2/)
|
||||
assert.match(error.message, /"restartSafeSamples":1,"paceWindowRemainingMs":0/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
@@ -1177,8 +1176,8 @@ const pacedRestartConfig = {
|
||||
timeoutMs: 1_200_000
|
||||
}
|
||||
|
||||
function fakeClock() {
|
||||
let now = 0
|
||||
function fakeClock(start = 0) {
|
||||
let now = start
|
||||
return {
|
||||
now: () => now,
|
||||
wait: async (milliseconds) => {
|
||||
@@ -1231,7 +1230,8 @@ test('passes after one pace window of an empty runtime and reports stranded leas
|
||||
},
|
||||
token: 'masked-token',
|
||||
progress: (line) => lines.push(line),
|
||||
...fakeClock()
|
||||
// A late start catches a window measured from zero instead of from the verifier's start.
|
||||
...fakeClock(1_000_000)
|
||||
})
|
||||
// One sample at each end of a full five-minute pace window.
|
||||
assert.equal(runtimeReads, 61)
|
||||
@@ -1246,12 +1246,15 @@ test('passes after one pace window of an empty runtime and reports stranded leas
|
||||
assert.equal(lines.length, 60)
|
||||
assert.ok(lines.every((line) =>
|
||||
line.event === 'relay_capacity_transition_restart_progress' &&
|
||||
line.requiredRestartSafeSamples === 61 &&
|
||||
line.stranded.restartBlockingActivityLeases === 4))
|
||||
assert.deepEqual(lines.slice(0, 2).map((line) => line.restartSafeSamples), [1, 2])
|
||||
assert.deepEqual(
|
||||
lines.slice(0, 2).map((line) => line.paceWindowRemainingMs),
|
||||
[300_000, 295_000]
|
||||
)
|
||||
})
|
||||
|
||||
test('the pace window resets on any non-zero runtime sample', async () => {
|
||||
test('a non-zero runtime sample restarts the quiet count', async () => {
|
||||
const quiet = harness(strandedCell)
|
||||
const busy = harness({ ...strandedCell, controls: 1 })
|
||||
let runtimeReads = 0
|
||||
@@ -1265,17 +1268,37 @@ test('the pace window resets on any non-zero runtime sample', async () => {
|
||||
progress: (line) => lines.push(line),
|
||||
...fakeClock()
|
||||
})
|
||||
assert.equal(runtimeReads, 91)
|
||||
// A mid-window reset adds no time; the quiet after it still reaches the window's end.
|
||||
assert.equal(runtimeReads, 61)
|
||||
assert.deepEqual(lines.slice(28, 31).map((line) => line.restartSafeSamples), [29, 0, 1])
|
||||
await assert.rejects(
|
||||
verifyCapacityTransition(
|
||||
{ ...pacedRestartConfig, timeoutMs: 295_000 },
|
||||
{ fetch: quiet, token: 'masked-token', progress: () => {}, ...fakeClock() }
|
||||
),
|
||||
/"restartSafeSamples":60,"requiredRestartSafeSamples":61/
|
||||
/"restartSafeSamples":60,"paceWindowRemainingMs":5000/
|
||||
)
|
||||
})
|
||||
|
||||
// c27 on 2026-10-13: the 20-minute paced drain emptied the runtime as its window closed, and the
|
||||
// old gate then waited a second full window of quiet samples before the restart.
|
||||
test('a runtime that empties as the pace window ends passes after two quiet samples', async () => {
|
||||
let runtimeReads = 0
|
||||
const lines = []
|
||||
await verifyCapacityTransition(pacedRestartConfig, {
|
||||
fetch: async (url, options) => {
|
||||
if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1
|
||||
return await harness({ ...strandedCell, controls: runtimeReads <= 60 ? 1 : 0 })(url, options)
|
||||
},
|
||||
token: 'masked-token',
|
||||
progress: (line) => lines.push(line),
|
||||
...fakeClock()
|
||||
})
|
||||
// Busy through 295 s; quiet at 300 s and 305 s, the first eligible pair.
|
||||
assert.equal(runtimeReads, 62)
|
||||
assert.deepEqual(lines.slice(59).map((line) => line.restartSafeSamples), [0, 1])
|
||||
})
|
||||
|
||||
test('restart-safe fails on live runtime work or an open migration regardless of leases', async (t) => {
|
||||
for (const blocker of [
|
||||
{ controls: 1 },
|
||||
@@ -1299,7 +1322,7 @@ test('restart-safe fails on live runtime work or an open migration regardless of
|
||||
}
|
||||
})
|
||||
|
||||
test('unauthenticated redials do not reset the pace window', async () => {
|
||||
test('unauthenticated redials do not restart the quiet count', async () => {
|
||||
let runtimeReads = 0
|
||||
const lines = []
|
||||
const result = await verifyCapacityTransition(pacedRestartConfig, {
|
||||
@@ -1439,7 +1462,7 @@ test('restart progress prints every counter the gate reads', async () => {
|
||||
assert.deepEqual(lines[0], {
|
||||
event: 'relay_capacity_transition_restart_progress',
|
||||
restartSafeSamples: 0,
|
||||
requiredRestartSafeSamples: 61,
|
||||
paceWindowRemainingMs: 300_000,
|
||||
totalConnections: 7,
|
||||
preAuthConnections: 1,
|
||||
runtimeAvailable: true,
|
||||
|
||||
Reference in New Issue
Block a user