Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions web/src/modules/inferences/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,20 @@ describe("installSteps", () => {
{ type: "ScalingLimited", status: "True", reason: "TooManyReplicas" },
])).toEqual({ key: "scaler", state: "ok", detail: "steady", params: { n: 1 } });
});
it("holds while a rollout or scale-down guard is in effect", () => {
it("reports a hold as steady even when the replica count has not caught up", () => {
for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]) {
expect(step([
{ type: "ScalingActive", status: "True", reason: "MetricsAvailable" },
{ type: "AbleToScale", status: "True", reason },
], 3, 1)).toEqual({ key: "scaler", state: "wait", detail: "deferred", params: { reason } });
], 3, 1)).toEqual({ key: "scaler", state: "ok", detail: "steady", params: { n: 3 } });
}
});
it("lets a failure win over a hold", () => {
expect(step([
{ type: "ScalingActive", status: "False", reason: "DecisionUnavailable" },
{ type: "AbleToScale", status: "True", reason: "ScaleDownStabilized" },
], 3, 1)).toEqual({ key: "scaler", state: "bad", detail: "failing", params: { reason: "DecisionUnavailable" } });
});
it("treats DeferralTimedOut as a normal scale", () => {
expect(step([
{ type: "ScalingActive", status: "True", reason: "DecisionReceived" },
Expand Down
19 changes: 4 additions & 15 deletions web/src/modules/inferences/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
Run,
} from "@swiss/lib/api";
import { isUninstalling } from "@swiss/lib/phase";
import { scalerFailing, scalerHold } from "@swiss/lib/scaler";

export type Tone = "success" | "info" | "warning" | "error" | "muted";

Expand Down Expand Up @@ -159,35 +160,23 @@ function routeStep({ result, spec, status }: Picked<ModelRouteSpec, ModelRouteSt
return status?.ready ? { key, state: "ok", detail: "ready", params } : { key, state: "wait", detail: "notReady", params };
}

// AbleToScale=True reasons that hold a step. Synced and DeferralTimedOut are a normal scale.
const SCALE_HOLD_REASONS = new Set(["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]);

function scalerStep({ result, status }: Picked<LLMScalerSpec, LLMScalerStatus>): Step {
const key = "scaler";
if (!result) return { key, state: "off", detail: "none" };
if (result.error) return { key, state: "wait", detail: "unreadable" };
if (result.missing) return { key, state: "bad", detail: "deleted" };
// DecisionAvailable is a deprecated mirror of ScalingActive and the only condition operator 0.5.0 writes.
const conditions = status?.conditions ?? [];
const ofType = (type: string) => conditions.find((c) => c.type === type);
const active = ofType("ScalingActive") ?? ofType("DecisionAvailable");
const able = ofType("AbleToScale");

const failing = able?.status === "False" ? able : active?.status === "False" ? active : undefined;
const failing = scalerFailing(status?.conditions);
if (failing) {
const reason = failing.reason || failing.type;
if (failing.message) return { key, state: "bad", detail: "failingMessage", params: { reason, message: failing.message } };
return { key, state: "bad", detail: "failing", params: { reason } };
}

if (able?.status === "True" && able.reason && SCALE_HOLD_REASONS.has(able.reason)) {
return { key, state: "wait", detail: "deferred", params: { reason: able.reason } };
}

const cur = status?.currentReplicas;
const want = status?.desiredReplicas;
if (cur === undefined) return { key, state: "wait", detail: "noDecision" };
if (want !== undefined && want !== cur) return { key, state: "wait", detail: "scaling", params: { cur, want } };
if (want !== undefined && want !== cur && !scalerHold(status?.conditions))
return { key, state: "wait", detail: "scaling", params: { cur, want } };
return { key, state: "ok", detail: "steady", params: { n: cur } };
}

Expand Down
1 change: 0 additions & 1 deletion web/src/modules/inferences/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@
"deleted": "LLMScaler deleted",
"failing": "{reason}",
"failingMessage": "{reason}: {message}",
"deferred": "Holding: {reason}",
"noDecision": "No decision yet",
"scaling": "{cur} → {want} replicas",
"steady": "{n} replicas, steady"
Expand Down
1 change: 0 additions & 1 deletion web/src/modules/inferences/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@
"deleted": "LLMScaler 已被删除",
"failing": "{reason}",
"failingMessage": "{reason}:{message}",
"deferred": "伸缩暂缓:{reason}",
"noDecision": "还没有伸缩决策",
"scaling": "{cur} → {want} 副本",
"steady": "{n} 副本,稳定"
Expand Down
32 changes: 32 additions & 0 deletions web/src/modules/swiss/components/Conditions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { renderToString } from "react-dom/server";
import type { Condition } from "@swiss/lib/api";
import { Conditions } from "@swiss/components/ReleaseObjects";

const able = (status: Condition["status"]): Condition => ({ type: "AbleToScale", status, reason: "ScaleDownStabilized" });
const active: Condition = { type: "ScalingActive", status: "True", reason: "MetricsAvailable" };

// The dot is the rounded-full span immediately before the condition type.
function dotClass(html: string, type: string): string {
const at = html.indexOf(`>${type}<`);
const marks = [...html.slice(0, at).matchAll(/class="([^"]*rounded-full[^"]*)"/g)];
return marks.at(-1)?.[1] ?? "";
}

describe("Conditions", () => {
it("colors a warned condition bg-warning and leaves the others alone", () => {
const conditions = [able("True"), active];
const warned = renderToString(<Conditions conditions={conditions} warn={["AbleToScale"]} />);
expect(dotClass(warned, "AbleToScale")).toContain("bg-warning");
expect(dotClass(warned, "ScalingActive")).not.toContain("bg-warning");

const plain = renderToString(<Conditions conditions={conditions} />);
expect(dotClass(plain, "AbleToScale")).toContain("bg-success");
expect(dotClass(plain, "AbleToScale")).not.toContain("bg-warning");

// warn is checked before neutral, so a listed type stays yellow even when neutral would mute it.
const both = renderToString(<Conditions conditions={conditions} warn={["AbleToScale"]} neutral={["AbleToScale"]} />);
expect(dotClass(both, "AbleToScale")).toContain("bg-warning");
expect(dotClass(both, "AbleToScale")).not.toContain("bg-muted-foreground");
});
});
46 changes: 26 additions & 20 deletions web/src/modules/swiss/components/ReleaseObjects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HoverHint } from "@swiss/components/ui/hint";
import { Button } from "@swiss/components/ui/button";
import { Card } from "@swiss/components/ui/card";
import { ErrorState } from "@swiss/components/States";
import { scalerFailing, scalerHold, scalerNeutral } from "@swiss/lib/scaler";
import { cn, timeAgo } from "@swiss/lib/utils";

// Unpolled, it loads once per visit and then moves only when the page's own
Expand Down Expand Up @@ -247,11 +248,13 @@ function ScalerPanel({
const current = status?.currentReplicas;
const desired = status?.desiredReplicas;
const failing = scalerFailing(status?.conditions);
const hold = scalerHold(status?.conditions);
const custom = spec?.metricProvider === "Custom";

let state: React.ReactNode = null;
if (failing)
state = <Badge variant="warning">{failing.reason || failing.type}</Badge>;
else if (hold) state = <Badge variant="success">steady</Badge>;
else if (current !== undefined && desired !== undefined) {
if (desired > current) state = <Badge variant="warning">scaling up</Badge>;
else if (desired < current)
Expand Down Expand Up @@ -349,7 +352,11 @@ function ScalerPanel({
</div>
)}

<Conditions conditions={status?.conditions} neutral={scalerNeutral(status?.conditions)} />
<Conditions
conditions={status?.conditions}
neutral={scalerNeutral(status?.conditions)}
warn={hold ? ["AbleToScale"] : undefined}
/>
</>
)}
</ObjectCard>
Expand Down Expand Up @@ -681,18 +688,15 @@ function Stale({
);
}

// Only AbleToScale=False or ScalingActive=False (DecisionAvailable on operator 0.5.0) is a scaler failure.
export function scalerFailing(conditions?: Condition[]): Condition | undefined {
const of = (type: string) => conditions?.find((c) => c.type === type);
return [of("AbleToScale"), of("ScalingActive") ?? of("DecisionAvailable")].find((c) => c?.status === "False");
}

// ScalingLimited is informational; DecisionAvailable only mirrors ScalingActive when that is present.
function scalerNeutral(conditions?: Condition[]): string[] {
return conditions?.some((c) => c.type === "ScalingActive") ? ["ScalingLimited", "DecisionAvailable"] : ["ScalingLimited"];
}

export function Conditions({ conditions, neutral = [] }: { conditions?: Condition[]; neutral?: string[] }) {
export function Conditions({
conditions,
neutral = [],
warn = [],
}: {
conditions?: Condition[];
neutral?: string[];
warn?: string[];
}) {
if (!conditions?.length) return null;
return (
<ul className="mt-auto space-y-1.5 border-t pt-3">
Expand All @@ -702,13 +706,15 @@ export function Conditions({ conditions, neutral = [] }: { conditions?: Conditio
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
neutral.includes(c.type)
? "bg-muted-foreground"
: c.status === "True"
? "bg-success"
: c.status === "False"
? "bg-warning"
: "bg-muted-foreground",
warn.includes(c.type)
? "bg-warning"
: neutral.includes(c.type)
? "bg-muted-foreground"
: c.status === "True"
? "bg-success"
: c.status === "False"
? "bg-warning"
: "bg-muted-foreground",
)}
/>
<span className="font-medium">{c.type}</span>
Expand Down
23 changes: 0 additions & 23 deletions web/src/modules/swiss/components/scaler-conditions.test.ts

This file was deleted.

83 changes: 83 additions & 0 deletions web/src/modules/swiss/lib/scaler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { Condition, ObjectResult } from "@swiss/lib/api";
import { scalerFailing, scalerHold, scalerNeutral } from "@swiss/lib/scaler";
import { scalerStep } from "@swiss/routes/DeploymentDetail";

const c = (type: string, status: Condition["status"], reason?: string): Condition => ({ type, status, reason });

describe("scalerFailing", () => {
it("does not treat ScalingLimited as a failure", () => {
expect(scalerFailing([
c("ScalingActive", "True", "DecisionReceived"),
c("AbleToScale", "True", "Synced"),
c("ScalingLimited", "False", "DesiredWithinRange"),
])).toBeUndefined();
});
it("fails on AbleToScale=False or ScalingActive=False", () => {
expect(scalerFailing([c("AbleToScale", "False", "TargetNotFound")])?.reason).toBe("TargetNotFound");
expect(scalerFailing([c("ScalingActive", "False", "InvalidSpec")])?.reason).toBe("InvalidSpec");
});
it("prefers AbleToScale when both are False", () => {
expect(scalerFailing([c("ScalingActive", "False", "InvalidSpec"), c("AbleToScale", "False", "TargetWriteFailed")])?.reason)
.toBe("TargetWriteFailed");
});
it("uses DecisionAvailable only when ScalingActive is absent", () => {
expect(scalerFailing([c("DecisionAvailable", "False", "DecisionUnavailable")])?.reason).toBe("DecisionUnavailable");
expect(scalerFailing([c("ScalingActive", "True", "MetricsAvailable"), c("DecisionAvailable", "False", "DecisionUnavailable")]))
.toBeUndefined();
});
});

describe("scalerHold", () => {
it("returns the hold reason of AbleToScale=True", () => {
for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"])
expect(scalerHold([c("AbleToScale", "True", reason)])).toBe(reason);
});
it("is undefined for a normal scale or a failure", () => {
expect(scalerHold([c("AbleToScale", "True", "Synced")])).toBeUndefined();
expect(scalerHold([c("AbleToScale", "True", "DeferralTimedOut")])).toBeUndefined();
expect(scalerHold([c("AbleToScale", "False", "ScaleDownStabilized")])).toBeUndefined();
expect(scalerHold(undefined)).toBeUndefined();
});
});

describe("scalerNeutral", () => {
it("always includes ScalingLimited, and DecisionAvailable only next to ScalingActive", () => {
expect(scalerNeutral([c("DecisionAvailable", "True", "DecisionReceived")])).toEqual(["ScalingLimited"]);
expect(scalerNeutral([c("ScalingActive", "True", "DecisionReceived")])).toEqual(["ScalingLimited", "DecisionAvailable"]);
expect(scalerNeutral(undefined)).toEqual(["ScalingLimited"]);
});
});

describe("scalerStep", () => {
const result = { ref: { apiVersion: "v1", kind: "LLMScaler", name: "r" } } as ObjectResult;
const step = (conditions: Condition[], cur = 1, want = 1) =>
scalerStep({ result, spec: undefined, status: { currentReplicas: cur, desiredReplicas: want, conditions } });

it("is steady with ScalingLimited=False", () => {
expect(step([c("ScalingActive", "True", "DecisionReceived"), c("AbleToScale", "True", "Synced"), c("ScalingLimited", "False", "DesiredWithinRange")]))
.toEqual({ label: "Scaling", state: "ok", detail: "1 replica, steady" });
});
it("fails on AbleToScale=False", () => {
expect(step([c("ScalingActive", "True", "DecisionReceived"), c("AbleToScale", "False", "TargetNotFound")]))
.toEqual({ label: "Scaling", state: "bad", detail: "TargetNotFound" });
});
it("reports a hold as steady", () => {
for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]) {
expect(step([c("ScalingActive", "True", "MetricsAvailable"), c("AbleToScale", "True", reason)], 3, 1))
.toEqual({ label: "Scaling", state: "ok", detail: "3 replicas, steady" });
}
});
it("lets a failure win over a hold", () => {
expect(step([c("ScalingActive", "False", "DecisionUnavailable"), c("AbleToScale", "True", "ScaleDownStabilized")], 3, 1))
.toEqual({ label: "Scaling", state: "bad", detail: "DecisionUnavailable" });
});
it("reads a healthy 0.5.0 object with DecisionAvailable=True", () => {
expect(step([c("DecisionAvailable", "True", "DecisionReceived")], 2, 2))
.toEqual({ label: "Scaling", state: "ok", detail: "2 replicas, steady" });
});
it("reads a Prometheus object with no conditions from its replicas", () => {
expect(step([], 1, 3)).toEqual({ label: "Scaling", state: "wait", detail: "1 → 3 replicas" });
expect(step([], 3, 3)).toEqual({ label: "Scaling", state: "ok", detail: "3 replicas, steady" });
});
});
24 changes: 24 additions & 0 deletions web/src/modules/swiss/lib/scaler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Condition } from "@swiss/lib/api";

const of = (conditions: Condition[] | undefined, type: string) => conditions?.find((c) => c.type === type);

// Only AbleToScale=False or ScalingActive=False (DecisionAvailable on operator 0.5.0) is a scaler failure.
export function scalerFailing(conditions?: Condition[]): Condition | undefined {
return [of(conditions, "AbleToScale"), of(conditions, "ScalingActive") ?? of(conditions, "DecisionAvailable")].find(
(c) => c?.status === "False",
);
}

// AbleToScale=True reasons that hold a step. A hold reports as steady; only its AbleToScale
// condition is marked. Synced and DeferralTimedOut are a normal scale.
const HOLD_REASONS = new Set(["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]);

export function scalerHold(conditions?: Condition[]): string | undefined {
const able = of(conditions, "AbleToScale");
return able?.status === "True" && able.reason && HOLD_REASONS.has(able.reason) ? able.reason : undefined;
}

// ScalingLimited is informational; DecisionAvailable only mirrors ScalingActive when that is present.
export function scalerNeutral(conditions?: Condition[]): string[] {
return of(conditions, "ScalingActive") ? ["ScalingLimited", "DecisionAvailable"] : ["ScalingLimited"];
}
8 changes: 5 additions & 3 deletions web/src/modules/swiss/routes/DeploymentDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import { Dialog } from "@swiss/components/ui/dialog";
import { Field, Input } from "@swiss/components/ui/input";
import { CatalogModelDiff } from "@swiss/components/CatalogModelDiff";
import { Provenance } from "@swiss/components/Provenance";
import { ReleaseObjects, pick, scalerFailing, useReleaseObjects } from "@swiss/components/ReleaseObjects";
import { ReleaseObjects, pick, useReleaseObjects } from "@swiss/components/ReleaseObjects";
import { scalerFailing, scalerHold } from "@swiss/lib/scaler";
import { AccessPoint } from "@swiss/components/Endpoint";
import { ReleaseStatus } from "@swiss/components/ReleaseStatus";
import { SLODialog } from "@swiss/components/SLOCard";
Expand Down Expand Up @@ -337,7 +338,7 @@ function routeStep({ result, spec, status }: ReturnType<typeof pick<ModelRouteSp
return status?.ready ? { label, state: "ok", detail: backends + route } : { label, state: "wait", detail: `not ready · ${backends}` };
}

function scalerStep({ result, status }: ReturnType<typeof pick<LLMScalerSpec, LLMScalerStatus>>): Step {
export function scalerStep({ result, status }: ReturnType<typeof pick<LLMScalerSpec, LLMScalerStatus>>): Step {
const label = "Scaling";
if (!result) return { label, state: "off", detail: "fixed replicas" };
if (result.error) return { label, state: "wait", detail: "unreadable" };
Expand All @@ -347,7 +348,8 @@ function scalerStep({ result, status }: ReturnType<typeof pick<LLMScalerSpec, LL
const cur = status?.currentReplicas;
const want = status?.desiredReplicas;
if (cur === undefined) return { label, state: "wait", detail: "no decision yet" };
if (want !== undefined && want !== cur) return { label, state: "wait", detail: `${cur} → ${want} replicas` };
if (want !== undefined && want !== cur && !scalerHold(status?.conditions))
return { label, state: "wait", detail: `${cur} → ${want} replicas` };
return { label, state: "ok", detail: `${cur} replica${cur === 1 ? "" : "s"}, steady` };
}

Expand Down
Loading