Skip to content

Container app deploys without DO namespace linkage due to circular Output dependency in bindContainer #72

Description

@yovanoc

Summary

When binding a Cloudflare.Container to a Cloudflare.DurableObjectNamespace via Cloudflare.Container.bind inside a DO class body, the container application is created in Cloudflare's control plane without its Durable Object namespace linkage. Runtime consequence:

Error: There is no container application assigned to this Durable Object namespace

The container is endlessly restarted by StartContainer.ts's exponential retry loop, silently masking the real error.

Environment

  • alchemy@2.0.0-beta.9 (reproduces on fork yovanoc/alchemy-effect-fork@2282ff3, same code on upstream main)
  • effect@4.0.0-beta.48
  • @cloudflare/workers-types latest
  • Bun runtime, macOS darwin
  • Pattern: canonical Container Layer — class + .make() in separate files, DO imports class

Reproduction

Canonical pattern from the Container docstring:

// src/containers/device.ts
export class DeviceContainer extends Cloudflare.Container<DeviceContainer>()("DeviceContainerApp", {
  main: "./src/containers/device/server/server.ts",
  runtime: "bun",
  instanceType: "dev",
}) {}

// src/containers/device-do.ts
export default class DeviceContainerDO extends Cloudflare.DurableObjectNamespace<DeviceContainerDO>()(
  "DeviceContainer",
  Effect.gen(function* () {
    const container = yield* Cloudflare.Container.bind(DeviceContainer);
    return Effect.gen(function* () {
      const started = yield* Cloudflare.start(container);
      const port = yield* started.getTcpPort(3000);
      return {
        fetch: Effect.gen(function* () {
          const request = yield* HttpServerRequest;
          return yield* port.fetch(request);
        }),
      };
    });
  }),
) {}

Deploy succeeds cleanly. Any invocation of the DO that reaches Cloudflare.start(container) triggers the runtime error above.

Evidence

Alchemy state file .alchemy/state/<app>/<stage>/DeviceContainerApp.json:

{
  "attr": {
    "applicationId": "a03a9b73-d707-42bb-b301-1f61457d943a",
    "durableObjects": null          // ← should contain { namespaceId: "..." }
  },
  "bindings": [
    {
      "sid": "DeviceContainer",
      "data": {
        "durableObjects": {}         // ← should contain { namespaceId: "<resolved>" }
      }
    }
  ]
}

Worker tail logs repeat every ~2 min:

INFO (#41): Container not running, starting...
INFO (#41): Container started, launching monitor
INFO (#53): Container monitor exited
Error There is no container application assigned to this Durable Object namespace

Root Cause Analysis

A circular Output dependency between Container and DurableObjectNamespace:

  1. DurableObjectNamespace.ts:585-596self.namespaceId is an Output<string> that resolves via worker.durableObjectNamespaces.pipe(Output.map(ns => ns?.[name])). It resolves only after the Worker has deployed (Worker is what populates durableObjectNamespaces).

  2. ContainerBinding.ts:32-36bindContainer calls:

    yield* container.bind`${namespace}`({
      durableObjects: {
        namespaceId: namespace.namespaceId,   // Output<string>, unresolved
      },
    });
  3. Apply.ts:451 — When the ContainerApplication is about to create, Output.evaluate(node.bindings, outputs) runs. But the Container is an upstream dependency of the Worker (Worker binds to Container). So at Container-create time, the Worker hasn't deployed and worker.durableObjectNamespaces[name] is still undefined. Output.evaluate resolves namespaceId to undefined.

  4. ContainerApplication.ts:871-888getDurableObjects(bindings):

    const dos = bindings.flatMap((b) =>
      b.data.durableObjects ? [b.data.durableObjects] : [],
    );
    if (dos.length === 0) return Effect.succeed(undefined);
    if (dos.length === 1) return Effect.succeed(dos[0]);

    b.data.durableObjects = { namespaceId: undefined } is truthy, so the function returns { namespaceId: undefined } instead of undefined.

  5. ContainerApplication.ts:1038createApplication is called with durableObjects: { namespaceId: undefined }, which serializes the namespaceId field away. Cloudflare's API receives a malformed/empty DO attachment and creates the app without linkage.

  6. ContainerApplication.ts:985-988 — The recreate-on-DO-change guard:

    if (output && !adoptPolicy && !deepEqual(output.durableObjects, durableObjects)) { ... }

    After the first deploy, output.durableObjects === null and durableObjects === { namespaceId: undefined }. Subsequent deploys hit this check: both sides deep-equal to "nothing meaningful" under JSON round-tripping, so the guard never fires and the broken state persists across all future alchemy deploy runs (including --force).

Intent vs Reality

The precreate/create split at ContainerApplication.ts:933-966 includes this comment:

Precreate intentionally omits the Durable Object attachment so the worker can bind to this application id and break the circular dependency. The final create step recreates the application with the resolved namespace when needed.

The intent is correct: precreate without DO linkage → Worker deploys → Worker populates durableObjectNamespacescreate re-runs with resolved namespaceId → recreate the app with linkage.

But there is no ordering mechanism that re-evaluates the Container's bindings after Worker deploy. Apply.ts evaluates bindings once at create time against currently-resolved outputs. Because Container is upstream of Worker in the dependency graph, the Worker's durableObjectNamespaces is never available when Container's bindings are evaluated.

Suggested Fix Directions

Not submitting a patch because the fix likely requires a framework-level decision, but options:

  1. Make DurableObjectNamespace declare the container linkage directly. Instead of having bindContainer push { namespaceId: <unresolvable Output> } into the container's bindings, have DurableObjectNamespace itself (which knows its own namespaceId resolution path) push the linkage. This inverts the dependency so the Output producer (Worker/DONamespace) is downstream of its consumer (Container's DO attachment).

  2. Two-phase container create: after Worker deploys and DO namespace IDs are known, re-invoke the Container provider's update path with the now-resolved bindings to patch in the DO linkage. Requires Apply.ts changes to support a "post-worker" re-evaluation pass.

  3. Fail loudly at create time: if getDurableObjects(bindings) returns a shape with namespaceId === undefined, Effect.die with a clear message instead of silently serializing a broken payload. This at least surfaces the bug during alchemy deploy instead of only at runtime.

Happy to contribute a patch once the preferred approach is agreed upon.

Workaround

None found. Manual CF API attachment of DO namespace to container application drifts on next deploy (alchemy state overwrites). Downgrading to Worker-only architecture (no containers) avoids the path entirely.

Related Files

  • packages/alchemy/src/Cloudflare/Container/ContainerBinding.ts (lines 32-36)
  • packages/alchemy/src/Cloudflare/Container/ContainerApplication.ts (lines 871-888, 933-1045)
  • packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts (lines 585-596)
  • packages/alchemy/src/Apply.ts (line 451)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions