CI and images / lint (push) Failing after 3s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 31s
CI and images / backend-lint-and-test (push) Successful in 35s
CI and images / integration (push) Successful in 2m44s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Operator: "there is a repull every time this page loads is there a reason
this info isn't being tracked in the background and stored in some way?"
There was a reason and it had expired, and underneath it there was plain
waste.
The expired one: /api/system/workers was deliberately uncached because an
operator dragging the stepper must not be shown a pre-change value. That
stopped being true at 1353d34, when the UI began patching its row from the
write's reply instead of refetching.
The waste: size_worker_lanes already inspected the broker on a timer to
decide pool sizes — computing the pool, active, reserved and queue depth
the page shows, using them, and discarding them. The browser then asked
the broker for the same numbers four times a minute, per open tab.
So one inspect now feeds three things: the sizing decision, a stored
sample (worker_lane_sample, alembic 0107), and the celery roster. No
request path touches the broker at all — the roster refresh comes off
/api/system/health too, where it had been rate-limited to 20s and so made
worker liveness a function of whether anyone had a browser open.
Consequences, stated rather than hidden:
- The live figures are up to one sweep old. measured_at travels with each
lane and the page says how old, because a stale number presented as
current is how someone watches a queue "not move" that is moving.
- The sweep is the roster's only writer now, so its period and the
staleness thresholds are in a relationship. 60s against a 90s stale
threshold left one missed tick between normal and all-yellow — the
shape of lesson #4355 — so the period is 30s, named once in
worker_lanes, and system_health asserts its headroom at import with a
test stating the same thing in prose.
- An idle lane therefore also gives a worker back twice as fast. That is
the direction asked for: "idle instances quiet down when not running".
Also bounds the inspect in push_lane_cap, which was an await with no
deadline (rule 156) — harmless while it ran on a request, less so now
that it runs in a background task where a hang would be silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
167 lines
6.9 KiB
JavaScript
167 lines
6.9 KiB
JavaScript
// Joining the roster to the worker lanes, for the System tab's one table.
|
|
//
|
|
// Extracted from the component rather than left inline because the failure
|
|
// this can have is SILENT and is exactly the thing the merge exists to fix: if
|
|
// a lane stops matching its roster part, nothing throws — the table simply
|
|
// grows a second row for the same worker, one with controls and one without,
|
|
// which is the duplication the operator asked to be rid of, returned by the
|
|
// code that removed it.
|
|
//
|
|
// Operator, 2026-09-23: "I feel that we can probably combine the two
|
|
// sections into a single table."
|
|
|
|
// A learned roster part and a lane are the same thing seen from two sides, and
|
|
// the QUEUES are what identify it — `service_roster.refresh_celery_roster`
|
|
// keys a celery part on exactly `"celery:" + ",".join(sorted(queues))`.
|
|
//
|
|
// Matched on the sorted set rather than on that string so the join survives a
|
|
// change to how the key is spelled, and so neither side has to agree about
|
|
// ORDER: the lane table lists a lane's queues in the order the role reads them
|
|
// (`default, import, thumbnail, download`) while the roster sorts them
|
|
// (`default, download, import, thumbnail`).
|
|
export function queueKey(queues) {
|
|
return [...(queues || [])].sort().join(',')
|
|
}
|
|
|
|
// Worst first. A stopped datastore is why someone opened this tab.
|
|
export const SEVERITY = { down: 3, stale: 2, unknown: 1, ok: 0 }
|
|
|
|
export function kindLabel(kind) {
|
|
if (kind === 'celery') return 'worker lane'
|
|
if (kind === 'agent') return 'GPU agent'
|
|
if (kind === 'datastore') return 'datastore'
|
|
return kind
|
|
}
|
|
|
|
/**
|
|
* One row per moving part, with a lane attached where there is one.
|
|
*
|
|
* @param parts the roster's parts, as /api/system/health returns them
|
|
* @param lanes the lane rows, as /api/system/workers returns them
|
|
* @param stuckFor a lane -> "40 minutes" | null reporter (laneStuckFor)
|
|
*/
|
|
export function mergeParts(parts, lanes, stuckFor = () => null) {
|
|
const unmatched = {}
|
|
for (const lane of lanes || []) unmatched[queueKey(lane.queues)] = lane
|
|
|
|
const out = []
|
|
for (const part of parts || []) {
|
|
const key = queueKey(part.queues)
|
|
const lane = part.kind === 'celery' ? unmatched[key] : undefined
|
|
if (lane) delete unmatched[key]
|
|
out.push({
|
|
key: part.key,
|
|
name: part.name,
|
|
kindLabel: lane?.optional ? 'optional lane' : kindLabel(part.kind),
|
|
state: part.state,
|
|
// A lane capped at zero is OFF, not broken. Say so, rather than let the
|
|
// roster's heartbeat sentence report the operator's own choice as a
|
|
// fault — the roster cannot know the difference, and the lane can.
|
|
detail: lane && lane.slots_cap === 0 ? 'off — cap is zero' : part.detail,
|
|
queues: (part.queues || []).join(', '),
|
|
lane,
|
|
stuckFor: lane ? stuckFor(lane) : null,
|
|
severity: SEVERITY[part.state] ?? SEVERITY.unknown,
|
|
})
|
|
}
|
|
|
|
// A lane the roster has not learned yet. Parts appear only once they have
|
|
// checked in, while the lane table is known up front — so without this, the
|
|
// lane an operator most needs to find (an optional one, never yet started)
|
|
// would be the only one missing from the table.
|
|
for (const lane of Object.values(unmatched)) out.push(laneRow(lane, stuckFor))
|
|
|
|
// Severity leads; then lanes ahead of everything else, because they are the
|
|
// rows you can actually do something about; then by name.
|
|
return out.sort((a, b) =>
|
|
b.severity - a.severity
|
|
|| Number(Boolean(b.lane)) - Number(Boolean(a.lane))
|
|
|| a.name.localeCompare(b.name))
|
|
}
|
|
|
|
function laneRow(lane, stuckFor) {
|
|
const on = lane.slots_cap > 0
|
|
let state = 'unknown'
|
|
if (lane.live?.present) state = on ? (stuckFor(lane) ? 'stale' : 'ok') : 'unknown'
|
|
else if (on) state = 'down'
|
|
return {
|
|
key: `lane:${lane.name}`,
|
|
name: lane.display_name,
|
|
kindLabel: lane.optional ? 'optional lane' : 'worker lane',
|
|
state,
|
|
detail: lane.live?.present
|
|
? (on ? 'running' : 'off — cap is zero')
|
|
: 'has not checked in yet',
|
|
queues: (lane.queues || []).join(', '),
|
|
lane,
|
|
stuckFor: stuckFor(lane),
|
|
severity: SEVERITY[state],
|
|
}
|
|
}
|
|
|
|
|
|
// How much has to be waiting before we tell someone to raise a cap.
|
|
//
|
|
// Not "anything at all". A lane at its cap with three items queued is working
|
|
// normally and will be empty in a moment; a notice there is one people learn
|
|
// to scroll past, and at that point it is worse than not having it.
|
|
export const ADVISE_BACKLOG = 10
|
|
|
|
/**
|
|
* The sentence that tells an operator the cap is now the limiting factor.
|
|
*
|
|
* Operator, 2026-09-23: *"there needs to be something that tells you user to
|
|
* bump those numbers to improve processing rate or they'd never know the
|
|
* controls exist."* The defaults are deliberately one-of-each, so on a busy
|
|
* instance the shipped configuration IS the bottleneck — and a conservative
|
|
* default nobody knows how to raise is just a slow product.
|
|
*
|
|
* Only fires when raising the cap would actually help: there is real work
|
|
* waiting, every worker the cap allows is already running, and the cap is
|
|
* below what this machine can hold. A lane already at its ceiling gets
|
|
* nothing, because there is nothing it could be told to do.
|
|
*/
|
|
export function laneAdvice(lane) {
|
|
if (!lane) return null
|
|
const pending = lane.pending
|
|
if (pending == null || pending < ADVISE_BACKLOG) return null
|
|
|
|
if (lane.slots_cap === 0) {
|
|
return `${pending.toLocaleString()} waiting, and this lane is off. `
|
|
+ 'Raise its cap to start working through them.'
|
|
}
|
|
if (lane.ceiling <= lane.slots_cap) {
|
|
// At the machine's limit, not the operator's. Saying "raise the cap"
|
|
// here would be advice they cannot take.
|
|
return null
|
|
}
|
|
if (!lane.live?.present || (lane.live.pool ?? 0) < lane.slots_cap) return null
|
|
|
|
return `${pending.toLocaleString()} waiting and all ${lane.slots_cap} `
|
|
+ `worker${lane.slots_cap === 1 ? '' : 's'} busy. `
|
|
+ `Raise the cap to run more at once — this machine allows up to `
|
|
+ `${lane.ceiling}.`
|
|
}
|
|
|
|
|
|
// When the worker numbers in the table were actually read.
|
|
//
|
|
// They used to be fetched live on every page load — a celery broadcast per
|
|
// request, on a page that polls every 15s. Operator, 2026-09-23: *"there is a
|
|
// repull every time this page loads — is there a reason this info isn't being
|
|
// tracked in the background and stored in some way?"* It is now: a sweep
|
|
// measures on a timer and stores it, and the page reads the table.
|
|
//
|
|
// So the age has to be ON SCREEN. A number that is up to half a minute old,
|
|
// presented as current, is how someone ends up watching a queue "not move"
|
|
// that is in fact moving. The NEWEST across lanes, because they are written
|
|
// by one sweep in one pass — a lane lagging the others means its row has
|
|
// never been written, and that lane says so in its own detail line.
|
|
export function measuredAt(lanes) {
|
|
const stamps = (lanes || [])
|
|
.map((l) => l.measured_at)
|
|
.filter(Boolean)
|
|
.sort()
|
|
return stamps.length ? stamps[stamps.length - 1] : null
|
|
}
|