diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go index eb680d2a..876a6238 100644 --- a/cli/internal/manifest/manifest.go +++ b/cli/internal/manifest/manifest.go @@ -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") @@ -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") diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go index 57cdcbb7..b7c5103a 100644 --- a/cli/internal/manifest/manifest_test.go +++ b/cli/internal/manifest/manifest_test.go @@ -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 { @@ -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: diff --git a/docs/services/scaling.mdx b/docs/services/scaling.mdx index 13ab4163..1ed8c62b 100644 --- a/docs/services/scaling.mdx +++ b/docs/services/scaling.mdx @@ -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 @@ -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. diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 27d3f370..204f73df 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -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({ @@ -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), @@ -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"], }); }), diff --git a/web/components/service/details/replicas-section.tsx b/web/components/service/details/replicas-section.tsx index 80cec112..be1632c8 100644 --- a/web/components/service/details/replicas-section.tsx +++ b/web/components/service/details/replicas-section.tsx @@ -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 { @@ -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))), })); }, []); @@ -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); }; @@ -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 ( @@ -401,41 +402,36 @@ export const ReplicasSection = memo(function ReplicasSection({ {placementMode === "automatic" ? (
-
- -

- The control plane distributes replicas evenly across healthy - {service.serverlessEnabled ? " proxy nodes" : " nodes"} and - moves them after failures. -

+
+
+

Desired replicas

+

+ The control plane distributes replicas evenly across healthy + {service.serverlessEnabled ? " proxy nodes" : " nodes"} and + moves them after failures. +

+
+ + {desiredReplicas} + +
+
+ { + setIsEditing(true); + setDesiredReplicas(value); + }} + /> +
+ 1 + 32 +
- { - setIsEditing(true); - setDesiredReplicas( - Math.max( - 1, - Math.min(10, Math.floor(event.target.valueAsNumber || 1)), - ), - ); - }} - className="w-24" - aria-describedby="automatic-replica-range" - /> -

- Choose between 1 and 10 replicas. -

{hasChanges ? (
{!manualTotalIsValid && (

- Manual placement requires 1 to 10 replicas in total. + Manual placement requires 1 to 32 replicas in total.

)} {hasChanges && ( diff --git a/web/components/ui/slider.tsx b/web/components/ui/slider.tsx new file mode 100644 index 00000000..ead1fcec --- /dev/null +++ b/web/components/ui/slider.tsx @@ -0,0 +1,60 @@ +import { Slider as SliderPrimitive } from "@base-ui/react/slider"; + +import { cn } from "@/lib/utils"; + +type SliderProps = + SliderPrimitive.Root.Props & { + getThumbAriaLabel?: (index: number) => string; + }; + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + "aria-label": ariaLabel, + getThumbAriaLabel, + ...props +}: SliderProps) { + const effectiveValue = value ?? defaultValue; + const thumbCount = Array.isArray(effectiveValue) ? effectiveValue.length : 1; + + return ( + + + + + + {Array.from({ length: thumbCount }, (_, index) => ( + + ))} + + + ); +} + +export { Slider }; diff --git a/web/lib/compose-parser.ts b/web/lib/compose-parser.ts index 5adc7345..2662baa2 100644 --- a/web/lib/compose-parser.ts +++ b/web/lib/compose-parser.ts @@ -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, diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts index 79d30979..9b05ac58 100644 --- a/web/lib/inngest/functions/rollout-helpers.ts +++ b/web/lib/inngest/functions/rollout-helpers.ts @@ -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]; @@ -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) { diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 7316bed3..3b723990 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -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", diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts index 6bc13d87..75fbfae6 100644 --- a/web/lib/public-api.ts +++ b/web/lib/public-api.ts @@ -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({ @@ -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), @@ -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"], }); }), diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts index cdb0448c..cdfa0b31 100644 --- a/web/lib/service-revision-changes.ts +++ b/web/lib/service-revision-changes.ts @@ -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, diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts index 0bad7661..315784b1 100644 --- a/web/lib/service-revision-spec.ts +++ b/web/lib/service-revision-spec.ts @@ -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" && diff --git a/web/tests/autoplacement.test.ts b/web/tests/autoplacement.test.ts index eae08e90..8c46c676 100644 --- a/web/tests/autoplacement.test.ts +++ b/web/tests/autoplacement.test.ts @@ -41,12 +41,18 @@ describe("automatic placement distribution", () => { ).toEqual(counts); }); - it("stacks ten replicas deterministically on two servers", () => { - expect(distributeReplicas(["b", "a"], 10)).toEqual([ - { serverId: "a", replicas: 5 }, - { serverId: "b", replicas: 5 }, + it("stacks 32 replicas deterministically on two servers", () => { + expect(distributeReplicas(["b", "a"], 32)).toEqual([ + { serverId: "a", replicas: 16 }, + { serverId: "b", replicas: 16 }, ]); }); + + it("rejects more than 32 replicas", () => { + expect(() => distributeReplicas(["a"], 33)).toThrow( + "Replica count must be between 1 and 32", + ); + }); }); describe("automatic placement eligibility diagnostics", () => { diff --git a/web/tests/public-api-source.test.ts b/web/tests/public-api-source.test.ts index 7cfcb4c7..22f97aea 100644 --- a/web/tests/public-api-source.test.ts +++ b/web/tests/public-api-source.test.ts @@ -154,7 +154,9 @@ describe("public API placement schema", () => { }); it.each([ + { mode: "automatic", replicas: 32 }, { mode: "automatic", replicas: 3 }, + { mode: "manual", placements: [{ serverId: "server-1", count: 32 }] }, { mode: "manual", placements: [{ serverId: "server-1", count: 2 }] }, ])("accepts valid placement intent", (placement) => { expect( @@ -165,12 +167,13 @@ describe("public API placement schema", () => { it.each([ { mode: "automatic", replicas: 0 }, + { mode: "automatic", replicas: 33 }, { mode: "manual", placements: [] }, { mode: "manual", placements: [ - { serverId: "a", count: 6 }, - { serverId: "b", count: 5 }, + { serverId: "a", count: 17 }, + { serverId: "b", count: 16 }, ], }, { diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts index 6bb02475..b8e1755c 100644 --- a/web/tests/service-revision-spec.test.ts +++ b/web/tests/service-revision-spec.test.ts @@ -250,14 +250,24 @@ describe("service revision specification", () => { it("snapshots automatic placement intent without resolved placements", () => { const input = draft({ volumes: [] }); input.service.placementMode = "automatic"; - input.service.replicas = 4; + input.service.replicas = 32; expect(buildServiceRevisionSpec(input)).toMatchObject({ - placement: { mode: "automatic", replicas: 4 }, + placement: { mode: "automatic", replicas: 32 }, placements: [], }); }); + it("rejects more than 32 automatic replicas", () => { + const input = draft({ volumes: [] }); + input.service.placementMode = "automatic"; + input.service.replicas = 33; + + expect(() => buildServiceRevisionSpec(input)).toThrow( + "Maximum 32 replicas allowed", + ); + }); + it("rejects automatic placement for stateful and volume-backed services", () => { const stateful = draft({ volumes: [] }); stateful.service.stateful = true;