feat: one number per lane — the cap — and the autoscaler is the mechanism (4295)
CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 24s
CI and images / integration (push) Failing after 24s
CI and images / backend-lint-and-test (push) Failing after 34s
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
CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 24s
CI and images / integration (push) Failing after 24s
CI and images / backend-lint-and-test (push) Failing after 34s
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, 2026-09-23: *"auto should be always on, not a setting, so that idle
instances quiet down when not running. the number that is visible and
something the user can tweak and manage should be the cap itself the number of
running workers is handled by the autoscaling function which is always on."*
They are right, and the reason it was not built this way is worth stating: the
manual dial came first (steps 2-4) and the autoscaler came last (step 7), as
an opt-in BESIDE a control that already existed. Nothing ever asked whether
the dial should still exist once something could move it automatically. Each
step was defensible; the result was three operator settings over one number.
## `slots`, `enabled` and `autoscale` are gone
`slots` was a MEASUREMENT wearing a preference's clothes. How many workers a
lane runs is read live and moved every minute; storing it meant the operator
had to keep two numbers in agreement and the autoscaler had to be told it was
allowed to touch one of them.
`autoscale` gated the mechanism behind a choice, so a lane nobody opted in
never gave its workers back — which is why an idle instance never quieted
down.
`enabled` is derived: a cap of zero means no consumers. "Off" and "may use no
workers" were two spellings of one fact, stored separately, free to disagree.
## Two sweeps become one
`reconcile_lanes_sync` drove the pool to the stored `slots`; `autoscale_lanes_
sync` moved it away from that same number; and most of step 7's hardest
reasoning — a stored value that is a FLOOR, a target of `max(stored, current)`
— existed only to stop them fighting. Delete the stored number and the problem
is not solved, it is absent.
`size_lanes_sync` runs every minute and owns both consumers and pool size. It
also subsumes what the reconcile was for: a worker restarted at its ENV
concurrency is corrected on the next tick rather than after five.
Growth is immediate, shrink is one worker per tick. Deliberately asymmetric —
"always on" is only pleasant if the ramp keeps up, and +1/minute would take
four minutes to answer a burst. Being one worker too large for a minute costs
a sleeping process; being too small costs work not happening. For ML the
asymmetry matters most: every new slot reloads a multi-GB model, so the slow
shrink is what stops a quiet patch from paying that cost again a minute later.
## The caps ship at one, and zero for ML
Per the operator. Conservative on purpose — and a conservative default nobody
knows how to raise is just a slow product, which is the other half of what
they asked for:
"there needs to be something that tells the user to bump those numbers to
improve processing rate or they'd never know the controls exist."
So a lane running everything its cap allows while work piles up says so, in
its own row, with the headroom named: *"4,060 waiting and all 1 worker busy.
Raise the cap to run more at once — this machine allows up to 7."*
It fires only when raising the cap would actually help. Not when the lane is
keeping up, not when the sizing pass has room it has not taken, and not at the
machine ceiling — where "raise the cap" is advice nobody can take.
## Migration 0105 rewrites the caps rather than carrying them
The old defaults (4/2/2/1) bounded a manual control and were loose because
moving within them was the ordinary act. The number now means "the most
workers this lane may use", which is a different promise; carrying the old
figure over would quadruple the worker lane on every existing install at the
moment this deploys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -55,10 +55,9 @@
|
||||
<tr>
|
||||
<th>Part</th>
|
||||
<th>Queues</th>
|
||||
<th class="text-right">Pending</th>
|
||||
<th class="text-right">Busy</th>
|
||||
<th class="text-center" style="width: 150px;">Slots</th>
|
||||
<th class="text-center" style="width: 84px;">Auto</th>
|
||||
<th class="text-right">Waiting</th>
|
||||
<th class="text-right">Workers</th>
|
||||
<th class="text-center" style="width: 160px;">Max workers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,7 +84,15 @@
|
||||
memory to change nothing. Seeing a lane wedged on one slow
|
||||
job is the useful half. -->
|
||||
<div v-if="row.stuckFor" class="fc-parts__detail text-warning">
|
||||
all slots busy for {{ row.stuckFor }}
|
||||
all workers busy for {{ row.stuckFor }}
|
||||
</div>
|
||||
<!-- The nudge. Operator: "there needs to be something that tells
|
||||
the user to bump those numbers to improve processing rate or
|
||||
they'd never know the controls exist." It fires only when
|
||||
raising the cap would actually help — see `laneAdvice`. -->
|
||||
<div v-if="row.advice" class="fc-parts__advice">
|
||||
<v-icon size="14" class="mr-1">mdi-arrow-up-bold-outline</v-icon>
|
||||
{{ row.advice }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -98,6 +105,9 @@
|
||||
{{ row.lane ? (row.lane.pending ?? '—') : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- Running, and how many of them are busy. Both are
|
||||
MEASUREMENTS — the sizing pass owns this number, nobody sets
|
||||
it — which is why neither is a control. -->
|
||||
<td class="text-right fc-parts__num">
|
||||
<template v-if="row.lane && row.lane.live.present">
|
||||
{{ row.lane.live.active }} / {{ row.lane.live.pool ?? '?' }}
|
||||
@@ -110,71 +120,65 @@
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-btn
|
||||
icon="mdi-minus" size="x-small" variant="text"
|
||||
:disabled="busy === row.lane.name || row.lane.slots <= 0"
|
||||
:aria-label="`Fewer slots for ${row.name}`"
|
||||
:disabled="busy === row.lane.name || row.lane.slots_cap <= 0"
|
||||
:aria-label="`Allow ${row.name} fewer workers`"
|
||||
@click="step(row.lane, -1)"
|
||||
/>
|
||||
<span class="fc-parts__slots">{{ row.lane.slots }}</span>
|
||||
<span class="fc-parts__slots">{{ row.lane.slots_cap }}</span>
|
||||
<v-btn
|
||||
icon="mdi-plus" size="x-small" variant="text"
|
||||
:disabled="busy === row.lane.name
|
||||
|| row.lane.slots >= row.lane.slots_cap"
|
||||
:aria-label="`More slots for ${row.name}`"
|
||||
|| row.lane.slots_cap >= row.lane.ceiling"
|
||||
:aria-label="`Allow ${row.name} more workers`"
|
||||
@click="step(row.lane, 1)"
|
||||
/>
|
||||
</div>
|
||||
<!-- The REASON a higher number is unavailable, always on
|
||||
screen. A greyed control with no explanation reads as a
|
||||
bug, and this ceiling is the one number here the operator
|
||||
cannot change from this table. -->
|
||||
<div class="fc-parts__sub">of {{ row.lane.slots_cap }}</div>
|
||||
screen — and, just as importantly, the headroom that says
|
||||
raising it is even possible. A greyed control with no
|
||||
explanation reads as a bug. -->
|
||||
<div class="fc-parts__sub">
|
||||
{{ row.lane.ceiling === 0
|
||||
? 'no room on this machine'
|
||||
: `up to ${row.lane.ceiling}` }}
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="fc-sys__muted">—</span>
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
<v-switch
|
||||
v-if="row.lane"
|
||||
:model-value="row.lane.autoscale"
|
||||
density="compact" hide-details color="accent"
|
||||
class="d-inline-flex"
|
||||
:disabled="busy === row.lane.name
|
||||
|| (!row.lane.autoscale && !canGrow(row.lane))"
|
||||
:aria-label="`Let ${row.name} add slots by itself`"
|
||||
@update:model-value="setAutoscale(row.lane, $event)"
|
||||
/>
|
||||
<span v-else class="fc-sys__muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
|
||||
<!-- What the two columns that are not self-explanatory actually mean.
|
||||
Operator, 2026-09-23: *"there's nothing to describe what 'auto' means
|
||||
or why their needs to be or should be on/off toggles."* It was a bare
|
||||
switch in a column headed Auto, beside a second switch headed On, with
|
||||
nothing anywhere saying what either one did. -->
|
||||
<!-- What the one control actually means. Operator, 2026-09-23: *"there's
|
||||
nothing to describe what 'auto' means or why their needs to be or
|
||||
should be on/off toggles."* There were three controls and no sentence
|
||||
explaining any of them; there is one now, and it is explained. -->
|
||||
<div class="fc-parts__legend mt-4">
|
||||
<p class="mb-2">
|
||||
<strong>Slots</strong> is how many tasks a lane runs at once. Changes
|
||||
reach the running worker immediately and survive a restart.
|
||||
<strong>Zero slots turns the lane off</strong> — it keeps its process
|
||||
and stops taking work, so it stays listed here rather than looking like
|
||||
a crash. Three of the four lanes need to be running for FabledCurator to
|
||||
work at all; ML tagging is the one that is genuinely optional. The
|
||||
<em>of N</em> beneath each dial is the most that lane may have on this
|
||||
machine.
|
||||
<strong>Max workers</strong> is the only thing you set. How many
|
||||
workers a lane is actually running at any moment is decided for you —
|
||||
it grows to meet a backlog and gives the workers back when the queue
|
||||
empties, so an idle instance settles down to one of each without being
|
||||
told to. The cap is the ceiling on that, never a target, so raising it
|
||||
costs nothing until there is work to spend it on.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>Auto</strong> lets a lane add slots <em>by itself</em> when its
|
||||
queue is backed up <em>and</em> every slot it has is busy — one at a
|
||||
time, never past <em>of N</em> — and hand them back once the backlog
|
||||
clears. Off means the lane stays at exactly the number you set. It is
|
||||
off by default, per lane, because this is the only thing on this page
|
||||
that acts without being asked. A lane already dialled to its maximum has
|
||||
nowhere to grow, so its switch stays unavailable until you leave it some
|
||||
room.
|
||||
<strong>A cap of zero turns the lane off.</strong> It keeps one parked
|
||||
process and stops taking work, so it stays listed here rather than
|
||||
looking like a crash. Three of the four lanes need to be running for
|
||||
FabledCurator to work at all; ML tagging is the one that is genuinely
|
||||
optional, and it ships at zero because switching it on downloads a
|
||||
model. <em>Up to N</em> beneath each dial is what this machine can
|
||||
hold — memory for ML tagging, processor cores for the rest — and it is
|
||||
recalculated from the container's real limits every time this page
|
||||
loads.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
The shipped caps are one of each, which is right for a first boot and
|
||||
wrong for a busy library. A lane that is running everything its cap
|
||||
allows while work piles up <strong>will say so in its row</strong>, and
|
||||
raising that cap is the answer when it does.
|
||||
</p>
|
||||
<p v-if="store.thresholds" class="mb-0">
|
||||
A part is called <strong>stale</strong> after
|
||||
@@ -204,7 +208,7 @@
|
||||
you are <em>not</em> running the GPU agent, which does the same work
|
||||
faster.
|
||||
</p>
|
||||
<p class="mb-1">Giving it a slot downloads, once:</p>
|
||||
<p class="mb-1">Raising its cap above zero downloads, once:</p>
|
||||
<ul class="mb-2">
|
||||
<li v-for="m in lane.models" :key="m.repo">
|
||||
<code>{{ m.repo }}</code> —
|
||||
@@ -214,8 +218,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mb-0 text-caption">
|
||||
Each slot loads its own copy, which is why this machine allows it at
|
||||
most {{ lane.ceiling }}.
|
||||
Each worker loads its own copy, which is why this machine allows it
|
||||
at most {{ lane.ceiling }}.
|
||||
<template v-if="lane.ceiling === 0">
|
||||
It has too little memory to run this at all.
|
||||
</template>
|
||||
@@ -230,7 +234,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
import { useSystemHealthStore } from '../../stores/systemHealth.js'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import { mergeParts } from '../../utils/systemParts.js'
|
||||
import { laneAdvice, mergeParts } from '../../utils/systemParts.js'
|
||||
|
||||
const store = useSystemHealthStore()
|
||||
const lanesStore = useSystemActivityStore()
|
||||
@@ -266,11 +270,11 @@ onUnmounted(() => { clearInterval(pollId) })
|
||||
// that removed it.
|
||||
const rows = computed(() => mergeParts(
|
||||
store.parts, lanesStore.lanes?.lanes ?? [], laneStuckFor,
|
||||
))
|
||||
).map((row) => ({ ...row, advice: laneAdvice(row.lane) })))
|
||||
|
||||
const offOptionalLanes = computed(() =>
|
||||
(lanesStore.lanes?.lanes ?? []).filter(
|
||||
(l) => l.optional && l.slots === 0 && l.models?.length,
|
||||
(l) => l.optional && l.slots_cap === 0 && l.models?.length,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -319,23 +323,12 @@ async function apply(lane, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
// The dial IS the switch — the API derives `enabled` from the number, so
|
||||
// stepping to zero turns the lane off and stepping off zero turns it on.
|
||||
// Nothing here sends `enabled`, and there is no second control that could
|
||||
// disagree with the number on screen.
|
||||
// The cap is the only thing an operator sets, and it doubles as the switch:
|
||||
// zero means no consumers. How many workers actually run is the sizing pass's
|
||||
// business — always on, reading the live pool every minute — so nothing here
|
||||
// sends a worker count.
|
||||
function step(lane, delta) {
|
||||
return apply(lane, { slots: lane.slots + delta })
|
||||
}
|
||||
|
||||
// Room to grow into. The autoscaler moves the LIVE pool, but the floor it
|
||||
// starts from is the stored value, so a lane already dialled to its cap has
|
||||
// nowhere to go and turning this on would do nothing at all.
|
||||
function canGrow(lane) {
|
||||
return lane.slots_cap > lane.slots
|
||||
}
|
||||
|
||||
function setAutoscale(lane, value) {
|
||||
return apply(lane, { autoscale: Boolean(value) })
|
||||
return apply(lane, { slots_cap: lane.slots_cap + delta })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -390,6 +383,10 @@ function setAutoscale(lane, value) {
|
||||
font-size: 0.8rem; color: rgb(var(--v-theme-on-surface) / 0.72);
|
||||
padding-left: 17px;
|
||||
}
|
||||
.fc-parts__advice {
|
||||
font-size: 0.8rem; padding-left: 17px; margin-top: 2px;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-parts__queues {
|
||||
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface) / 0.6);
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ export function mergeParts(parts, lanes, stuckFor = () => null) {
|
||||
name: part.name,
|
||||
kindLabel: lane?.optional ? 'optional lane' : kindLabel(part.kind),
|
||||
state: part.state,
|
||||
// A lane dialled to zero is OFF, not broken. Say so, rather than let the
|
||||
// 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 === 0 ? 'off — no slots' : part.detail,
|
||||
detail: lane && lane.slots_cap === 0 ? 'off — cap is zero' : part.detail,
|
||||
queues: (part.queues || []).join(', '),
|
||||
lane,
|
||||
stuckFor: lane ? stuckFor(lane) : null,
|
||||
@@ -80,7 +80,7 @@ export function mergeParts(parts, lanes, stuckFor = () => null) {
|
||||
}
|
||||
|
||||
function laneRow(lane, stuckFor) {
|
||||
const on = lane.slots > 0
|
||||
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'
|
||||
@@ -90,7 +90,7 @@ function laneRow(lane, stuckFor) {
|
||||
kindLabel: lane.optional ? 'optional lane' : 'worker lane',
|
||||
state,
|
||||
detail: lane.live?.present
|
||||
? (on ? 'running' : 'off — no slots')
|
||||
? (on ? 'running' : 'off — cap is zero')
|
||||
: 'has not checked in yet',
|
||||
queues: (lane.queues || []).join(', '),
|
||||
lane,
|
||||
@@ -98,3 +98,47 @@ function laneRow(lane, stuckFor) {
|
||||
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}.`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mergeParts, queueKey } from '../src/utils/systemParts.js'
|
||||
import { laneAdvice, mergeParts, queueKey } from '../src/utils/systemParts.js'
|
||||
|
||||
// The System tab's one table (operator 2026-09-23: "combine the two sections
|
||||
// into a single table"). What is pinned here is the JOIN, because its failure
|
||||
@@ -25,9 +25,8 @@ const LANE = {
|
||||
// sorted. If the join ever compares these two lists directly rather than as
|
||||
// sets, this fixture is what catches it.
|
||||
queues: ['default', 'import', 'thumbnail', 'download'],
|
||||
slots: 2,
|
||||
slots_cap: 4,
|
||||
autoscale: false,
|
||||
ceiling: 8,
|
||||
live: { present: true, replicas: 1, pool: 2, active: 0 },
|
||||
pending: 0,
|
||||
}
|
||||
@@ -71,7 +70,7 @@ describe('mergeParts', () => {
|
||||
// is exactly the one with no roster entry.
|
||||
const ml = {
|
||||
...LANE, name: 'ml', display_name: 'ML tagging', queues: ['ml'],
|
||||
slots: 0, optional: true,
|
||||
slots_cap: 0, optional: true,
|
||||
live: { present: false, replicas: 0, pool: null, active: 0 },
|
||||
}
|
||||
const rows = mergeParts([POSTGRES], [ml])
|
||||
@@ -81,15 +80,15 @@ describe('mergeParts', () => {
|
||||
expect(row.kindLabel).toBe('optional lane')
|
||||
})
|
||||
|
||||
it('does not call a lane at zero slots broken', () => {
|
||||
it('does not call a lane capped at zero broken', () => {
|
||||
// The roster only knows a heartbeat age, so it goes on saying "is running"
|
||||
// for a lane the operator deliberately dialled to nothing. The lane knows
|
||||
// the difference; reporting the operator's own choice as a fault is how an
|
||||
// indicator stops being read.
|
||||
const off = { ...LANE, slots: 0 }
|
||||
const off = { ...LANE, slots_cap: 0 }
|
||||
const row = mergeParts([PART], [off])[0]
|
||||
|
||||
expect(row.detail).toBe('off — no slots')
|
||||
expect(row.detail).toBe('off — cap is zero')
|
||||
})
|
||||
|
||||
it('puts the broken thing first, whatever it is', () => {
|
||||
@@ -126,3 +125,68 @@ describe('mergeParts', () => {
|
||||
expect(mergeParts(undefined, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// The nudge. 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 caps ship at one of each, so on a busy instance the SHIPPED
|
||||
// CONFIGURATION is the bottleneck. What is pinned here is that it fires only
|
||||
// when raising the cap would actually help — a notice on a lane that is
|
||||
// keeping up, or one already at the machine's limit, is a notice people learn
|
||||
// to scroll past, and at that point it is worse than not having it.
|
||||
|
||||
describe('laneAdvice', () => {
|
||||
const busyAtCap = {
|
||||
slots_cap: 1, ceiling: 7, pending: 4060,
|
||||
live: { present: true, pool: 1, active: 1 },
|
||||
}
|
||||
|
||||
it('speaks when the cap is the limiting factor', () => {
|
||||
const advice = laneAdvice(busyAtCap)
|
||||
expect(advice).toContain('4,060 waiting')
|
||||
expect(advice).toContain('Raise the cap')
|
||||
// The headroom, so it is an instruction rather than a complaint.
|
||||
expect(advice).toContain('7')
|
||||
})
|
||||
|
||||
it('says something different about a lane that is switched off', () => {
|
||||
const advice = laneAdvice({ ...busyAtCap, slots_cap: 0 })
|
||||
expect(advice).toContain('this lane is off')
|
||||
})
|
||||
|
||||
it('stays quiet when the lane is keeping up', () => {
|
||||
expect(laneAdvice({ ...busyAtCap, pending: 2 })).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet when the cap is not the thing holding it back', () => {
|
||||
// Four allowed, two running — the sizing pass has room it has not taken,
|
||||
// so the queue is not the cap's fault.
|
||||
expect(laneAdvice({
|
||||
...busyAtCap, slots_cap: 4, live: { present: true, pool: 2, active: 2 },
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet at the machine ceiling, where the advice is untakeable', () => {
|
||||
// The one case where saying "raise the cap" would send someone to a
|
||||
// control that will refuse them.
|
||||
expect(laneAdvice({ ...busyAtCap, ceiling: 1 })).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet about a lane that is not answering', () => {
|
||||
// An unswept read is not a verdict: nothing replied, so "all workers
|
||||
// busy" is a claim nobody made.
|
||||
expect(laneAdvice({
|
||||
...busyAtCap, live: { present: false, pool: null, active: 0 },
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet when the backlog is unknown rather than reading it as huge', () => {
|
||||
expect(laneAdvice({ ...busyAtCap, pending: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing about a row that is not a lane at all', () => {
|
||||
expect(laneAdvice(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,13 +28,13 @@ const LANES_BODY = {
|
||||
{
|
||||
name: 'worker', display_name: 'Worker',
|
||||
queues: ['default', 'import', 'thumbnail', 'download'],
|
||||
slots: 1, slots_cap: 4, ceiling: 8, enabled: true, memory_bound: false,
|
||||
slots_cap: 1, ceiling: 8, enabled: true, memory_bound: false,
|
||||
live: { present: true, replicas: 1, pool: 1, active: 0, reserved: 3 },
|
||||
queue_depth: 5, pending: 8,
|
||||
},
|
||||
{
|
||||
name: 'ml', display_name: 'ML tagging', queues: ['ml'],
|
||||
slots: 0, slots_cap: 1, ceiling: 2, enabled: false, memory_bound: true,
|
||||
slots_cap: 0, ceiling: 2, enabled: false, memory_bound: true,
|
||||
live: { present: false, replicas: 0, pool: null, active: 0, reserved: 0 },
|
||||
queue_depth: null, pending: null,
|
||||
},
|
||||
@@ -64,23 +64,22 @@ describe('worker lanes store', () => {
|
||||
})
|
||||
|
||||
it('setLane posts only the fields it was given', async () => {
|
||||
// Partial update: the stepper sends slots without restating a cap it did
|
||||
// not touch. Sending the whole row back would make two operators editing
|
||||
// different fields clobber each other.
|
||||
// One field: the cap. How many workers are running is a measurement the
|
||||
// sizing pass owns, so there is nothing else for the UI to send.
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
if (init?.method === 'POST') {
|
||||
return { status: 200, body: { name: 'worker', slots: 2, applied: true } }
|
||||
return { status: 200, body: { name: 'worker', slots_cap: 2, applied: true } }
|
||||
}
|
||||
return { status: 200, body: LANES_BODY }
|
||||
})
|
||||
const s = useSystemActivityStore()
|
||||
await s.setLane('worker', { slots: 2 })
|
||||
await s.setLane('worker', { slots_cap: 2 })
|
||||
|
||||
const post = calls.find((c) => c.init?.method === 'POST')
|
||||
expect(post.url).toContain('/api/system/workers/worker')
|
||||
expect(JSON.parse(post.init.body)).toEqual({ slots: 2 })
|
||||
expect(JSON.parse(post.init.body)).toEqual({ slots_cap: 2 })
|
||||
})
|
||||
|
||||
it('setLane refetches so the card shows the server truth, not the guess', async () => {
|
||||
@@ -94,7 +93,7 @@ describe('worker lanes store', () => {
|
||||
return { status: 200, body: LANES_BODY }
|
||||
})
|
||||
const s = useSystemActivityStore()
|
||||
await s.setLane('worker', { slots: 2 })
|
||||
await s.setLane('worker', { slots_cap: 2 })
|
||||
expect(gets).toBe(1)
|
||||
})
|
||||
|
||||
@@ -106,13 +105,13 @@ describe('worker lanes store', () => {
|
||||
if (init?.method === 'POST') {
|
||||
return {
|
||||
status: 200,
|
||||
body: { applied: false, apply_error: 'lane is not running', slots: 2 },
|
||||
body: { applied: false, apply_error: 'lane is not running', slots_cap: 2 },
|
||||
}
|
||||
}
|
||||
return { status: 200, body: LANES_BODY }
|
||||
})
|
||||
const s = useSystemActivityStore()
|
||||
const reply = await s.setLane('worker', { slots: 2 })
|
||||
const reply = await s.setLane('worker', { slots_cap: 2 })
|
||||
expect(reply.applied).toBe(false)
|
||||
expect(reply.apply_error).toContain('not running')
|
||||
})
|
||||
@@ -182,7 +181,7 @@ describe('laneStuckFor', () => {
|
||||
})
|
||||
|
||||
it('says nothing when the pool is zero', () => {
|
||||
// A lane sized to zero is not "fully busy at zero" — it is off.
|
||||
// A lane with no workers is not "fully busy at zero" — it is off.
|
||||
expect(laneStuckFor(lane({ live: { present: true, pool: 0, active: 0 } })))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user