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
8 changes: 4 additions & 4 deletions cli/internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ func Validate(m Manifest) error {
if m.Service.StartCommand != nil && *m.Service.StartCommand == "" {
return errors.New("service.startCommand cannot be blank")
}
if m.Service.Replicas < 1 || m.Service.Replicas > 10 {
return errors.New("service.replicas must be between 1 and 10")
if m.Service.Replicas < 1 || m.Service.Replicas > 32 {
return errors.New("service.replicas must be between 1 and 32")
}
if m.Service.Placement == nil {
return errors.New("service.placement is required")
Expand Down Expand Up @@ -249,8 +249,8 @@ func Validate(m Manifest) error {
}
total += server.Count
}
if total < 1 || total > 10 {
return errors.New("service.placement manual total must be between 1 and 10")
if total < 1 || total > 32 {
return errors.New("service.placement manual total must be between 1 and 32")
}
if total != m.Service.Replicas {
return errors.New("service.placement manual total must equal service.replicas")
Expand Down
22 changes: 21 additions & 1 deletion cli/internal/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TestPlacementRoundTripAndValidation(t *testing.T) {
{"blank server", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: " ", Count: 1}}}, 1},
{"duplicate server", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 1}, {ServerID: "a", Count: 1}}}, 2},
{"nonpositive count", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 0}}}, 1},
{"total exceeds limit", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 11}}}, 10},
{"total exceeds limit", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 33}}}, 32},
{"total differs from replicas", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 1}}}, 2},
}
for _, tc := range tests {
Expand All @@ -82,6 +82,26 @@ func TestPlacementRoundTripAndValidation(t *testing.T) {
}
}

func TestReplicaLimit(t *testing.T) {
for _, placement := range []*Placement{
{Mode: "automatic"},
{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 32}}},
} {
m := base()
m.Service.Replicas = 32
m.Service.Placement = placement
if err := Validate(m); err != nil {
t.Fatalf("32 replicas rejected: %v", err)
}
}

m := base()
m.Service.Replicas = 33
if err := Validate(m); err == nil {
t.Fatal("33 replicas accepted")
}
}

