Skip to content

Commit 84d7532

Browse files
authored
Fix concurrent Cloudflare entity handler builds (#7346)
1 parent f9d9e65 commit 84d7532

3 files changed

Lines changed: 106 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/platform-cloudflare": patch
3+
---
4+
5+
Share an in-flight entity handler build between concurrent first requests.

packages/platform/cloudflare/src/internal/entityRuntime.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*(
6464
replyRegistry?: EntityReplyRegistry
6565
) {
6666
let cached: CachedHandlers | undefined
67+
let building: Deferred.Deferred<CachedHandlers> | undefined
6768
const metricContext = Context.merge(
6869
registration.context,
6970
Metric.CurrentMetricAttributes.context({ type: registration.entity.type })
@@ -84,8 +85,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*(
8485
)
8586
})
8687

87-
const getHandlers = Effect.fnUntraced(function*() {
88-
if (cached !== undefined) return cached
88+
const buildHandlers = Effect.fnUntraced(function*() {
8989
const scope = yield* Scope.make()
9090
let context = registration.context.pipe(
9191
Context.add(CurrentAddress, address),
@@ -99,11 +99,27 @@ export const makeEntityRuntime = Effect.fnUntraced(function*(
9999
if (replyRegistry !== undefined) {
100100
context = Context.add(context, CurrentReplyRegistry, replyRegistry)
101101
}
102-
const handlers = yield* Effect.provideContext(registration.build, context)
102+
const handlers = yield* Effect.provideContext(registration.build, context).pipe(
103+
Effect.tapCause((cause) => Scope.close(scope, Exit.failCause(cause)))
104+
)
103105
ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext)
104-
return cached = { handlers, context, scope }
106+
return { handlers, context, scope }
105107
})
106108

109+
const getHandlers = (): Effect.Effect<CachedHandlers> =>
110+
Effect.suspend(() => {
111+
if (cached !== undefined) return Effect.succeed(cached)
112+
if (building !== undefined) return Deferred.await(building)
113+
const deferred = Deferred.makeUnsafe<CachedHandlers>()
114+
building = deferred
115+
return Effect.onExit(buildHandlers(), (exit) =>
116+
Effect.sync(() => {
117+
building = undefined
118+
if (Exit.isSuccess(exit)) cached = exit.value
119+
Deferred.doneUnsafe(deferred, exit)
120+
}))
121+
})
122+
107123
const runWithDefectRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
108124
const policy = registration.options?.defectRetryPolicy
109125
if (policy === undefined) return Effect.exit(effect)

packages/platform/cloudflare/test/EntityRuntime.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import type { EntityRegistration } from "@effect/platform-cloudflare/internal/entityRegistry"
22
import { makeEntityRuntime } from "@effect/platform-cloudflare/internal/entityRuntime"
33
import { assert, describe, it } from "@effect/vitest"
4-
import { Cause, Context, Effect, Exit, Metric, Option, Schedule, Schema, Stream, Tracer } from "effect"
4+
import {
5+
Cause,
6+
Context,
7+
Deferred,
8+
Effect,
9+
Exit,
10+
Fiber,
11+
Metric,
12+
Option,
13+
Schedule,
14+
Schema,
15+
Scope,
16+
Stream,
17+
Tracer
18+
} from "effect"
519
import { ClusterMetrics, Entity, EntityAddress, EntityId, EntityType, ShardId } from "effect/unstable/cluster"
620
import { Rpc, RpcSchema } from "effect/unstable/rpc"
721

@@ -131,6 +145,72 @@ describe("EntityRuntime", () => {
131145
assert.deepStrictEqual(replies.map((reply) => reply.exit.value), ["pong", "pong"])
132146
}))
133147

148+
it.effect("shares an asynchronous handler build between concurrent first requests", () =>
149+
Effect.gen(function*() {
150+
const Concurrent = Entity.make("Concurrent", [
151+
Rpc.make("Ping", { success: Schema.String })
152+
])
153+
const concurrentAddress = new EntityAddress.EntityAddress({
154+
shardId: ShardId.make("default", 1),
155+
entityType: EntityType.make("Concurrent"),
156+
entityId: EntityId.make("42")
157+
})
158+
const context = Context.empty()
159+
const metricContext = Context.merge(
160+
context,
161+
Metric.CurrentMetricAttributes.context({ type: Concurrent.type })
162+
)
163+
const releaseBuild = Deferred.makeUnsafe<void>()
164+
let builds = 0
165+
let finalizers = 0
166+
const registration: EntityRegistration = {
167+
entity: Concurrent,
168+
build: Effect.gen(function*() {
169+
const scope = Option.getOrThrow(yield* Effect.serviceOption(Scope.Scope))
170+
builds++
171+
yield* Scope.addFinalizer(
172+
scope,
173+
Effect.sync(() => {
174+
finalizers++
175+
})
176+
)
177+
yield* Deferred.await(releaseBuild)
178+
return Concurrent.of({ Ping: () => Effect.succeed("pong") })
179+
}),
180+
options: { concurrency: "unbounded" },
181+
context
182+
}
183+
const runtime = yield* makeEntityRuntime(registration, concurrentAddress, () => "reply")
184+
const first = yield* Effect.forkChild(
185+
runtime.run({ ...request, address: concurrentAddress } as any, Option.none(), false, () => Effect.void)
186+
)
187+
const second = yield* Effect.forkChild(
188+
runtime.run(
189+
{
190+
...request,
191+
requestId: "0198bd72-6a83-72f1-8d87-5e9b5cf1e003",
192+
address: concurrentAddress
193+
} as any,
194+
Option.none(),
195+
false,
196+
() => Effect.void
197+
)
198+
)
199+
200+
yield* Effect.yieldNow
201+
yield* Deferred.succeed(releaseBuild, undefined)
202+
yield* Fiber.join(first)
203+
yield* Fiber.join(second)
204+
205+
assert.strictEqual(builds, 1)
206+
assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(1))
207+
assert.strictEqual(finalizers, 0)
208+
209+
yield* runtime.invalidate()
210+
assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(0))
211+
assert.strictEqual(finalizers, 1)
212+
}))
213+
134214
it.effect("resumes ask stream sequence from lastSentChunk and ends with WithExit", () =>
135215
Effect.gen(function*() {
136216
const Streaming = Entity.make("User", [

0 commit comments

Comments
 (0)