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
249 changes: 134 additions & 115 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"catalog": {
"@alchemy.run/node-utils": "0.0.5",
"@aws-sdk/credential-providers": "^3.0.0",
"@cloudflare/containers": "^0.1.1",
"@cloudflare/containers": "^0.3.7",
"@cloudflare/vite-plugin": "^1.13.12",
"@cloudflare/workers-types": "^4.20250805.0",
"@distilled.cloud/aws": "workspace:*",
Expand Down Expand Up @@ -106,6 +106,7 @@
"vitest": "^4.1.8",
"uuid": "^14.0.0",
"web-tree-sitter": "0.25.10",
"wrangler": "^4.103.0",
"ws": "^8.20.0",
"yaml": "^2.0.0",
"@aws-crypto/crc32": "^5.2.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/alchemy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@
},
"devDependencies": {
"@clack/prompts": "^0.11.0",
"@cloudflare/containers": "catalog:",
"@cloudflare/puppeteer": "^1.1.0",
"@cloudflare/workers-types": "catalog:",
"@effect/platform-bun": "catalog:",
Expand Down Expand Up @@ -378,6 +379,7 @@
"typescript": "catalog:",
"uuid": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:",
"ws": "catalog:"
},
"peerDependencies": {
Expand Down
39 changes: 39 additions & 0 deletions packages/alchemy/src/Cloudflare/Containers/Container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,45 @@ export class ContainerError extends Data.TaggedError("ContainerError")<{
readonly cause?: unknown;
}> {}

/**
* No container instance could be allocated within the start budget — the
* account is at its concurrent-instance cap (`maxInstances`) or the platform
* is still provisioning. Mirrors `@cloudflare/containers`'
* `NO_CONTAINER_INSTANCE_ERROR` (surfaced as HTTP 503 by native).
*/
export class NoContainerInstanceError extends Data.TaggedError(
"NoContainerInstanceError",
)<{
readonly message: string;
readonly cause?: unknown;
}> {}

/**
* Cloudflare is rate limiting container starts ("too many containers per
* second"). Mirrors `@cloudflare/containers`' `RATE_LIMITED_ERROR` (HTTP 429).
* Hammering `start()` while rate limited only prolongs it, so callers should
* back off rather than retry tightly.
*/
export class ContainerRateLimitedError extends Data.TaggedError(
"ContainerRateLimitedError",
)<{
readonly message: string;
readonly cause?: unknown;
}> {}

/**
* The container instance exited/crashed while we were waiting for its port —
* the entrypoint failed to bind or died. Mirrors native's "container exited"
* detection (`!this.container.running` mid-wait); not curable by continuing to
* poll the same instance.
*/
export class ContainerCrashedError extends Data.TaggedError(
"ContainerCrashedError",
)<{
readonly message: string;
readonly cause?: unknown;
}> {}

export interface ContainerStartupOptions extends cf.ContainerStartupOptions {}

/**
Expand Down
18 changes: 12 additions & 6 deletions packages/alchemy/src/Cloudflare/Containers/ContainerApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,17 @@ export interface ContainerApplicationProps extends PlatformProps {
*/
dockerfile?: string;
/**
* Initial number of instances to maintain.
* @default 1
* Initial number of instances to maintain. Matches wrangler, which forces
* this to 0 whenever {@link maxInstances} is set (pure scale-from-zero).
* @default 0
*/
instances?: number;
/**
* Maximum number of instances the application may scale to.
* @default 1
* Maximum number of instances the application may scale to. Matches
* wrangler's default of 20. A value of 1 serializes every Durable Object
* instance through a single container slot, so the default lets containers
* scale concurrently out of the box.
* @default 20
*/
maxInstances?: number;
/**
Expand All @@ -164,8 +168,10 @@ export interface ContainerApplicationProps extends PlatformProps {
*/
schedulingPolicy?: ContainerApplication.SchedulingPolicy;
/**
* Instance type for each deployment.
* @default "dev"
* Instance type for each deployment. Defaults to wrangler's `"lite"` tier
* (1/16 vCPU, 256 MiB, 2 GB disk) when no explicit {@link vcpu}/{@link memory}/
* {@link disk} is set. (`"dev"` is wrangler's deprecated alias for `"lite"`.)
* @default "lite"
*/
instanceType?: ContainerApplication.InstanceType;
/**
Expand Down
49 changes: 36 additions & 13 deletions packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,17 @@ export const LiveContainerProvider = () =>
) =>
normalizeNulls({
image: imageRef,
instanceType: props.instanceType,
// Default to wrangler's instance type ("lite") so containers schedule
// the same way out of the box. `instance_type` is mutually exclusive
// with explicit vcpu/memory/disk, so only default it when none are
// set. ("dev" is wrangler's deprecated alias for "lite".)
instanceType:
props.instanceType ??
(props.vcpu === undefined &&
props.memory === undefined &&
props.disk === undefined
? "lite"
: undefined),
observability: props.observability,
sshPublicKeyIds: props.sshPublicKeyIds,
secrets: props.secrets,
Expand All @@ -130,6 +140,28 @@ export const LiveContainerProvider = () =>
checks: props.checks,
}) as ContainerApplication.Configuration;

// Scaling/placement defaults mirror wrangler's container defaults
// (`wrangler-dist/cli.js`) so an Alchemy container behaves like a
// `wrangler deploy`d one without extra config:
// - max_instances: 20 (`container.max_instances ?? 20`)
// - instances: 0 (wrangler forces 0 whenever
// max_instances is set, which we always
// do — pure scale-from-zero)
// - scheduling_policy: "default"
// (wrangler also defaults `constraints.tiers` to `[1, 2]`, but the
// distilled SDK models constraints as singular `tier`, not the `tiers`
// array, so we leave constraints untouched — it's a minor placement hint
// next to the scaling defaults.)
// A maxInstances default of 1 (the previous value) silently serialised
// every Durable Object instance through a single container slot, which is
// the dominant cause of "containers are slow under load".
const scalingDefaults = (props: ContainerApplicationProps) => ({
instances: props.instances ?? 0,
maxInstances: props.maxInstances ?? 20,
schedulingPolicy: props.schedulingPolicy ?? "default",
constraints: props.constraints ?? {},
});

const computeImage = Effect.fn(function* (
id: string,
props: ContainerApplicationProps,
Expand Down Expand Up @@ -420,10 +452,7 @@ export const LiveContainerProvider = () =>
const application = yield* Containers.createContainerApplication({
accountId,
name,
instances: news.instances ?? 1,
maxInstances: news.maxInstances ?? 1,
schedulingPolicy: news.schedulingPolicy ?? "default",
constraints: news.constraints ?? {},
...scalingDefaults(news),
affinities: news.affinities,
configuration,
durableObjects,
Expand Down Expand Up @@ -518,10 +547,7 @@ export const LiveContainerProvider = () =>
Containers.updateContainerApplication({
accountId,
applicationId: existing.applicationId,
instances: news.instances ?? 1,
maxInstances: news.maxInstances ?? 1,
schedulingPolicy: news.schedulingPolicy ?? "default",
constraints: news.constraints ?? {},
...scalingDefaults(news),
affinities: news.affinities,
configuration,
}),
Expand All @@ -544,10 +570,7 @@ export const LiveContainerProvider = () =>
return yield* Containers.createContainerApplication({
accountId,
name: existing.applicationName,
instances: news.instances ?? 1,
maxInstances: news.maxInstances ?? 1,
schedulingPolicy: news.schedulingPolicy ?? "default",
constraints: news.constraints ?? {},
...scalingDefaults(news),
affinities: news.affinities,
configuration,
durableObjects,
Expand Down
Loading
Loading