Release: dev → main (first public release) #258
@@ -0,0 +1,51 @@
|
|||||||
|
"""worker_lane.autoscale — may this lane grow itself?
|
||||||
|
|
||||||
|
Milestone 422 step 7. One boolean, defaulting FALSE on every existing row and
|
||||||
|
on every new one.
|
||||||
|
|
||||||
|
## Why the default is false and not "sensible"
|
||||||
|
|
||||||
|
This is the only part of the milestone that acts without anyone watching. The
|
||||||
|
manual dial (step 4) and the reconcile (step 3) both do exactly what someone
|
||||||
|
asked for; this one decides. Shipping it on would mean every install starts
|
||||||
|
with a process that changes its own resource usage based on a heuristic tuned
|
||||||
|
against nobody's workload.
|
||||||
|
|
||||||
|
Off also makes the failure mode benign: if the signal is wrong, nothing
|
||||||
|
happens until an operator opts a lane in, and they opted in while watching.
|
||||||
|
|
||||||
|
## Why per lane and not one global switch
|
||||||
|
|
||||||
|
The lanes are not alike in what a slot costs. A `worker` slot is a process;
|
||||||
|
an `ml` slot is another copy of a ~3.5GB model. A global switch would enable
|
||||||
|
growth on a lane whose behaviour under load nobody has observed, and the one
|
||||||
|
it would hurt most is the one whose cost is least visible.
|
||||||
|
|
||||||
|
Revision ID: 0104
|
||||||
|
Revises: 0103
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0104"
|
||||||
|
down_revision: Union[str, None] = "0103"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"worker_lane",
|
||||||
|
sa.Column(
|
||||||
|
"autoscale", sa.Boolean(),
|
||||||
|
server_default=sa.text("false"), nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("worker_lane", "autoscale")
|
||||||
@@ -84,15 +84,16 @@ async def update_lane(name: str):
|
|||||||
if not isinstance(value, int) or isinstance(value, bool):
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
return _bad("invalid_body", detail=f"{key} must be an integer")
|
return _bad("invalid_body", detail=f"{key} must be an integer")
|
||||||
fields[key] = value
|
fields[key] = value
|
||||||
if "enabled" in body:
|
for key in ("enabled", "autoscale"):
|
||||||
if not isinstance(body["enabled"], bool):
|
if key in body:
|
||||||
return _bad("invalid_body", detail="enabled must be a boolean")
|
if not isinstance(body[key], bool):
|
||||||
fields["enabled"] = body["enabled"]
|
return _bad("invalid_body", detail=f"{key} must be a boolean")
|
||||||
|
fields[key] = body[key]
|
||||||
|
|
||||||
if not fields:
|
if not fields:
|
||||||
return _bad(
|
return _bad(
|
||||||
"invalid_body",
|
"invalid_body",
|
||||||
detail="give at least one of slots, slots_cap, enabled",
|
detail="give at least one of slots, slots_cap, enabled, autoscale",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
||||||
"schedule": 300.0, # every 5 minutes
|
"schedule": 300.0, # every 5 minutes
|
||||||
},
|
},
|
||||||
|
"autoscale-worker-lanes": {
|
||||||
|
"task": "backend.app.tasks.maintenance.autoscale_worker_lanes",
|
||||||
|
"schedule": 60.0, # every minute — it reacts to a BACKLOG, and
|
||||||
|
# a five-minute reaction to a queue filling up is no reaction.
|
||||||
|
# Cheap when nothing opted in (one DB read, then nothing) and
|
||||||
|
# cheap when settled (one inspect + one LLEN sweep, no control
|
||||||
|
# messages), so the short interval costs little.
|
||||||
|
},
|
||||||
"reconcile-worker-lanes": {
|
"reconcile-worker-lanes": {
|
||||||
"task": "backend.app.tasks.maintenance.reconcile_worker_lanes",
|
"task": "backend.app.tasks.maintenance.reconcile_worker_lanes",
|
||||||
"schedule": 300.0, # every 5 minutes — the window in which a
|
"schedule": 300.0, # every 5 minutes — the window in which a
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ class WorkerLane(Base):
|
|||||||
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
|
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
# Whether the autoscaler may raise this lane's slots on its own, up to
|
||||||
|
# slots_cap and never past it (milestone 422 step 7).
|
||||||
|
#
|
||||||
|
# OFF by default and per-lane rather than global. It is the one part of
|
||||||
|
# this milestone that acts unattended, so it is opt-in per lane the
|
||||||
|
# operator has actually thought about — a global switch would turn it on
|
||||||
|
# for lanes whose behaviour under load nobody has watched, including `ml`,
|
||||||
|
# where every extra slot is another copy of a multi-GB model.
|
||||||
|
autoscale: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, server_default="false",
|
||||||
|
)
|
||||||
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -50,11 +50,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import WorkerLane
|
from ..models import TaskRun, WorkerLane
|
||||||
from .worker_lanes import LANES, LANES_BY_QUEUE_KEY, Lane, derived_ceiling
|
from .worker_lanes import LANES, LANES_BY_QUEUE_KEY, Lane, derived_ceiling
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -103,6 +104,16 @@ class LaneLiveState:
|
|||||||
no replica reported — unknown, never zero."""
|
no replica reported — unknown, never zero."""
|
||||||
return max(self.pools.values()) if self.pools else None
|
return max(self.pools.values()) if self.pools else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capacity(self) -> int:
|
||||||
|
"""Total slots across replicas — how many tasks this lane can run at
|
||||||
|
once. Distinct from `pool`, and the two must not be confused: `pool`
|
||||||
|
is the DIAL (one process's size, what grow/shrink move), `capacity` is
|
||||||
|
the CAPABILITY. Asking "is this lane saturated" compares `active`,
|
||||||
|
which is summed across replicas, against this — against `pool` it
|
||||||
|
would call two half-busy replicas of 4 saturated at 4 active."""
|
||||||
|
return sum(self.pools.values())
|
||||||
|
|
||||||
|
|
||||||
def _lane_for_queues(queues: tuple[str, ...]) -> Lane | None:
|
def _lane_for_queues(queues: tuple[str, ...]) -> Lane | None:
|
||||||
return LANES_BY_QUEUE_KEY.get(tuple(sorted(queues)))
|
return LANES_BY_QUEUE_KEY.get(tuple(sorted(queues)))
|
||||||
@@ -260,6 +271,7 @@ async def _rows_by_name(session: AsyncSession) -> dict[str, WorkerLane]:
|
|||||||
slots=lane.default_slots,
|
slots=lane.default_slots,
|
||||||
slots_cap=lane.default_slots_cap,
|
slots_cap=lane.default_slots_cap,
|
||||||
enabled=lane.default_enabled,
|
enabled=lane.default_enabled,
|
||||||
|
autoscale=lane.default_autoscale,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
rows[lane.name] = row
|
rows[lane.name] = row
|
||||||
@@ -278,7 +290,9 @@ async def lane_view(session: AsyncSession) -> list[dict]:
|
|||||||
rows = await _rows_by_name(session)
|
rows = await _rows_by_name(session)
|
||||||
live = await asyncio.to_thread(inspect_lanes_sync)
|
live = await asyncio.to_thread(inspect_lanes_sync)
|
||||||
depths = await asyncio.to_thread(_queue_depths_sync)
|
depths = await asyncio.to_thread(_queue_depths_sync)
|
||||||
|
oldest = await _oldest_running_by_queue(session)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
out = []
|
out = []
|
||||||
for lane in LANES:
|
for lane in LANES:
|
||||||
row = rows[lane.name]
|
row = rows[lane.name]
|
||||||
@@ -297,6 +311,7 @@ async def lane_view(session: AsyncSession) -> list[dict]:
|
|||||||
"slots_cap": row.slots_cap,
|
"slots_cap": row.slots_cap,
|
||||||
"ceiling": derived_ceiling(lane),
|
"ceiling": derived_ceiling(lane),
|
||||||
"enabled": row.enabled,
|
"enabled": row.enabled,
|
||||||
|
"autoscale": row.autoscale,
|
||||||
"memory_bound": lane.memory_bound,
|
"memory_bound": lane.memory_bound,
|
||||||
"optional": lane.optional,
|
"optional": lane.optional,
|
||||||
# What enabling this lane will download, so the UI can say WHICH
|
# What enabling this lane will download, so the UI can say WHICH
|
||||||
@@ -321,10 +336,55 @@ async def lane_view(session: AsyncSession) -> list[dict]:
|
|||||||
},
|
},
|
||||||
"queue_depth": depth,
|
"queue_depth": depth,
|
||||||
"pending": None if depth is None else depth + state.reserved,
|
"pending": None if depth is None else depth + state.reserved,
|
||||||
|
# How long the oldest still-running task on this lane has been
|
||||||
|
# going, in minutes. The operator asked for a trigger here — grow
|
||||||
|
# a lane whose tasks run past some duration — and it stayed a
|
||||||
|
# REPORT: a long task does not finish sooner because the lane
|
||||||
|
# gained a slot, so scaling on it would spend memory to change
|
||||||
|
# nothing. Shown so they can see a lane wedged on one slow job,
|
||||||
|
# which is the genuinely useful half of the idea.
|
||||||
|
"oldest_running_minutes": _minutes_since(
|
||||||
|
min(
|
||||||
|
(oldest[q] for q in lane.queues if q in oldest),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _oldest_running_by_queue(session: AsyncSession) -> dict[str, datetime]:
|
||||||
|
"""When the longest-running unfinished task on each queue started.
|
||||||
|
|
||||||
|
Read from `task_run`, which is OUR OWN table on OUR OWN wall clock, and
|
||||||
|
deliberately not from celery's `inspect active()`. Those entries carry a
|
||||||
|
`time_start` taken from the WORKER's `time.monotonic()` — a clock with an
|
||||||
|
arbitrary origin per process. Subtracting it from this process's wall
|
||||||
|
clock produces a number that looks like a duration and is meaningless, and
|
||||||
|
it would be meaningless in the direction that matters: plausible.
|
||||||
|
|
||||||
|
`task_run` also already carries the per-queue staleness thresholds the
|
||||||
|
recovery sweep uses, so a row still `running` here is one the system
|
||||||
|
itself considers legitimately in flight rather than abandoned.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(TaskRun.queue, func.min(TaskRun.started_at))
|
||||||
|
.where(TaskRun.status == "running", TaskRun.finished_at.is_(None))
|
||||||
|
.group_by(TaskRun.queue)
|
||||||
|
)
|
||||||
|
return {queue: started for queue, started in result if started is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_since(started: datetime | None, now: datetime) -> int | None:
|
||||||
|
"""Whole minutes, or None when nothing is running. Never negative: a row
|
||||||
|
written by a container whose clock is a few seconds ahead must read as 0
|
||||||
|
rather than as a task that starts in the future."""
|
||||||
|
if started is None:
|
||||||
|
return None
|
||||||
|
return max(0, int((now - started).total_seconds() // 60))
|
||||||
|
|
||||||
|
|
||||||
def _queue_depths_sync() -> dict[str, int | None]:
|
def _queue_depths_sync() -> dict[str, int | None]:
|
||||||
"""Redis LLEN per queue. None for one that did not answer — see lane_view.
|
"""Redis LLEN per queue. None for one that did not answer — see lane_view.
|
||||||
|
|
||||||
@@ -362,6 +422,7 @@ async def set_lane(
|
|||||||
slots: int | None = None,
|
slots: int | None = None,
|
||||||
slots_cap: int | None = None,
|
slots_cap: int | None = None,
|
||||||
enabled: bool | None = None,
|
enabled: bool | None = None,
|
||||||
|
autoscale: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Store the operator's choice, then push it to the running lane.
|
"""Store the operator's choice, then push it to the running lane.
|
||||||
|
|
||||||
@@ -383,6 +444,7 @@ async def set_lane(
|
|||||||
new_cap = row.slots_cap if slots_cap is None else slots_cap
|
new_cap = row.slots_cap if slots_cap is None else slots_cap
|
||||||
new_slots = row.slots if slots is None else slots
|
new_slots = row.slots if slots is None else slots
|
||||||
new_enabled = row.enabled if enabled is None else enabled
|
new_enabled = row.enabled if enabled is None else enabled
|
||||||
|
new_autoscale = row.autoscale if autoscale is None else autoscale
|
||||||
|
|
||||||
ceiling = derived_ceiling(lane)
|
ceiling = derived_ceiling(lane)
|
||||||
if new_cap < 0 or new_slots < 0:
|
if new_cap < 0 or new_slots < 0:
|
||||||
@@ -398,6 +460,7 @@ async def set_lane(
|
|||||||
row.slots_cap = new_cap
|
row.slots_cap = new_cap
|
||||||
row.slots = new_slots
|
row.slots = new_slots
|
||||||
row.enabled = new_enabled
|
row.enabled = new_enabled
|
||||||
|
row.autoscale = new_autoscale
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
applied, error = True, None
|
applied, error = True, None
|
||||||
@@ -428,6 +491,7 @@ async def set_lane(
|
|||||||
"slots_cap": row.slots_cap,
|
"slots_cap": row.slots_cap,
|
||||||
"ceiling": ceiling,
|
"ceiling": ceiling,
|
||||||
"enabled": row.enabled,
|
"enabled": row.enabled,
|
||||||
|
"autoscale": row.autoscale,
|
||||||
"applied": applied,
|
"applied": applied,
|
||||||
"apply_error": error,
|
"apply_error": error,
|
||||||
# Tells the card to say a download has started rather than leaving the
|
# Tells the card to say a download has started rather than leaving the
|
||||||
@@ -458,11 +522,28 @@ def _enqueue_model_fetch() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def reconcile_lanes_sync(desired: dict[str, tuple[int, bool]]) -> dict:
|
def reconcile_lanes_sync(
|
||||||
|
desired: dict[str, tuple[int, bool]],
|
||||||
|
autoscaling: frozenset[str] = frozenset(),
|
||||||
|
) -> dict:
|
||||||
"""Drive every RUNNING lane to its stored slots and enabled flag.
|
"""Drive every RUNNING lane to its stored slots and enabled flag.
|
||||||
|
|
||||||
`desired` is lane name -> (slots, enabled), read from the database by the
|
`desired` is lane name -> (slots, enabled), read from the database by the
|
||||||
caller. This function touches no database: the celery task that schedules
|
caller. `autoscaling` names the lanes the autoscaler is allowed to move.
|
||||||
|
|
||||||
|
## For an autoscaling lane the stored value is a FLOOR, not a target
|
||||||
|
|
||||||
|
Step 7's autoscaler raises a saturated lane's live pool without changing
|
||||||
|
its row — the row holds what the OPERATOR set. If this pass treated that
|
||||||
|
row as an exact target it would shrink the lane back on the very next
|
||||||
|
tick, and the two sweeps would fight forever at five-minute intervals:
|
||||||
|
grow, revert, grow, revert. That is lesson #4183's failure arriving
|
||||||
|
between two enforcers rather than inside one.
|
||||||
|
|
||||||
|
So for those lanes the target becomes `max(stored, current)` — this pass
|
||||||
|
still restores a lane that came back from a restart below what the
|
||||||
|
operator set, and never takes back what the autoscaler added. Bringing it
|
||||||
|
down is the autoscaler's job, and it does so only to that same floor. This function touches no database: the celery task that schedules
|
||||||
it owns the sync session, and keeping the DB out of here is what lets the
|
it owns the sync session, and keeping the DB out of here is what lets the
|
||||||
same code be called from anywhere that already knows the target.
|
same code be called from anywhere that already knows the target.
|
||||||
|
|
||||||
@@ -521,17 +602,177 @@ def reconcile_lanes_sync(desired: dict[str, tuple[int, bool]]) -> dict:
|
|||||||
changed.append(lane.name)
|
changed.append(lane.name)
|
||||||
|
|
||||||
current = state.pool
|
current = state.pool
|
||||||
if current is not None and current == slots:
|
# The floor, for a lane the autoscaler manages. See the docstring.
|
||||||
|
target_slots = slots
|
||||||
|
if lane.name in autoscaling and current is not None:
|
||||||
|
target_slots = max(slots, current)
|
||||||
|
if current is not None and current == target_slots:
|
||||||
continue
|
continue
|
||||||
ok, err = set_lane_slots_sync(lane, slots, live=state)
|
ok, err = set_lane_slots_sync(lane, target_slots, live=state)
|
||||||
if ok:
|
if ok:
|
||||||
if lane.name not in changed:
|
if lane.name not in changed:
|
||||||
changed.append(lane.name)
|
changed.append(lane.name)
|
||||||
log.info(
|
log.info(
|
||||||
"worker_control: %s reconciled %s -> %s slots",
|
"worker_control: %s reconciled %s -> %s slots",
|
||||||
lane.name, current, slots,
|
lane.name, current, target_slots,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
failed[lane.name] = err or "could not resize"
|
failed[lane.name] = err or "could not resize"
|
||||||
|
|
||||||
return {"changed": changed, "skipped": skipped, "failed": failed}
|
return {"changed": changed, "skipped": skipped, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
# --- the autoscaler (step 7) --------------------------------------------------
|
||||||
|
#
|
||||||
|
# The only part of this milestone that acts without anyone asking. Everything
|
||||||
|
# above does what an operator pressed; this decides. So it is off by default,
|
||||||
|
# opted into per lane, bounded by the cap the operator set, and it reports what
|
||||||
|
# it did rather than moving numbers silently.
|
||||||
|
|
||||||
|
# ## Why these three numbers are not in Settings
|
||||||
|
#
|
||||||
|
# Rule 25 puts anything an operator might want to tune in the UI, and the
|
||||||
|
# knobs that decide what this does ARE there: whether a lane autoscales at
|
||||||
|
# all, its cap, and its floor — all DB-backed, all changeable without a
|
||||||
|
# restart. What is left here is the POLICY's internals, and exposing them
|
||||||
|
# would add four numbers per lane to a card whose whole value is being
|
||||||
|
# readable at a glance, to tune a decision the operator has a better lever
|
||||||
|
# for. If growth turns out to be too eager or too shy in practice, that is a
|
||||||
|
# reason to change these values for everyone, not to ask each operator to
|
||||||
|
# discover them.
|
||||||
|
|
||||||
|
# All slots busy AND this many tasks waiting before a lane may grow.
|
||||||
|
#
|
||||||
|
# The AND is the design. Depth with free slots means nothing — celery is about
|
||||||
|
# to pick those up, and growing the pool would add idle children. Saturation
|
||||||
|
# with an empty queue means nothing either: the lane is busy with exactly as
|
||||||
|
# much work as exists. Only both together say "there is more work than this
|
||||||
|
# lane can reach".
|
||||||
|
AUTOSCALE_BACKLOG_THRESHOLD = 10
|
||||||
|
|
||||||
|
# Grow by one slot per tick, never to the cap in one jump. A lane that is
|
||||||
|
# saturated because of one slow burst settles a slot or two above where it
|
||||||
|
# started rather than at its ceiling, and the next tick re-measures rather
|
||||||
|
# than committing to a guess made once.
|
||||||
|
AUTOSCALE_STEP = 1
|
||||||
|
|
||||||
|
# Hysteresis: shrink only when the backlog is well BELOW the grow threshold,
|
||||||
|
# not merely under it. Equal thresholds flap — one task arriving and leaving
|
||||||
|
# would grow and shrink the lane forever at the tick interval, which is
|
||||||
|
# lesson #4183's churn arriving through a different door.
|
||||||
|
AUTOSCALE_SHRINK_BELOW = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AutoscaleDecision:
|
||||||
|
"""What the autoscaler did to one lane, and why — in the operator's terms.
|
||||||
|
|
||||||
|
A reason string on every outcome including "nothing", because an
|
||||||
|
autoscaler that only speaks when it acts is one nobody can debug when it
|
||||||
|
does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lane: str
|
||||||
|
action: str # "grew" | "shrank" | "held"
|
||||||
|
slots: int
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def autoscale_lanes_sync(
|
||||||
|
lanes: dict[str, tuple[int, int, bool]],
|
||||||
|
) -> list[AutoscaleDecision]:
|
||||||
|
"""Decide and apply one round of autoscaling.
|
||||||
|
|
||||||
|
`lanes` is name -> (slots_cap, configured_slots, autoscale_on), read from
|
||||||
|
the database by the caller — this function touches no database, for the
|
||||||
|
same reason `reconcile_lanes_sync` does not.
|
||||||
|
|
||||||
|
## What is current, and what is the floor
|
||||||
|
|
||||||
|
The value this moves is the LIVE pool, read from `inspect`. The stored
|
||||||
|
`configured_slots` is what the operator set and is only the FLOOR: growth
|
||||||
|
goes above it and a shrink returns to it, never below.
|
||||||
|
|
||||||
|
The two are deliberately not the same number, and reading the stored value
|
||||||
|
as "current" is the mistake that makes this function useless in a way no
|
||||||
|
unit test of a single tick would show. The autoscaler never writes the
|
||||||
|
row, so the stored value never moves; a tick that computed `stored + 1`
|
||||||
|
would propose the same target forever, cap the lane one slot above the
|
||||||
|
floor no matter the load, and — because resizing a replica already at the
|
||||||
|
target issues nothing and reports success — claim `grew` on every tick
|
||||||
|
while nothing changed. Lesson #4183's non-convergence, arriving with a
|
||||||
|
success message attached.
|
||||||
|
|
||||||
|
So: `current = state.pool`, `configured` is the floor, and both `grew` and
|
||||||
|
`shrank` mean the live pool actually moved.
|
||||||
|
"""
|
||||||
|
live = inspect_lanes_sync()
|
||||||
|
depths = _queue_depths_sync()
|
||||||
|
out: list[AutoscaleDecision] = []
|
||||||
|
|
||||||
|
for lane in LANES:
|
||||||
|
target = lanes.get(lane.name)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
cap, configured, on = target
|
||||||
|
if not on:
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = live[lane.name]
|
||||||
|
if not state.present or state.pool is None:
|
||||||
|
# Nothing answered. Not "idle" — unknown, and a decision drawn
|
||||||
|
# from an unswept read is exactly what snippet #3969 warns about.
|
||||||
|
# `configured` is reported because there is no live number to
|
||||||
|
# report; it is what the lane will come back at.
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "held", configured, "lane is not answering",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = state.pool
|
||||||
|
known = [depths.get(q) for q in lane.queues]
|
||||||
|
if all(d is None for d in known):
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "held", current, "queue depth unavailable",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
backlog = sum(d for d in known if d is not None) + state.reserved
|
||||||
|
# Against CAPACITY, not against the dial: `active` is summed across
|
||||||
|
# replicas, so comparing it to one replica's pool size would call two
|
||||||
|
# half-busy replicas of 4 saturated at 4 active and grow a lane that
|
||||||
|
# has idle slots.
|
||||||
|
saturated = state.active >= state.capacity > 0
|
||||||
|
busy = backlog >= AUTOSCALE_BACKLOG_THRESHOLD
|
||||||
|
|
||||||
|
if saturated and busy and current < cap:
|
||||||
|
new = min(cap, current + AUTOSCALE_STEP)
|
||||||
|
ok, err = set_lane_slots_sync(lane, new, live=state)
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "grew" if ok else "held", new if ok else current,
|
||||||
|
f"{backlog} waiting and all {state.capacity} slots busy"
|
||||||
|
if ok else f"could not grow: {err}",
|
||||||
|
))
|
||||||
|
elif saturated and busy:
|
||||||
|
# At the cap with work still waiting. Said out loud rather than
|
||||||
|
# held silently: this is the operator's own ceiling doing its job,
|
||||||
|
# and it is the moment they would want to know they set it.
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "held", current,
|
||||||
|
f"{backlog} waiting but the cap is {cap}",
|
||||||
|
))
|
||||||
|
elif current > configured and backlog <= AUTOSCALE_SHRINK_BELOW:
|
||||||
|
new = max(configured, current - AUTOSCALE_STEP)
|
||||||
|
ok, err = set_lane_slots_sync(lane, new, live=state)
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "shrank" if ok else "held", new if ok else current,
|
||||||
|
f"backlog cleared, back toward {configured}"
|
||||||
|
if ok else f"could not shrink: {err}",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# The fixed point. A settled lane sends nothing and says so —
|
||||||
|
# the tick is one inspect and one LLEN sweep, no control messages.
|
||||||
|
out.append(AutoscaleDecision(
|
||||||
|
lane.name, "held", current,
|
||||||
|
f"{backlog} waiting, {state.active} busy",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ class Lane:
|
|||||||
# rubber stamp and protects nobody.
|
# rubber stamp and protects nobody.
|
||||||
default_slots_cap: int
|
default_slots_cap: int
|
||||||
default_enabled: bool
|
default_enabled: bool
|
||||||
|
# Whether a lane arrives with the autoscaler on. False for every lane, and
|
||||||
|
# the field exists anyway: this module is the one place that describes a
|
||||||
|
# lane, and leaving one operator-settable default to the column's DDL
|
||||||
|
# default would make it the only setting you cannot read here.
|
||||||
|
default_autoscale: bool = False
|
||||||
# True when a slot costs a copy of the ML model rather than just a process.
|
# True when a slot costs a copy of the ML model rather than just a process.
|
||||||
# The only lane whose ceiling is decided by memory instead of by cores.
|
# The only lane whose ceiling is decided by memory instead of by cores.
|
||||||
memory_bound: bool = False
|
memory_bound: bool = False
|
||||||
|
|||||||
@@ -1387,14 +1387,69 @@ def reconcile_worker_lanes() -> dict:
|
|||||||
from ..models import WorkerLane
|
from ..models import WorkerLane
|
||||||
from ..services.worker_control import reconcile_lanes_sync
|
from ..services.worker_control import reconcile_lanes_sync
|
||||||
|
|
||||||
|
# Both dicts built INSIDE the session. Reading a column off a detached
|
||||||
|
# instance happens to work while the attribute is still loaded and stops
|
||||||
|
# working the moment anything expires it — a failure that would appear
|
||||||
|
# long after this line, in a sweep nobody is watching.
|
||||||
with _sync_session_factory()() as session:
|
with _sync_session_factory()() as session:
|
||||||
desired = {
|
rows = list(session.execute(select(WorkerLane)).scalars())
|
||||||
row.name: (row.slots, row.enabled)
|
desired = {row.name: (row.slots, row.enabled) for row in rows}
|
||||||
for row in session.execute(select(WorkerLane)).scalars()
|
# Lanes the autoscaler may move. For these the stored value is a
|
||||||
}
|
# FLOOR: this sweep restores a lane that fell below it and never takes
|
||||||
|
# back what the autoscaler added, or the two would fight every five
|
||||||
|
# minutes.
|
||||||
|
autoscaling = frozenset(row.name for row in rows if row.autoscale)
|
||||||
if not desired:
|
if not desired:
|
||||||
# Migration 0103 seeds these, so an empty table means it has not run
|
# Migration 0103 seeds these, so an empty table means it has not run
|
||||||
# yet. Nothing to assert — and inventing defaults here would let this
|
# yet. Nothing to assert — and inventing defaults here would let this
|
||||||
# task disagree with the seed it is supposed to be enforcing.
|
# task disagree with the seed it is supposed to be enforcing.
|
||||||
return {"changed": [], "skipped": [], "failed": {}}
|
return {"changed": [], "skipped": [], "failed": {}}
|
||||||
return reconcile_lanes_sync(desired)
|
return reconcile_lanes_sync(desired, autoscaling)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="backend.app.tasks.maintenance.autoscale_worker_lanes")
|
||||||
|
def autoscale_worker_lanes() -> dict:
|
||||||
|
"""Grow a saturated lane, within the cap the operator set.
|
||||||
|
|
||||||
|
Milestone 422 step 7, and the only sweep in this milestone that decides
|
||||||
|
rather than obeys. Everything else applies what someone pressed.
|
||||||
|
|
||||||
|
OFF unless a lane opts in. A no-op costs one `celery inspect` and one LLEN
|
||||||
|
sweep and sends no control messages — the same fixed point the reconcile
|
||||||
|
holds, and for the same reason: this runs forever, so a settled system has
|
||||||
|
to be silent or the real signal drowns in its own heartbeat.
|
||||||
|
|
||||||
|
Returns every lane's decision INCLUDING the ones it held, each with a
|
||||||
|
reason. An autoscaler that only speaks when it acts cannot be debugged on
|
||||||
|
the day it does not.
|
||||||
|
"""
|
||||||
|
from ..models import WorkerLane
|
||||||
|
from ..services.worker_control import autoscale_lanes_sync
|
||||||
|
|
||||||
|
with _sync_session_factory()() as session:
|
||||||
|
lanes = {
|
||||||
|
# The stored `slots` is passed ONLY as the floor. What the
|
||||||
|
# autoscaler moves is the live pool, which it reads itself — this
|
||||||
|
# row is never written, so using it as the current value would pin
|
||||||
|
# every decision to the same number forever.
|
||||||
|
row.name: (row.slots_cap, row.slots, True)
|
||||||
|
for row in session.execute(select(WorkerLane)).scalars()
|
||||||
|
if row.autoscale
|
||||||
|
}
|
||||||
|
if not lanes:
|
||||||
|
return {"decisions": []}
|
||||||
|
|
||||||
|
decisions = autoscale_lanes_sync(lanes)
|
||||||
|
for d in decisions:
|
||||||
|
if d.action != "held":
|
||||||
|
log.info(
|
||||||
|
"autoscale: %s %s to %s slots — %s",
|
||||||
|
d.lane, d.action, d.slots, d.reason,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"decisions": [
|
||||||
|
{"lane": d.lane, "action": d.action, "slots": d.slots,
|
||||||
|
"reason": d.reason}
|
||||||
|
for d in decisions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<th class="text-right">Pending</th>
|
<th class="text-right">Pending</th>
|
||||||
<th class="text-right">Busy</th>
|
<th class="text-right">Busy</th>
|
||||||
<th style="width: 200px;">Slots</th>
|
<th style="width: 200px;">Slots</th>
|
||||||
|
<th class="text-right">Auto</th>
|
||||||
<th class="text-right">On</th>
|
<th class="text-right">On</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -62,6 +63,15 @@
|
|||||||
<div v-else-if="lane.live.replicas > 1" class="text-caption fc-muted">
|
<div v-else-if="lane.live.replicas > 1" class="text-caption fc-muted">
|
||||||
{{ lane.live.replicas }} replicas
|
{{ lane.live.replicas }} replicas
|
||||||
</div>
|
</div>
|
||||||
|
<!-- The operator asked for a lane held at full for a long time
|
||||||
|
to TRIGGER growth. It reports instead: extra slots do not
|
||||||
|
make a long task finish sooner, so scaling on this would
|
||||||
|
spend memory to change nothing. Seeing the lane is wedged
|
||||||
|
on one slow job is the useful half, and it cannot mislead
|
||||||
|
a lane into growing for the wrong reason. -->
|
||||||
|
<div v-if="laneStuckFor(lane)" class="text-caption text-warning">
|
||||||
|
all slots busy for {{ laneStuckFor(lane) }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-caption fc-muted">{{ lane.queues.join(', ') }}</td>
|
<td class="text-caption fc-muted">{{ lane.queues.join(', ') }}</td>
|
||||||
@@ -106,6 +116,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- The autoscaler is the one control here that acts without
|
||||||
|
being asked, so it is a separate switch from `On` rather than
|
||||||
|
a mode folded into it, and it is off until someone opts this
|
||||||
|
particular lane in.
|
||||||
|
|
||||||
|
Turning it ON needs room to grow into; turning it OFF never
|
||||||
|
does. Disabling the whole switch at cap == slots would strand
|
||||||
|
an already-autoscaling lane as soon as someone raised its
|
||||||
|
floor to the cap, with the control that would undo it greyed
|
||||||
|
out. And the reason is on screen, for the same reason the
|
||||||
|
ceiling justifies itself below: a greyed control with no
|
||||||
|
explanation reads as a bug. -->
|
||||||
|
<td class="text-right">
|
||||||
|
<v-switch
|
||||||
|
:model-value="lane.autoscale"
|
||||||
|
density="compact" hide-details color="accent"
|
||||||
|
:disabled="busy === lane.name || (!lane.autoscale && !canGrow(lane))"
|
||||||
|
:aria-label="`Let ${lane.display_name} grow itself`"
|
||||||
|
@update:model-value="setAutoscale(lane, $event)"
|
||||||
|
/>
|
||||||
|
<div v-if="!canGrow(lane)" class="text-caption fc-muted">
|
||||||
|
raise the cap
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<v-switch
|
<v-switch
|
||||||
:model-value="lane.enabled"
|
:model-value="lane.enabled"
|
||||||
@@ -162,7 +197,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||||
import { formatRelative } from '../../utils/date.js'
|
import { formatRelative } from '../../utils/date.js'
|
||||||
import CardHeading from '../common/CardHeading.vue'
|
import CardHeading from '../common/CardHeading.vue'
|
||||||
|
|
||||||
@@ -238,6 +273,17 @@ function step(lane, delta) {
|
|||||||
function toggle(lane, value) {
|
function toggle(lane, value) {
|
||||||
return apply(lane, { enabled: Boolean(value) })
|
return apply(lane, { enabled: Boolean(value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Room to grow into. The autoscaler moves the LIVE pool, but the floor it
|
||||||
|
// starts from is the stored value, so a lane whose floor already sits at its
|
||||||
|
// cap has nowhere to go and enabling it would do nothing at all.
|
||||||
|
function canGrow(lane) {
|
||||||
|
return lane.slots_cap > lane.slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoscale(lane, value) {
|
||||||
|
return apply(lane, { autoscale: Boolean(value) })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ import { ref } from 'vue'
|
|||||||
|
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
// Both halves, never one (milestone 422 step 7). Saturation alone is a lane
|
||||||
|
// doing its job; a long-running task alone may be the only thing on a lane
|
||||||
|
// with slots to spare. Only together do they mean "nothing else on this lane
|
||||||
|
// can start", which is the thing worth interrupting someone about.
|
||||||
|
//
|
||||||
|
// The operator asked for this to TRIGGER growth. It reports instead: a long
|
||||||
|
// task does not finish sooner because the lane gained a slot, so autoscaling
|
||||||
|
// on it would spend memory to change nothing. Exported rather than left in
|
||||||
|
// the card so the AND is pinned by a test — a warning that fires on half the
|
||||||
|
// condition is a warning people learn to ignore.
|
||||||
|
export const LANE_STUCK_MINUTES = 15
|
||||||
|
|
||||||
|
export function laneStuckFor(lane) {
|
||||||
|
const mins = lane?.oldest_running_minutes
|
||||||
|
if (mins === null || mins === undefined || mins < LANE_STUCK_MINUTES) return null
|
||||||
|
// Unknown is not busy: a lane nothing answered for has no live reading to
|
||||||
|
// call saturated, and `pool` of 0 or null is not a full pool.
|
||||||
|
if (!lane.live?.present || !lane.live.pool) return null
|
||||||
|
if (lane.live.active < lane.live.pool) return null
|
||||||
|
if (mins < 120) return `${mins} minutes`
|
||||||
|
return `${Math.floor(mins / 60)} hours`
|
||||||
|
}
|
||||||
|
|
||||||
export const useSystemActivityStore = defineStore('systemActivity', () => {
|
export const useSystemActivityStore = defineStore('systemActivity', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useSystemActivityStore } from '../src/stores/systemActivity.js'
|
import { laneStuckFor, useSystemActivityStore } from '../src/stores/systemActivity.js'
|
||||||
|
|
||||||
// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
|
// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
|
||||||
// part that decides what the card can tell the operator.
|
// part that decides what the card can tell the operator.
|
||||||
@@ -139,3 +139,54 @@ describe('worker lanes store', () => {
|
|||||||
expect(err.body.detail).toContain('container can hold')
|
expect(err.body.detail).toContain('container can hold')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- the long-running-task warning (step 7) ----------------------------------
|
||||||
|
//
|
||||||
|
// The operator asked for "a task runs for x concurrent time" to TRIGGER
|
||||||
|
// growth. It became a warning: extra slots do not make a running task finish
|
||||||
|
// sooner. What is pinned here is the AND — a warning that fires on half its
|
||||||
|
// condition is one people learn to ignore, and at that point it is worse than
|
||||||
|
// not having it.
|
||||||
|
|
||||||
|
describe('laneStuckFor', () => {
|
||||||
|
const lane = (over = {}) => ({
|
||||||
|
oldest_running_minutes: 40,
|
||||||
|
live: { present: true, pool: 4, active: 4 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns when every slot is busy and the oldest task is old', () => {
|
||||||
|
expect(laneStuckFor(lane())).toBe('40 minutes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing when the lane has a free slot', () => {
|
||||||
|
expect(laneStuckFor(lane({ live: { present: true, pool: 4, active: 3 } })))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing for a saturated lane whose tasks are young', () => {
|
||||||
|
expect(laneStuckFor(lane({ oldest_running_minutes: 2 }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing when nothing is running', () => {
|
||||||
|
expect(laneStuckFor(lane({ oldest_running_minutes: null }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing about a lane that is not answering', () => {
|
||||||
|
// Unknown is not busy. A lane nothing answered for has no live reading to
|
||||||
|
// call saturated, and asserting one would be a verdict from an unswept
|
||||||
|
// read — the same distinction `present` exists for everywhere else here.
|
||||||
|
expect(laneStuckFor(lane({ live: { present: false, pool: null, active: 0 } })))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing when the pool is zero', () => {
|
||||||
|
// A lane sized to zero is not "fully busy at zero" — it is off.
|
||||||
|
expect(laneStuckFor(lane({ live: { present: true, pool: 0, active: 0 } })))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to hours once minutes stop being readable', () => {
|
||||||
|
expect(laneStuckFor(lane({ oldest_running_minutes: 195 }))).toBe('3 hours')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -321,3 +321,245 @@ def test_the_reconcile_task_is_registered_and_scheduled():
|
|||||||
assert name in celery.tasks
|
assert name in celery.tasks
|
||||||
scheduled = {e["task"] for e in celery.conf.beat_schedule.values()}
|
scheduled = {e["task"] for e in celery.conf.beat_schedule.values()}
|
||||||
assert name in scheduled
|
assert name in scheduled
|
||||||
|
|
||||||
|
|
||||||
|
# --- the autoscaler (step 7) --------------------------------------------------
|
||||||
|
#
|
||||||
|
# Every case below fixes the LIVE pool and the stored floor to DIFFERENT
|
||||||
|
# numbers wherever it can. That is deliberate: the first version of this
|
||||||
|
# function read the stored value as the current one, and with the two equal
|
||||||
|
# — which is what a single tick of a settled system looks like — every test
|
||||||
|
# here still passed while the autoscaler could not grow past floor+1 or shrink
|
||||||
|
# at all. Equal fixtures cannot see that bug.
|
||||||
|
|
||||||
|
|
||||||
|
def _autoscale_live(
|
||||||
|
monkeypatch, *, pools, active, reserved, depth, present=True,
|
||||||
|
):
|
||||||
|
state = wc.LaneLiveState(
|
||||||
|
present=present,
|
||||||
|
replicas=len(pools),
|
||||||
|
hostnames=sorted(pools),
|
||||||
|
pools=dict(pools),
|
||||||
|
active=active,
|
||||||
|
reserved=reserved,
|
||||||
|
consuming=set(LANES_BY_NAME["worker"].queues),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wc, "inspect_lanes_sync",
|
||||||
|
lambda: {name: (state if name == "worker" else wc.LaneLiveState())
|
||||||
|
for name in LANES_BY_NAME},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wc, "_queue_depths_sync",
|
||||||
|
lambda: {q: depth for lane in LANES_BY_NAME.values() for q in lane.queues},
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _worker(decisions):
|
||||||
|
return next(d for d in decisions if d.lane == "worker")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prefetched_backlog_still_triggers_growth(monkeypatch):
|
||||||
|
"""THE case an LLEN-only implementation misses, and the reason `reserved`
|
||||||
|
was plumbed through in step 2. Celery prefetches, so a lane can read queue
|
||||||
|
depth 0 while holding thirty tasks in worker memory — an autoscaler
|
||||||
|
watching LLEN alone sees an idle system and never grows."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 2}, active=2, reserved=30, depth=0)
|
||||||
|
|
||||||
|
d = _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)}))
|
||||||
|
|
||||||
|
assert d.action == "grew"
|
||||||
|
assert d.slots == 3
|
||||||
|
assert control.grew == [(1, ["host-a"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_keeps_climbing_past_the_floor_on_later_ticks(monkeypatch):
|
||||||
|
"""The regression test for reading the stored value as the current one.
|
||||||
|
|
||||||
|
The row still says 2 — the autoscaler never writes it — but the live pool
|
||||||
|
is already 5 from earlier ticks. The target must be 6. Computing from the
|
||||||
|
stored 2 would propose 3, which resizes nothing (the replica is past it),
|
||||||
|
reports success, and pins the lane one slot above its floor forever while
|
||||||
|
claiming to grow on every tick."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 5}, active=5, reserved=40, depth=0)
|
||||||
|
|
||||||
|
d = _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)}))
|
||||||
|
|
||||||
|
assert d.slots == 6
|
||||||
|
assert control.grew == [(1, ["host-a"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backlog_with_free_slots_does_nothing(monkeypatch):
|
||||||
|
"""Depth alone is not a signal — celery is about to pick those up, and
|
||||||
|
growing would add children that idle."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 8}, active=1, reserved=0, depth=50)
|
||||||
|
|
||||||
|
assert _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)})).action == "held"
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_saturation_is_measured_against_every_replica(monkeypatch):
|
||||||
|
"""`active` is summed across replicas and `pool` is one replica's size, so
|
||||||
|
comparing them calls two half-busy replicas of 4 saturated at 4 active.
|
||||||
|
Capacity is 8 here and 4 tasks are running: half the lane is idle."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(
|
||||||
|
monkeypatch, pools={"host-a": 4, "host-b": 4},
|
||||||
|
active=4, reserved=40, depth=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)})).action == "held"
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_saturated_lane_with_no_backlog_does_nothing(monkeypatch):
|
||||||
|
"""The long-running-task case. One slow task holding every slot with an
|
||||||
|
empty queue needs no extra slots — growing does not make it finish sooner,
|
||||||
|
which is why the operator's 'runs for x time' idea became a UI warning
|
||||||
|
rather than a trigger."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 2}, active=2, reserved=0, depth=0)
|
||||||
|
|
||||||
|
assert _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)})).action == "held"
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_growth_stops_at_the_cap_and_says_so(monkeypatch):
|
||||||
|
"""The autoscaler gets no authority the operator does not already have.
|
||||||
|
And it SAYS it is capped — that is the moment they would want to know they
|
||||||
|
set one."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 4}, active=4, reserved=99, depth=0)
|
||||||
|
|
||||||
|
d = _worker(wc.autoscale_lanes_sync({"worker": (4, 2, True)}))
|
||||||
|
|
||||||
|
assert d.action == "held"
|
||||||
|
assert "cap is 4" in d.reason
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cleared_backlog_returns_the_lane_to_the_operator_s_value(monkeypatch):
|
||||||
|
"""Down toward `configured`, one step at a time."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 5}, active=0, reserved=0, depth=0)
|
||||||
|
|
||||||
|
d = _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)}))
|
||||||
|
|
||||||
|
assert d.action == "shrank"
|
||||||
|
assert d.slots == 4
|
||||||
|
assert control.shrank == [(1, ["host-a"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_never_shrinks_below_what_the_operator_set(monkeypatch):
|
||||||
|
"""The stored value is the operator's and the autoscaler must not eat it —
|
||||||
|
there would be nothing left to restore to."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 2}, active=0, reserved=0, depth=0)
|
||||||
|
|
||||||
|
assert _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)})).action == "held"
|
||||||
|
assert control.shrank == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hysteresis_keeps_a_middling_backlog_from_flapping(monkeypatch):
|
||||||
|
"""Between the two thresholds nothing happens. Equal thresholds would grow
|
||||||
|
and shrink the lane forever as one task arrives and leaves — lesson
|
||||||
|
#4183's churn arriving through a different door."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 5}, active=5, reserved=5, depth=0)
|
||||||
|
|
||||||
|
assert _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)})).action == "held"
|
||||||
|
assert control.grew == []
|
||||||
|
assert control.shrank == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lane_that_did_not_opt_in_is_never_touched(monkeypatch):
|
||||||
|
"""Off by default, per lane. This is the only sweep that decides rather
|
||||||
|
than obeys, so it acts only where someone said it may — and it does not
|
||||||
|
even report on a lane it was not given."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 2}, active=2, reserved=99, depth=0)
|
||||||
|
|
||||||
|
assert wc.autoscale_lanes_sync({"worker": (8, 2, False)}) == []
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absent_lane_holds_rather_than_guessing(monkeypatch):
|
||||||
|
"""`present=False` is 'nothing answered', not 'idle'. Deciding from an
|
||||||
|
unswept read is snippet #3969's shape."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(
|
||||||
|
monkeypatch, pools={}, active=0, reserved=0, depth=0, present=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
d = _worker(wc.autoscale_lanes_sync({"worker": (8, 2, True)}))
|
||||||
|
|
||||||
|
assert d.action == "held"
|
||||||
|
assert "not answering" in d.reason
|
||||||
|
assert control.grew == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_settled_lane_sends_no_control_messages(monkeypatch):
|
||||||
|
"""The fixed point, asserted as a property rather than inferred from the
|
||||||
|
reported action: this runs every minute forever, so a settled system has
|
||||||
|
to be silent or a real correction drowns in the heartbeat."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_autoscale_live(monkeypatch, pools={"host-a": 2}, active=1, reserved=0, depth=1)
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
assert _worker(
|
||||||
|
wc.autoscale_lanes_sync({"worker": (8, 2, True)})
|
||||||
|
).action == "held"
|
||||||
|
assert control.grew == []
|
||||||
|
assert control.shrank == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- the two sweeps must not fight -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reconcile_does_not_undo_what_the_autoscaler_added(monkeypatch):
|
||||||
|
"""Otherwise the two would fight every five minutes: grow, revert, grow,
|
||||||
|
revert. For an autoscaling lane the stored slots are a FLOOR."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_stub_live(monkeypatch, "worker", pools={"host-a": 6})
|
||||||
|
|
||||||
|
wc.reconcile_lanes_sync({"worker": (2, True)}, frozenset({"worker"}))
|
||||||
|
|
||||||
|
assert control.shrank == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reconcile_still_restores_an_autoscaling_lane_that_fell_below(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""A floor is still a floor. A restart drops the lane to its env value and
|
||||||
|
this must bring it back to what the operator set."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_stub_live(monkeypatch, "worker", pools={"host-a": 1})
|
||||||
|
|
||||||
|
wc.reconcile_lanes_sync({"worker": (4, True)}, frozenset({"worker"}))
|
||||||
|
|
||||||
|
assert control.grew == [(3, ["host-a"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_autoscaling_lane_is_still_driven_down_to_its_value(monkeypatch):
|
||||||
|
"""The floor applies only to lanes that opted in — otherwise turning
|
||||||
|
autoscale off would leave the lane stuck at whatever it had grown to."""
|
||||||
|
control = _stub_control(monkeypatch)
|
||||||
|
_stub_live(monkeypatch, "worker", pools={"host-a": 6})
|
||||||
|
|
||||||
|
wc.reconcile_lanes_sync({"worker": (2, True)}, frozenset())
|
||||||
|
|
||||||
|
assert control.shrank == [(4, ["host-a"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_autoscale_task_is_registered_and_scheduled():
|
||||||
|
import backend.app.tasks.maintenance # noqa: F401
|
||||||
|
from backend.app.celery_app import celery
|
||||||
|
|
||||||
|
name = "backend.app.tasks.maintenance.autoscale_worker_lanes"
|
||||||
|
assert name in celery.tasks
|
||||||
|
assert name in {e["task"] for e in celery.conf.beat_schedule.values()}
|
||||||
|
|||||||
Reference in New Issue
Block a user