func TestPlacementIsRequired(t *testing.T) {
_, err := Parse([]byte(`apiVersion: v1
service:
Expand Down
4 changes: 2 additions & 2 deletions docs/services/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: "Replicas, placement, and server pinning."

Each service can run multiple replicas across your cluster. Configure how many replicas run on each server from the service settings.

Replica count ranges from 1 to 10 per service.
Replica count ranges from 1 to 32 per service.

## Serverless scaling

Expand Down Expand Up @@ -59,7 +59,7 @@ You can also manually lock any service to a specific server by setting the locke
- Stateful services are limited to 1 replica.
- Stateful services are always pinned to their locked server.
- Stateful services do not automatically fail over to another server.
- Maximum 10 replicas per service.
- Maximum 32 replicas per service.
- Serverless scaling requires a public HTTP service domain.
- Sleep and wake are proxy-local; serverless replicas must be placed on proxy nodes.
- Serverless traffic must be routed only to proxy nodes that own a local proxy replica for that service.
Expand Down
8 changes: 4 additions & 4 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,7 @@ export type ServiceConfigUpdate = {
const placementInputSchema = z.discriminatedUnion("mode", [
z.strictObject({
mode: z.literal("automatic"),
replicas: z.number().int().min(1).max(10),
replicas: z.number().int().min(1).max(32),
}),
z
.strictObject({
Expand All @@ -1076,7 +1076,7 @@ const placementInputSchema = z.discriminatedUnion("mode", [
.array(
z.strictObject({
serverId: z.string().min(1),
count: z.number().int().min(1).max(10),
count: z.number().int().min(1).max(32),
}),
)
.min(1),
Expand All @@ -1092,10 +1092,10 @@ const placementInputSchema = z.discriminatedUnion("mode", [
path: ["placements"],
});
const total = value.placements.reduce((sum, item) => sum + item.count, 0);
if (total > 10)
if (total > 32)
context.addIssue({
code: "custom",
message: "Total replicas must be between 1 and 10",
message: "Total replicas must be between 1 and 32",
path: ["placements"],
});
}),
Expand Down
76 changes: 36 additions & 40 deletions web/components/service/details/replicas-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
Expand Down Expand Up @@ -198,7 +199,7 @@ export const ReplicasSection = memo(function ReplicasSection({
setIsEditing(true);
setLocalReplicas((prev) => ({
...prev,
[serverId]: Math.max(0, Math.min(10, Math.floor(value))),
[serverId]: Math.max(0, Math.min(32, Math.floor(value))),
}));
}, []);

Expand All @@ -211,7 +212,7 @@ export const ReplicasSection = memo(function ReplicasSection({
(sum, count) => sum + count,
0,
);
setDesiredReplicas(Math.max(1, Math.min(10, manualTotal || 1)));
setDesiredReplicas(Math.max(1, Math.min(32, manualTotal || 1)));
}
setPlacementMode(nextMode);
};
Expand Down Expand Up @@ -268,7 +269,7 @@ export const ReplicasSection = memo(function ReplicasSection({
: null;
};

const manualTotalIsValid = totalReplicas >= 1 && totalReplicas <= 10;
const manualTotalIsValid = totalReplicas >= 1 && totalReplicas <= 32;

if (service.stateful) {
return (
Expand Down Expand Up @@ -401,41 +402,36 @@ export const ReplicasSection = memo(function ReplicasSection({

{placementMode === "automatic" ? (
<div className="space-y-4">
<div className="space-y-1">
<label htmlFor="desired-replicas" className="text-sm font-medium">
Desired replicas
</label>
<p className="text-sm text-muted-foreground">
The control plane distributes replicas evenly across healthy
{service.serverlessEnabled ? " proxy nodes" : " nodes"} and
moves them after failures.
</p>
<div className="flex max-w-xl items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">Desired replicas</p>
<p className="text-sm text-muted-foreground">
The control plane distributes replicas evenly across healthy
{service.serverlessEnabled ? " proxy nodes" : " nodes"} and
moves them after failures.
</p>
</div>
<span className="shrink-0 text-sm font-medium tabular-nums">
{desiredReplicas}
</span>
</div>
<div className="max-w-xl space-y-2 py-1">
<Slider
aria-label="Desired replicas"
min={1}
max={32}
step={1}
value={desiredReplicas}
onValueChange={(value) => {
setIsEditing(true);
setDesiredReplicas(value);
}}
/>
<div className="flex justify-between text-center text-xs text-muted-foreground">
<span className="w-4 shrink-0">1</span>
<span className="w-4 shrink-0">32</span>
</div>
</div>
<Input
id="desired-replicas"
type="number"
min={1}
max={10}
step={1}
value={desiredReplicas}
onChange={(event) => {
setIsEditing(true);
setDesiredReplicas(
Math.max(
1,
Math.min(10, Math.floor(event.target.valueAsNumber || 1)),
),
);
}}
className="w-24"
aria-describedby="automatic-replica-range"
/>
<p
id="automatic-replica-range"
className="text-xs text-muted-foreground"
>
Choose between 1 and 10 replicas.
</p>
{hasChanges ? (
<div className="pt-3 border-t">
<Button onClick={handleSave} disabled={isSaving} size="sm">
Expand Down Expand Up @@ -509,7 +505,7 @@ export const ReplicasSection = memo(function ReplicasSection({
}
disabled={!!manualServerUnavailableReason(server)}
min={0}
max={10}
max={32}
className="w-16 h-8 text-center"
/>
<Button
Expand All @@ -523,7 +519,7 @@ export const ReplicasSection = memo(function ReplicasSection({
)
}
disabled={
(localReplicas[server.id] || 0) >= 10 ||
(localReplicas[server.id] || 0) >= 32 ||
!!manualServerUnavailableReason(server)
}
>
Expand All @@ -541,7 +537,7 @@ export const ReplicasSection = memo(function ReplicasSection({
</div>
{!manualTotalIsValid && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Manual placement requires 1 to 10 replicas in total.
Manual placement requires 1 to 32 replicas in total.
</p>
)}
{hasChanges && (
Expand Down
60 changes: 60 additions & 0 deletions web/components/ui/slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider";

import { cn } from "@/lib/utils";

type SliderProps<Value extends number | readonly number[]> =
SliderPrimitive.Root.Props<Value> & {
getThumbAriaLabel?: (index: number) => string;
};

function Slider<Value extends number | readonly number[]>({
className,
defaultValue,
value,
min = 0,
max = 100,
"aria-label": ariaLabel,
getThumbAriaLabel,
...props
}: SliderProps<Value>) {
const effectiveValue = value ?? defaultValue;
const thumbCount = Array.isArray(effectiveValue) ? effectiveValue.length : 1;

return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: thumbCount }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
aria-label={getThumbAriaLabel ? undefined : ariaLabel}
getAriaLabel={getThumbAriaLabel}
index={index}
// biome-ignore lint/suspicious/noArrayIndexKey: Slider thumbs are identified by their value index.
key={index}
className="relative block size-4 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}

export { Slider };
2 changes: 1 addition & 1 deletion web/lib/compose-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export function parseComposeYaml(yamlContent: string): ComposeParseResult {

let replicas = serviceConfig.deploy?.replicas ?? 1;
if (replicas < 1) replicas = 1;
if (replicas > 10) replicas = 10;
if (replicas > 32) replicas = 32;
if (stateful && replicas > 1) {
warnings.push({
service: serviceName,
Expand Down
8 changes: 4 additions & 4 deletions web/lib/inngest/functions/rollout-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export function distributeReplicas(
): Placement[] {
const ids = [...new Set(serverIds)].sort((a, b) => a.localeCompare(b));
if (ids.length === 0) throw new Error("No eligible servers for deployment");
if (!Number.isInteger(replicas) || replicas < 1 || replicas > 10)
throw new Error("Replica count must be between 1 and 10");
if (!Number.isInteger(replicas) || replicas < 1 || replicas > 32)
throw new Error("Replica count must be between 1 and 32");
const counts = new Map(ids.map((id) => [id, 0]));
for (let index = 0; index < replicas; index++) {
const id = ids[index % ids.length];
Expand Down Expand Up @@ -114,8 +114,8 @@ export function calculateRevisionPlacements(
if (totalReplicas < 1) {
throw new Error("At least one replica is required");
}
if (totalReplicas > 10) {
throw new Error("Maximum 10 replicas allowed");
if (totalReplicas > 32) {
throw new Error("Maximum 32 replicas allowed");
}

if (specification.stateful) {
Expand Down
2 changes: 1 addition & 1 deletion web/lib/inngest/functions/rollout-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { handleRolloutFailure } from "./rollout-utils";

const PREFLIGHT_FAILURE_MESSAGES = [
"At least one replica is required",
"Maximum 10 replicas allowed",
"Maximum 32 replicas allowed",
"No servers selected for deployment",
"Stateful services can only have exactly 1 replica",
"Stateful services must be deployed to exactly one server",
Expand Down
8 changes: 4 additions & 4 deletions web/lib/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ const hostnameSchema = z
export const placementSchema = z.discriminatedUnion("mode", [
z.strictObject({
mode: z.literal("automatic"),
replicas: z.number().int().min(1).max(10),
replicas: z.number().int().min(1).max(32),
}),
z
.strictObject({
Expand All @@ -563,7 +563,7 @@ export const placementSchema = z.discriminatedUnion("mode", [
.array(
z.strictObject({
serverId: z.string().min(1),
count: z.number().int().min(1).max(10),
count: z.number().int().min(1).max(32),
}),
)
.min(1),
Expand All @@ -578,10 +578,10 @@ export const placementSchema = z.discriminatedUnion("mode", [
message: "Server IDs must be unique",
path: ["placements"],
});
if (value.placements.reduce((sum, item) => sum + item.count, 0) > 10)
if (value.placements.reduce((sum, item) => sum + item.count, 0) > 32)
context.addIssue({
code: "custom",
message: "Total replicas must be between 1 and 10",
message: "Total replicas must be between 1 and 32",
path: ["placements"],
});
}),
Expand Down
2 changes: 1 addition & 1 deletion web/lib/service-revision-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const serviceRevisionSpecSchema = z
z.strictObject({ mode: z.literal("manual") }),
z.strictObject({
mode: z.literal("automatic"),
replicas: z.number().int().min(1).max(10),
replicas: z.number().int().min(1).max(32),
}),
]),
...serviceRevisionSpecFields,
Expand Down
4 changes: 2 additions & 2 deletions web/lib/service-revision-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ function validateServiceRevisionSpec(
if (totalReplicas < 1 && !allowNoPlacements) {
throw new Error("At least one replica is required");
}
if (totalReplicas > 10) {
throw new Error("Maximum 10 replicas allowed");
if (totalReplicas > 32) {
throw new Error("Maximum 32 replicas allowed");
}
if (
specification.placement.mode === "automatic" &&
Expand Down
Loading
Loading