Skip to content

ClusterWorkflowEngine: full and partial workflow entities share an entity typemake(executionId).run is not a function #2520

Description

@Federaffo

What version of Effect is running?

effect@4.0.0-beta.57 (also on main)

What steps can reproduce the bug?

Problem

ClusterWorkflowEngine builds two entities per workflow with the same type Workflow/${name}: the full one has the run RPC, the partial one doesn't. Entity Equal/Hash are by type only, so they collide in Sharding's client ResourceMap — whichever client is built first wins for idleTimeToLive (5 min).

If the partial client is built first (any DurableDeferred.succeed/resume/activity), a later Workflow.execute(...) reuses it and throws, because the partial client's proxy has no run:

TypeError: make(executionId).run is not a function

Reproduction

import { Effect, Layer, Schema } from "effect"
import { ClusterWorkflowEngine, TestRunner } from "effect/unstable/cluster"
import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"
import { DurableDeferred, Workflow } from "effect/unstable/workflow"

const wf = Workflow.make({
  name: "demo/run",
  payload: Schema.Struct({ runId: Schema.String }),
  success: Schema.Struct({ runId: Schema.String }),
  error: Schema.Never,
  idempotencyKey: ({ runId }) => runId,
})
const engineLayer = ClusterWorkflowEngine.layer.pipe(Layer.provide(TestRunner.layer))

const scenario = (deferredFirst: boolean) =>
  Effect.gen(function* () {
    const engine = yield* WorkflowEngine.WorkflowEngine
    if (deferredFirst) {
      const d = DurableDeferred.make("d", { success: Schema.Struct({ status: Schema.String }) })
      yield* Effect.gen(function* () {
        const token = yield* DurableDeferred.tokenFromPayload(d, { workflow: wf, payload: { runId: "r1" } })
        yield* DurableDeferred.succeed(d, { token, value: { status: "ok" } })
      }).pipe(Effect.provideService(WorkflowEngine.WorkflowEngine, engine), Effect.ignore)
    }
    return yield* wf.execute({ runId: "r2" }, { discard: true }).pipe(
      Effect.provideService(WorkflowEngine.WorkflowEngine, engine),
      Effect.matchCause({ onSuccess: () => "OK", onFailure: (c) => "FAIL: " + String(c).split("\n")[0] }),
    )
  }).pipe(Effect.provide(engineLayer), Effect.scoped)

Effect.runPromise(Effect.gen(function* () {
  console.log("execute first       ->", yield* scenario(false))
  console.log("deferred then exec  ->", yield* scenario(true))
}) as any)

Output:

execute first       -> OK
deferred then exec  -> FAIL: Die(TypeError: make(executionId).run is not a function ...)

The only difference is call order.

What is the expected behavior?

Workflow.execute should succeed regardless of whether a partial-entity op ran first — the full and partial entities should not share a client-cache slot.

What do you see instead?

make(executionId).run is not a function

Additional information

Possible cause (packages/cluster/src)

// Entity.ts — equality/hash by type only
[Hash.symbol]() { return Hash.structure({ type: this.type }) }
[Equal.symbol](that) { return isEntity(that) && this.type === that.type }

// ClusterWorkflowEngine.ts — both entities share `Workflow/${name}`
makeWorkflowEntity        -> Entity.make(`Workflow/${workflow.name}`, [run, deferred, resume, activity])
makePartialWorkflowEntity -> Entity.make(`Workflow/${workflowName}`, [deferred, resume, activity]) // no run

Sharding.makeClient reads clients: ResourceMap keyed by entity equality, so get(fullEntity) returns the cached partial client (no run).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions