@effect/platform-cloudflare 0.0.0-stage → 4.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/AGENTS.md +434 -0
  2. package/CLAUDE.md +434 -0
  3. package/LICENSE +21 -0
  4. package/README.md +505 -2
  5. package/ai-docs/README.md +44 -0
  6. package/ai-docs/package.json +36 -0
  7. package/ai-docs/src/01_effect/01_basics/01_effect-gen.ts +30 -0
  8. package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +52 -0
  9. package/ai-docs/src/01_effect/01_basics/10_creating-effects.ts +74 -0
  10. package/ai-docs/src/01_effect/01_basics/index.md +7 -0
  11. package/ai-docs/src/01_effect/02_schema/10_schema-basics.ts +43 -0
  12. package/ai-docs/src/01_effect/02_schema/index.md +7 -0
  13. package/ai-docs/src/01_effect/03_services/01_service.ts +45 -0
  14. package/ai-docs/src/01_effect/03_services/10_reference.ts +10 -0
  15. package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +70 -0
  16. package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +66 -0
  17. package/ai-docs/src/01_effect/03_services/index.md +5 -0
  18. package/ai-docs/src/01_effect/04_errors/01_error-handling.ts +30 -0
  19. package/ai-docs/src/01_effect/04_errors/10_catch-tags.ts +24 -0
  20. package/ai-docs/src/01_effect/04_errors/20_reason-errors.ts +64 -0
  21. package/ai-docs/src/01_effect/04_errors/index.md +1 -0
  22. package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +105 -0
  23. package/ai-docs/src/01_effect/05_resources/20_layer-side-effects.ts +31 -0
  24. package/ai-docs/src/01_effect/05_resources/30_layer-map.ts +86 -0
  25. package/ai-docs/src/01_effect/05_resources/index.md +3 -0
  26. package/ai-docs/src/01_effect/06_running/10_run-main.ts +30 -0
  27. package/ai-docs/src/01_effect/06_running/20_layer-launch.ts +27 -0
  28. package/ai-docs/src/01_effect/06_running/index.md +1 -0
  29. package/ai-docs/src/01_effect/07_pubsub/10_pubsub.ts +56 -0
  30. package/ai-docs/src/01_effect/07_pubsub/index.md +3 -0
  31. package/ai-docs/src/03_stream/10_creating-streams.ts +103 -0
  32. package/ai-docs/src/03_stream/20_consuming-streams.ts +137 -0
  33. package/ai-docs/src/03_stream/30_encoding.ts +163 -0
  34. package/ai-docs/src/03_stream/index.md +4 -0
  35. package/ai-docs/src/04_integration/10_managed-runtime.ts +129 -0
  36. package/ai-docs/src/04_integration/index.md +5 -0
  37. package/ai-docs/src/05_batching/10_request-resolver.ts +89 -0
  38. package/ai-docs/src/05_batching/index.md +3 -0
  39. package/ai-docs/src/06_schedule/10_schedules.ts +111 -0
  40. package/ai-docs/src/06_schedule/index.md +3 -0
  41. package/ai-docs/src/07_datetime/10_creating-and-formatting.ts +30 -0
  42. package/ai-docs/src/07_datetime/20_time-zones.ts +44 -0
  43. package/ai-docs/src/07_datetime/index.md +5 -0
  44. package/ai-docs/src/08_observability/10_logging.ts +66 -0
  45. package/ai-docs/src/08_observability/20_otlp-tracing.ts +95 -0
  46. package/ai-docs/src/08_observability/index.md +7 -0
  47. package/ai-docs/src/09_testing/10_effect-tests.ts +55 -0
  48. package/ai-docs/src/09_testing/20_layer-tests.ts +138 -0
  49. package/ai-docs/src/09_testing/index.md +1 -0
  50. package/ai-docs/src/10_predicate/01_basics.ts +14 -0
  51. package/ai-docs/src/10_predicate/index.md +9 -0
  52. package/ai-docs/src/40_sql/10_basics.ts +175 -0
  53. package/ai-docs/src/40_sql/index.md +6 -0
  54. package/ai-docs/src/50_http-client/10_basics.ts +102 -0
  55. package/ai-docs/src/50_http-client/index.md +3 -0
  56. package/ai-docs/src/51_http-server/10_basics.ts +118 -0
  57. package/ai-docs/src/51_http-server/20_testing.ts +100 -0
  58. package/ai-docs/src/51_http-server/fixtures/api/Api.ts +14 -0
  59. package/ai-docs/src/51_http-server/fixtures/api/Authorization.ts +36 -0
  60. package/ai-docs/src/51_http-server/fixtures/api/System.ts +10 -0
  61. package/ai-docs/src/51_http-server/fixtures/api/Users.ts +107 -0
  62. package/ai-docs/src/51_http-server/fixtures/domain/User.ts +21 -0
  63. package/ai-docs/src/51_http-server/fixtures/domain/UserErrors.ts +22 -0
  64. package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +37 -0
  65. package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +80 -0
  66. package/ai-docs/src/51_http-server/fixtures/server/Users.ts +183 -0
  67. package/ai-docs/src/51_http-server/index.md +3 -0
  68. package/ai-docs/src/52_graphql/10_basics.ts +117 -0
  69. package/ai-docs/src/52_graphql/20_testing.ts +45 -0
  70. package/ai-docs/src/52_graphql/30_batching.ts +118 -0
  71. package/ai-docs/src/52_graphql/40_aliased-batching.ts +124 -0
  72. package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
  73. package/ai-docs/src/52_graphql/index.md +6 -0
  74. package/ai-docs/src/60_child-process/10_working-with-child-processes.ts +117 -0
  75. package/ai-docs/src/60_child-process/index.md +3 -0
  76. package/ai-docs/src/70_cli/10_basics.ts +162 -0
  77. package/ai-docs/src/70_cli/index.md +5 -0
  78. package/ai-docs/src/71_ai/10_language-model.ts +156 -0
  79. package/ai-docs/src/71_ai/20_tools.ts +226 -0
  80. package/ai-docs/src/71_ai/30_chat.ts +158 -0
  81. package/ai-docs/src/71_ai/fixtures/domain/LaunchPlan.ts +9 -0
  82. package/ai-docs/src/71_ai/index.md +5 -0
  83. package/ai-docs/src/80_cluster/10_entities.ts +97 -0
  84. package/ai-docs/src/80_cluster/index.md +4 -0
  85. package/ai-docs/src/index.md +10 -0
  86. package/ai-docs/tsconfig.json +28 -0
  87. package/dist/AlchemyCloudflareCluster.d.ts +134 -0
  88. package/dist/AlchemyCloudflareCluster.d.ts.map +1 -0
  89. package/dist/AlchemyCloudflareCluster.js +111 -0
  90. package/dist/AlchemyCloudflareCluster.js.map +1 -0
  91. package/dist/CloudflareCluster.d.ts +196 -0
  92. package/dist/CloudflareCluster.d.ts.map +1 -0
  93. package/dist/CloudflareCluster.js +533 -0
  94. package/dist/CloudflareCluster.js.map +1 -0
  95. package/dist/CloudflareDurableObjectPrograms.d.ts +279 -0
  96. package/dist/CloudflareDurableObjectPrograms.d.ts.map +1 -0
  97. package/dist/CloudflareDurableObjectPrograms.js +295 -0
  98. package/dist/CloudflareDurableObjectPrograms.js.map +1 -0
  99. package/dist/CloudflareDurableObjects.d.ts +149 -0
  100. package/dist/CloudflareDurableObjects.d.ts.map +1 -0
  101. package/dist/CloudflareDurableObjects.js +337 -0
  102. package/dist/CloudflareDurableObjects.js.map +1 -0
  103. package/dist/CloudflarePersistedQueue.d.ts +36 -0
  104. package/dist/CloudflarePersistedQueue.d.ts.map +1 -0
  105. package/dist/CloudflarePersistedQueue.js +122 -0
  106. package/dist/CloudflarePersistedQueue.js.map +1 -0
  107. package/dist/CloudflareWorkflowEngine.d.ts +72 -0
  108. package/dist/CloudflareWorkflowEngine.d.ts.map +1 -0
  109. package/dist/CloudflareWorkflowEngine.js +187 -0
  110. package/dist/CloudflareWorkflowEngine.js.map +1 -0
  111. package/dist/index.d.ts +29 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +33 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/internal/alchemyCluster.d.ts +2 -0
  116. package/dist/internal/alchemyCluster.d.ts.map +1 -0
  117. package/dist/internal/alchemyCluster.js +75 -0
  118. package/dist/internal/alchemyCluster.js.map +1 -0
  119. package/dist/internal/boundedMap.d.ts +3 -0
  120. package/dist/internal/boundedMap.d.ts.map +1 -0
  121. package/dist/internal/boundedMap.js +15 -0
  122. package/dist/internal/boundedMap.js.map +1 -0
  123. package/dist/internal/cluster.d.ts +57 -0
  124. package/dist/internal/cluster.d.ts.map +1 -0
  125. package/dist/internal/cluster.js +2 -0
  126. package/dist/internal/cluster.js.map +1 -0
  127. package/dist/internal/clusterName.d.ts +2 -0
  128. package/dist/internal/clusterName.d.ts.map +1 -0
  129. package/dist/internal/clusterName.js +16 -0
  130. package/dist/internal/clusterName.js.map +1 -0
  131. package/dist/internal/entityKeepAlive.d.ts +2 -0
  132. package/dist/internal/entityKeepAlive.d.ts.map +1 -0
  133. package/dist/internal/entityKeepAlive.js +73 -0
  134. package/dist/internal/entityKeepAlive.js.map +1 -0
  135. package/dist/internal/entityMailbox.d.ts +2 -0
  136. package/dist/internal/entityMailbox.d.ts.map +1 -0
  137. package/dist/internal/entityMailbox.js +160 -0
  138. package/dist/internal/entityMailbox.js.map +1 -0
  139. package/dist/internal/entityRegistry.d.ts +16 -0
  140. package/dist/internal/entityRegistry.d.ts.map +1 -0
  141. package/dist/internal/entityRegistry.js +22 -0
  142. package/dist/internal/entityRegistry.js.map +1 -0
  143. package/dist/internal/entityReply.d.ts +2 -0
  144. package/dist/internal/entityReply.d.ts.map +1 -0
  145. package/dist/internal/entityReply.js +35 -0
  146. package/dist/internal/entityReply.js.map +1 -0
  147. package/dist/internal/entityRuntime.d.ts +2 -0
  148. package/dist/internal/entityRuntime.d.ts.map +1 -0
  149. package/dist/internal/entityRuntime.js +823 -0
  150. package/dist/internal/entityRuntime.js.map +1 -0
  151. package/dist/internal/entitySql.d.ts +2 -0
  152. package/dist/internal/entitySql.d.ts.map +1 -0
  153. package/dist/internal/entitySql.js +29 -0
  154. package/dist/internal/entitySql.js.map +1 -0
  155. package/dist/internal/entityStorage.d.ts +2 -0
  156. package/dist/internal/entityStorage.d.ts.map +1 -0
  157. package/dist/internal/entityStorage.js +98 -0
  158. package/dist/internal/entityStorage.js.map +1 -0
  159. package/dist/internal/entityWire.d.ts +2 -0
  160. package/dist/internal/entityWire.d.ts.map +1 -0
  161. package/dist/internal/entityWire.js +146 -0
  162. package/dist/internal/entityWire.js.map +1 -0
  163. package/dist/internal/queueRuntime.d.ts +2 -0
  164. package/dist/internal/queueRuntime.d.ts.map +1 -0
  165. package/dist/internal/queueRuntime.js +88 -0
  166. package/dist/internal/queueRuntime.js.map +1 -0
  167. package/dist/internal/queueStorage.d.ts +2 -0
  168. package/dist/internal/queueStorage.d.ts.map +1 -0
  169. package/dist/internal/queueStorage.js +129 -0
  170. package/dist/internal/queueStorage.js.map +1 -0
  171. package/dist/internal/registry.d.ts +10 -0
  172. package/dist/internal/registry.d.ts.map +1 -0
  173. package/dist/internal/registry.js +24 -0
  174. package/dist/internal/registry.js.map +1 -0
  175. package/dist/internal/singletonRegistry.d.ts +2 -0
  176. package/dist/internal/singletonRegistry.d.ts.map +1 -0
  177. package/dist/internal/singletonRegistry.js +9 -0
  178. package/dist/internal/singletonRegistry.js.map +1 -0
  179. package/dist/internal/singletonRuntime.d.ts +2 -0
  180. package/dist/internal/singletonRuntime.d.ts.map +1 -0
  181. package/dist/internal/singletonRuntime.js +42 -0
  182. package/dist/internal/singletonRuntime.js.map +1 -0
  183. package/dist/internal/singletonStorage.d.ts +2 -0
  184. package/dist/internal/singletonStorage.d.ts.map +1 -0
  185. package/dist/internal/singletonStorage.js +34 -0
  186. package/dist/internal/singletonStorage.js.map +1 -0
  187. package/dist/internal/transport.d.ts +2 -0
  188. package/dist/internal/transport.d.ts.map +1 -0
  189. package/dist/internal/transport.js +49 -0
  190. package/dist/internal/transport.js.map +1 -0
  191. package/dist/internal/workflowRegistry.d.ts +2 -0
  192. package/dist/internal/workflowRegistry.d.ts.map +1 -0
  193. package/dist/internal/workflowRegistry.js +29 -0
  194. package/dist/internal/workflowRegistry.js.map +1 -0
  195. package/dist/internal/workflowRuntime.d.ts +2 -0
  196. package/dist/internal/workflowRuntime.d.ts.map +1 -0
  197. package/dist/internal/workflowRuntime.js +322 -0
  198. package/dist/internal/workflowRuntime.js.map +1 -0
  199. package/dist/internal/workflowStorage.d.ts +2 -0
  200. package/dist/internal/workflowStorage.d.ts.map +1 -0
  201. package/dist/internal/workflowStorage.js +113 -0
  202. package/dist/internal/workflowStorage.js.map +1 -0
  203. package/dist/internal/workflowWire.d.ts +2 -0
  204. package/dist/internal/workflowWire.d.ts.map +1 -0
  205. package/dist/internal/workflowWire.js +52 -0
  206. package/dist/internal/workflowWire.js.map +1 -0
  207. package/package.json +74 -3
  208. package/src/AlchemyCloudflareCluster.ts +200 -0
  209. package/src/CloudflareCluster.ts +704 -0
  210. package/src/CloudflareDurableObjectPrograms.ts +543 -0
  211. package/src/CloudflareDurableObjects.ts +430 -0
  212. package/src/CloudflarePersistedQueue.ts +189 -0
  213. package/src/CloudflareWorkflowEngine.ts +220 -0
  214. package/src/index.ts +38 -0
  215. package/src/internal/alchemyCluster.ts +120 -0
  216. package/src/internal/boundedMap.ts +15 -0
  217. package/src/internal/cluster.ts +57 -0
  218. package/src/internal/clusterName.ts +19 -0
  219. package/src/internal/entityKeepAlive.ts +121 -0
  220. package/src/internal/entityMailbox.ts +324 -0
  221. package/src/internal/entityRegistry.ts +46 -0
  222. package/src/internal/entityReply.ts +54 -0
  223. package/src/internal/entityRuntime.ts +1318 -0
  224. package/src/internal/entitySql.ts +60 -0
  225. package/src/internal/entityStorage.ts +168 -0
  226. package/src/internal/entityWire.ts +204 -0
  227. package/src/internal/queueRuntime.ts +174 -0
  228. package/src/internal/queueStorage.ts +255 -0
  229. package/src/internal/registry.ts +31 -0
  230. package/src/internal/singletonRegistry.ts +21 -0
  231. package/src/internal/singletonRuntime.ts +88 -0
  232. package/src/internal/singletonStorage.ts +64 -0
  233. package/src/internal/transport.ts +67 -0
  234. package/src/internal/workflowRegistry.ts +78 -0
  235. package/src/internal/workflowRuntime.ts +443 -0
  236. package/src/internal/workflowStorage.ts +234 -0
  237. package/src/internal/workflowWire.ts +123 -0
@@ -0,0 +1,1318 @@
1
+ /** @internal */
2
+ import type { DurableObjectStorage } from "@cloudflare/workers-types"
3
+ import * as Cause from "effect/Cause"
4
+ import * as ClusterMetrics from "effect/cluster/ClusterMetrics"
5
+ import { isUninterruptibleForServer, Persisted } from "effect/cluster/ClusterSchema"
6
+ import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/cluster/Entity"
7
+ import type * as EntityAddress from "effect/cluster/EntityAddress"
8
+ import * as Envelope from "effect/cluster/Envelope"
9
+ import * as Reply from "effect/cluster/Reply"
10
+ import * as RunnerAddress from "effect/cluster/RunnerAddress"
11
+ import * as Context from "effect/Context"
12
+ import * as Deferred from "effect/Deferred"
13
+ import * as Effect from "effect/Effect"
14
+ import * as Exit from "effect/Exit"
15
+ import * as Fiber from "effect/Fiber"
16
+ import { identity } from "effect/Function"
17
+ import * as Metric from "effect/Metric"
18
+ import * as Option from "effect/Option"
19
+ import * as Pull from "effect/Pull"
20
+ import * as Queue from "effect/Queue"
21
+ import * as Result from "effect/Result"
22
+ import * as Rpc from "effect/rpc/Rpc"
23
+ import * as RpcMessage from "effect/rpc/RpcMessage"
24
+ import * as RpcSchema from "effect/rpc/RpcSchema"
25
+ import * as RpcServer from "effect/rpc/RpcServer"
26
+ import type * as Schedule from "effect/Schedule"
27
+ import * as Scope from "effect/Scope"
28
+ import * as Semaphore from "effect/Semaphore"
29
+ import type { EntityDeliveryOptions, EntityInvokeResult } from "../CloudflareDurableObjectPrograms.ts"
30
+ import { encodeName } from "./clusterName.ts"
31
+ import type { EntityKeepAlive } from "./entityKeepAlive.ts"
32
+ import {
33
+ ackChunk,
34
+ clearReplies,
35
+ completeTell,
36
+ loadMessage,
37
+ loadNextReply,
38
+ loadUnprocessed,
39
+ persistRequest,
40
+ type PersistResult,
41
+ type ReplyTarget,
42
+ saveReply,
43
+ type StoredMessage
44
+ } from "./entityMailbox.ts"
45
+ import { type EntityRegistration, getEntityRegistration, keepAliveHeartbeatMillis } from "./entityRegistry.ts"
46
+ import { CurrentEntityName, CurrentReplyRegistry, type EntityReplyRegistry, makeReplyRegistry } from "./entityReply.ts"
47
+ import { type EntitySql, makeEntitySql } from "./entitySql.ts"
48
+ import {
49
+ armAlarm,
50
+ earliestDeliverAt,
51
+ loadEntityState,
52
+ nextAlarmAt,
53
+ saveKeepAlive,
54
+ withTransaction
55
+ } from "./entityStorage.ts"
56
+ import { decodeReplyFor, decodeRequest, encodeReplyFor, peekEnvelopeTag } from "./entityWire.ts"
57
+
58
+ interface CachedHandlers {
59
+ readonly handlers: Record<string, (request: any) => any>
60
+ readonly context: Context.Context<never>
61
+ readonly scope: Scope.Closeable
62
+ readonly server: ServerHandle
63
+ }
64
+
65
+ // A build's `RpcServer`. It outlives a defect rebuild so requests already
66
+ // running on it finish there; once retired, it closes when the last request
67
+ // holding a lease on it ends.
68
+ interface ServerHandle {
69
+ readonly server: RpcServer.RpcServer<any>
70
+ readonly scope: Scope.Closeable
71
+ leases: number
72
+ retired: boolean
73
+ closed: boolean
74
+ }
75
+
76
+ // One request written to the wake's `RpcServer`. A defect retry writes a new
77
+ // server request, so ids are never reused while a previous fiber unwinds.
78
+ interface ServerRequest {
79
+ readonly envelope: Envelope.Request.Any
80
+ readonly discard: boolean
81
+ readonly respond: (reply: Reply.Reply<any>) => Effect.Effect<void>
82
+ readonly progress: {
83
+ lastSentChunk: Option.Option<Reply.Chunk<any>>
84
+ sequence: number
85
+ }
86
+ readonly exit: Deferred.Deferred<Exit.Exit<unknown, unknown>>
87
+ // Completes once the server's request effect, including its request scope,
88
+ // has finished.
89
+ readonly finished: Deferred.Deferred<void>
90
+ started: boolean
91
+ interrupted: boolean
92
+ fork: boolean
93
+ }
94
+
95
+ /** @internal */
96
+ export const makeEntityRuntime = Effect.fnUntraced(function*(
97
+ registration: EntityRegistration,
98
+ address: EntityAddress.EntityAddress,
99
+ nextId: () => string,
100
+ entityName = encodeName(address.entityType, address.entityId),
101
+ keepAlive?: EntityKeepAlive,
102
+ replyRegistry?: EntityReplyRegistry,
103
+ entitySql?: EntitySql
104
+ ) {
105
+ let cached: CachedHandlers | undefined
106
+ let building: Deferred.Deferred<CachedHandlers> | undefined
107
+ const metricContext = Context.merge(
108
+ registration.context,
109
+ Metric.CurrentMetricAttributes.context({ type: registration.entity.type })
110
+ )
111
+ // Bounds concurrent handler executions from the entity's `concurrency`
112
+ // build option; `"unbounded"` leaves handlers unlimited.
113
+ const concurrency = registration.options?.concurrency ?? 1
114
+ const handlerSemaphore = concurrency === "unbounded" ? undefined : Semaphore.makeUnsafe(concurrency)
115
+
116
+ const serverRequests = new Map<number, ServerRequest>()
117
+ let nextServerRequestId = 0
118
+ // Set while `server.write` runs; a request the server rejects before it
119
+ // forks a handler is answered with a `Defect` that carries no request id.
120
+ let writing: ServerRequest | undefined
121
+
122
+ const onFromServer = (response: RpcMessage.FromServer<any>): Effect.Effect<void> => {
123
+ switch (response._tag) {
124
+ case "Exit": {
125
+ const request = serverRequests.get(Number(response.requestId))
126
+ if (request !== undefined) Deferred.doneUnsafe(request.exit, Effect.succeed(response.exit))
127
+ return Effect.void
128
+ }
129
+ case "Chunk": {
130
+ const request = serverRequests.get(Number(response.requestId))
131
+ if (request === undefined || request.discard || request.interrupted) return Effect.void
132
+ const progress = request.progress
133
+ const reply = new Reply.Chunk({
134
+ requestId: request.envelope.requestId,
135
+ id: nextId() as any,
136
+ sequence: progress.sequence++,
137
+ values: response.values as any
138
+ })
139
+ return request.respond(reply).pipe(
140
+ Effect.tap(() =>
141
+ Effect.sync(() => {
142
+ progress.lastSentChunk = Option.some(reply)
143
+ })
144
+ ),
145
+ // The server ends the request without an exit after a failed chunk
146
+ // write, so the failure becomes the request's exit here.
147
+ Effect.onError((cause) =>
148
+ Effect.sync(() => {
149
+ if (!Cause.hasInterruptsOnly(cause)) {
150
+ Deferred.doneUnsafe(request.exit, Effect.succeed(Exit.failCause(cause)))
151
+ }
152
+ })
153
+ )
154
+ )
155
+ }
156
+ case "Defect": {
157
+ if (writing !== undefined) Deferred.doneUnsafe(writing.exit, Effect.succeed(Exit.die(response.defect)))
158
+ return Effect.void
159
+ }
160
+ case "ClientEnd":
161
+ return Effect.void
162
+ }
163
+ }
164
+
165
+ // Closes a retired server once nothing holds a lease on it. `closed` is set
166
+ // synchronously, so a lease taken afterwards moves to the current build.
167
+ const closeIfDrained = (handle: ServerHandle): Effect.Effect<void> =>
168
+ Effect.suspend(() => {
169
+ if (!handle.retired || handle.leases > 0 || handle.closed) return Effect.void
170
+ handle.closed = true
171
+ return Scope.close(handle.scope, Exit.void)
172
+ })
173
+
174
+ const invalidate = Effect.fnUntraced(function*() {
175
+ if (cached === undefined) return
176
+ const scope = cached.scope
177
+ const server = cached.server
178
+ cached = undefined
179
+ server.retired = true
180
+ yield* closeIfDrained(server)
181
+ // The closing build's holders are gone with it. `EntityResource` skips
182
+ // `keepAlive(false)` while its scope shuts down, so keeping the count
183
+ // would leak a holder into the rebuild.
184
+ keepAlive?.resetHolders()
185
+ yield* Scope.close(scope, Exit.void).pipe(
186
+ Effect.ensuring(Effect.sync(() => {
187
+ ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext)
188
+ }))
189
+ )
190
+ })
191
+
192
+ const buildHandlers = Effect.fnUntraced(function*() {
193
+ const scope = yield* Scope.make()
194
+ let context = registration.context.pipe(
195
+ Context.add(CurrentAddress, address),
196
+ Context.add(CurrentRunnerAddress, RunnerAddress.make(`${address.entityType}/${address.entityId}`, 0)),
197
+ Context.add(CurrentEntityName, entityName),
198
+ Context.add(Scope.Scope, scope)
199
+ )
200
+ if (keepAlive !== undefined) {
201
+ context = Context.add(context, KeepAliveHandler, keepAlive.update)
202
+ }
203
+ if (replyRegistry !== undefined) {
204
+ context = Context.add(context, CurrentReplyRegistry, replyRegistry)
205
+ }
206
+ const { handlers, server } = yield* Effect.gen(function*() {
207
+ if (entitySql !== undefined) {
208
+ context = Context.merge(context, yield* Scope.provide(entitySql.make, scope))
209
+ }
210
+ const handlers = yield* Effect.provideContext(registration.build, context)
211
+ // The server sees each handler's result before it takes a permit; a
212
+ // forked result is marked here so its request skips the entity permit.
213
+ const serverHandlers: Record<string, (payload: any, options: { readonly requestId: any }) => any> = {}
214
+ for (const tag of Object.keys(handlers)) {
215
+ const handler = handlers[tag]
216
+ serverHandlers[tag] = (request, { requestId }) => {
217
+ const result = handler(request)
218
+ if (Rpc.isWrapper(result as object) && (result as Rpc.Wrapper<unknown>).fork) {
219
+ const pending = serverRequests.get(Number(requestId))
220
+ if (pending !== undefined) pending.fork = true
221
+ }
222
+ return result
223
+ }
224
+ }
225
+ const handlersContext: Context.Context<never> = yield* Effect.provideContext(
226
+ registration.entity.protocol.toHandlers(serverHandlers),
227
+ context as Context.Context<any>
228
+ )
229
+ const serverScope = yield* Scope.make()
230
+ // Middleware services come from the registration context: `Entity.toLayer`
231
+ // requires them, and `registerEntity` captures that context.
232
+ const server = yield* (RpcServer.makeNoSerialization(registration.entity.protocol, {
233
+ onFromServer,
234
+ concurrency: "unbounded",
235
+ disableTracing: true,
236
+ disableClientAcks: true,
237
+ disableFatalDefects: true
238
+ }) as Effect.Effect<RpcServer.RpcServer<any>, never, Scope.Scope>).pipe(
239
+ Effect.provideContext(Context.add(Context.merge(context, handlersContext), Scope.Scope, serverScope))
240
+ )
241
+ const handle: ServerHandle = { server, scope: serverScope, leases: 0, retired: false, closed: false }
242
+ return { handlers, server: handle }
243
+ }).pipe(
244
+ Effect.tapCause((cause) => Scope.close(scope, Exit.failCause(cause)))
245
+ )
246
+ ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext)
247
+ // Any event that builds the entity restores a hold persisted before a
248
+ // restart.
249
+ keepAlive?.restore()
250
+ return { handlers, context, scope, server } satisfies CachedHandlers
251
+ })
252
+
253
+ const getHandlers = (): Effect.Effect<CachedHandlers> =>
254
+ Effect.suspend(() => {
255
+ if (cached !== undefined) return Effect.succeed(cached)
256
+ if (building !== undefined) return Deferred.await(building)
257
+ const deferred = Deferred.makeUnsafe<CachedHandlers>()
258
+ building = deferred
259
+ return Effect.onExit(buildHandlers(), (exit) =>
260
+ Effect.sync(() => {
261
+ building = undefined
262
+ if (Exit.isSuccess(exit)) cached = exit.value
263
+ Deferred.doneUnsafe(deferred, exit)
264
+ }))
265
+ })
266
+
267
+ const runWithDefectRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
268
+ const policy = registration.options?.defectRetryPolicy
269
+ if (policy === undefined) return Effect.exit(effect)
270
+ const retryable = Effect.flatMap(Effect.exit(effect), (exit) =>
271
+ Exit.isFailure(exit) && Cause.hasDies(exit.cause)
272
+ ? Effect.fail(exit.cause)
273
+ : Effect.succeed(exit))
274
+ return Effect.retryOrElse(
275
+ retryable,
276
+ policy as Schedule.Schedule<unknown, Cause.Cause<E>>,
277
+ (cause) => Effect.succeed(Exit.failCause(cause))
278
+ )
279
+ }
280
+
281
+ // Takes a lease on the current build's server for one request, including
282
+ // its defect retries. Only the build itself stays interruptible. A server
283
+ // that closed after `getHandlers` returned it is no longer cached, so the
284
+ // lookup runs again.
285
+ const leaseHandlers = (
286
+ restore: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
287
+ ): Effect.Effect<CachedHandlers> =>
288
+ Effect.flatMap(restore(getHandlers()), (entry) =>
289
+ Effect.suspend(() => {
290
+ if (entry.server.closed) return leaseHandlers(restore)
291
+ entry.server.leases++
292
+ return Effect.succeed(entry)
293
+ }))
294
+
295
+ const releaseLease = (entry: CachedHandlers): Effect.Effect<void> =>
296
+ Effect.suspend(() => {
297
+ entry.server.leases--
298
+ return closeIfDrained(entry.server)
299
+ })
300
+
301
+ const rebuildAfterDefect = invalidate().pipe(
302
+ Effect.andThen(Effect.catchCause(getHandlers(), () => Effect.void))
303
+ )
304
+
305
+ // Writes one attempt to the server and waits for its exit and for the
306
+ // request scope to close. Interrupting the caller interrupts the server
307
+ // request and still waits, so an `Rpc.uninterruptible` handler finishes
308
+ // before the caller's interruption completes.
309
+ const runOnServer = (
310
+ server: RpcServer.RpcServer<any>,
311
+ request: ServerRequest,
312
+ permit: HandlerPermit
313
+ ): Effect.Effect<unknown, unknown> => {
314
+ const id = nextServerRequestId++
315
+ const settled = Effect.flatMap(
316
+ Deferred.await(request.exit),
317
+ (exit) => request.started ? Effect.as(Deferred.await(request.finished), exit) : Effect.succeed(exit)
318
+ )
319
+ // `finished` completes on every exit of the request effect, including an
320
+ // interrupt while it waits for the permit or before the handler starts.
321
+ // `started` is set inside the `ensuring`, so `settled` only waits for
322
+ // `finished` once the finalizer is registered.
323
+ const onRequest = <X, E, R>(effect: Effect.Effect<X, E, R>): Effect.Effect<X, E, R> =>
324
+ Effect.ensuring(
325
+ Effect.suspend(() => {
326
+ request.started = true
327
+ if (request.interrupted) return Effect.interrupt
328
+ return request.fork ? effect : permit.acquire(effect)
329
+ }),
330
+ Effect.sync(() => Deferred.doneUnsafe(request.finished, Effect.void))
331
+ )
332
+ const write = Effect.suspend(() => {
333
+ serverRequests.set(id, request)
334
+ writing = request
335
+ return server.write(0, {
336
+ _tag: "Request",
337
+ id: RpcMessage.RequestId(id),
338
+ tag: request.envelope.tag,
339
+ payload: new Request({ ...request.envelope, lastSentChunk: request.progress.lastSentChunk }),
340
+ headers: request.envelope.headers
341
+ }, { onRequest }).pipe(
342
+ Effect.ensuring(Effect.sync(() => {
343
+ writing = undefined
344
+ }))
345
+ )
346
+ })
347
+ // The write and the interrupt hook are installed without an interruptible
348
+ // gap, so an interrupt that arrives just after the write still reaches
349
+ // the server request.
350
+ return Effect.uninterruptibleMask((restore) =>
351
+ Effect.andThen(
352
+ write,
353
+ Effect.onInterrupt(restore(settled), () =>
354
+ Effect.suspend(() => {
355
+ request.interrupted = true
356
+ const stop = request.started
357
+ ? server.write(0, { _tag: "Interrupt", requestId: RpcMessage.RequestId(id), interruptors: [] })
358
+ : Effect.void
359
+ return Effect.andThen(stop, settled)
360
+ }))
361
+ )
362
+ ).pipe(
363
+ Effect.flatten,
364
+ Effect.ensuring(Effect.sync(() => serverRequests.delete(id)))
365
+ )
366
+ }
367
+
368
+ const run = (
369
+ envelope: Envelope.Request.Any,
370
+ lastSentChunk: Option.Option<Reply.Chunk<any>>,
371
+ discard: boolean,
372
+ respond: (reply: Reply.Reply<any>) => Effect.Effect<void>,
373
+ permit: HandlerPermit = makeHandlerPermit(handlerSemaphore)
374
+ ): Effect.Effect<void> =>
375
+ Effect.uninterruptibleMask((restore) =>
376
+ Effect.flatMap(leaseHandlers(restore), (entry) =>
377
+ Effect.ensuring(
378
+ restore(runLeased(entry, envelope, lastSentChunk, discard, respond, permit)),
379
+ releaseLease(entry)
380
+ ))
381
+ )
382
+
383
+ const runLeased = Effect.fnUntraced(function*(
384
+ entry: CachedHandlers,
385
+ envelope: Envelope.Request.Any,
386
+ lastSentChunk: Option.Option<Reply.Chunk<any>>,
387
+ discard: boolean,
388
+ respond: (reply: Reply.Reply<any>) => Effect.Effect<void>,
389
+ permit: HandlerPermit
390
+ ) {
391
+ const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps | undefined
392
+ const handler = entry.handlers[envelope.tag]
393
+ if (rpc === undefined || handler === undefined) {
394
+ const exit = Exit.die(`Unknown entity RPC tag: ${envelope.tag}`)
395
+ if (!discard) {
396
+ yield* respond(
397
+ new Reply.WithExit({
398
+ requestId: envelope.requestId,
399
+ id: nextId() as any,
400
+ exit
401
+ })
402
+ )
403
+ }
404
+ return
405
+ }
406
+
407
+ const streamSchema = RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema : undefined
408
+ const progress = {
409
+ lastSentChunk,
410
+ sequence: Option.match(lastSentChunk, {
411
+ onNone: () => 0,
412
+ onSome: (chunk) => chunk.sequence + 1
413
+ })
414
+ }
415
+
416
+ const execute = Effect.suspend(() =>
417
+ runOnServer(entry.server.server, {
418
+ envelope,
419
+ discard,
420
+ respond,
421
+ progress,
422
+ exit: Deferred.makeUnsafe(),
423
+ finished: Deferred.makeUnsafe(),
424
+ started: false,
425
+ interrupted: false,
426
+ fork: false
427
+ }, permit)
428
+ ).pipe(
429
+ Effect.withSpan("CloudflareCluster.handler", {
430
+ attributes: {
431
+ entityType: registration.entity.type,
432
+ entityId: String(address.entityId),
433
+ rpc: envelope.tag
434
+ }
435
+ }, { captureStackTrace: false })
436
+ )
437
+ const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context)
438
+ if (!discard) {
439
+ yield* respond(
440
+ new Reply.WithExit({
441
+ requestId: envelope.requestId,
442
+ id: nextId() as any,
443
+ exit: streamSchema !== undefined && Exit.isSuccess(exit) ? Exit.void : exit as any
444
+ })
445
+ )
446
+ }
447
+ if (Exit.isFailure(exit) && Cause.hasDies(exit.cause)) {
448
+ yield* rebuildAfterDefect
449
+ }
450
+ })
451
+
452
+ // Builds the handlers without running a request, for wakes that only need
453
+ // the entity alive. Building restores the hold; the explicit `restore`
454
+ // covers an entity that was already built when its hold ended.
455
+ const warm: Effect.Effect<void> = Effect.andThen(getHandlers(), Effect.sync(() => keepAlive?.restore()))
456
+
457
+ return { run, invalidate, warm, handlerSemaphore } as const
458
+ })
459
+
460
+ type EntityRuntime = Effect.Success<ReturnType<typeof makeEntityRuntime>>
461
+
462
+ interface SessionReply {
463
+ readonly text: string
464
+ readonly terminal: boolean
465
+ }
466
+
467
+ interface Session {
468
+ readonly queue: Queue.Queue<SessionReply, Cause.Done>
469
+ readonly completeInterrupt: Effect.Effect<void>
470
+ // `ClusterSchema.Uninterruptible` is `true` or `"server"`: an interrupt is
471
+ // ignored and the handler runs to completion.
472
+ readonly uninterruptible: boolean
473
+ cancelling: boolean
474
+ ack: {
475
+ readonly replyId: string
476
+ readonly deferred: Deferred.Deferred<void>
477
+ } | undefined
478
+ fiber: Fiber.Fiber<void> | undefined
479
+ // Completed when the handler saves its next reply or exits, for duplicates
480
+ // of a persisted stream that arrived before that reply existed.
481
+ saved: Deferred.Deferred<void> | undefined
482
+ }
483
+
484
+ interface WorkerWaiter {
485
+ readonly clientRequestId: string
486
+ readonly deferred: Deferred.Deferred<EntityInvokeResult>
487
+ }
488
+
489
+ interface RunOptions {
490
+ readonly scheduled?: boolean
491
+ readonly replyTos?: ReadonlyArray<ReplyTarget> | undefined
492
+ }
493
+
494
+ /** @internal */
495
+ export interface EntityManagerOptions {
496
+ readonly storage: DurableObjectStorage
497
+ readonly address: EntityAddress.EntityAddress
498
+ readonly entityName: string
499
+ readonly keepAlive: EntityKeepAlive
500
+ readonly waitUntil: (effect: Effect.Effect<unknown>) => void
501
+ readonly getNamespace: () => {
502
+ readonly getByName: (name: string) => {
503
+ readonly deliverReply: (requestId: string, reply: string) => Promise<boolean>
504
+ }
505
+ } | undefined
506
+ }
507
+
508
+ /** @internal */
509
+ export interface EntityManager {
510
+ readonly invoke: (
511
+ envelopeText: string,
512
+ discard: boolean,
513
+ delivery?: EntityDeliveryOptions | undefined
514
+ ) => Effect.Effect<EntityInvokeResult>
515
+ readonly acknowledge: (requestId: string, replyId: string) => Effect.Effect<ReadonlyArray<string>>
516
+ readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Effect.Effect<void>
517
+ readonly reset: (requestId: string) => Effect.Effect<void>
518
+ readonly alarm: Effect.Effect<void>
519
+ readonly deliverReply: (requestId: string, reply: string) => Effect.Effect<boolean>
520
+ /** Writes the keep-alive flag and moves the alarm to match it. */
521
+ readonly saveKeepAlive: (wanted: boolean) => Effect.Effect<void>
522
+ }
523
+
524
+ const success = (requestId: string, replies: ReadonlyArray<string>): EntityInvokeResult => ({
525
+ _tag: "Success",
526
+ requestId,
527
+ replies
528
+ })
529
+
530
+ interface HandlerPermit {
531
+ readonly acquire: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
532
+ readonly pause: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
533
+ }
534
+
535
+ const unlimitedHandlerPermit: HandlerPermit = { acquire: identity, pause: identity }
536
+
537
+ /**
538
+ * One permit per handler execution. `acquire` holds the permit for the whole
539
+ * run; `pause` gives it back while a chunk is parked on its client
540
+ * acknowledgement so an unacked stream cannot starve other handlers. The
541
+ * `held` flag keeps take/release balanced when interruption lands inside a
542
+ * paused section: a pause that never re-acquires leaves `held` false, and
543
+ * `acquire` then skips its release.
544
+ */
545
+ const makeHandlerPermit = (semaphore: Semaphore.Semaphore | undefined): HandlerPermit => {
546
+ if (semaphore === undefined) return unlimitedHandlerPermit
547
+ let held = false
548
+ return {
549
+ acquire: (effect) =>
550
+ Effect.uninterruptibleMask((restore) =>
551
+ restore(Semaphore.take(semaphore, 1)).pipe(
552
+ Effect.flatMap(() => {
553
+ held = true
554
+ return restore(effect)
555
+ }),
556
+ Effect.onExit(() =>
557
+ Effect.suspend(() => {
558
+ if (!held) return Effect.void
559
+ held = false
560
+ return Effect.asVoid(Semaphore.release(semaphore, 1))
561
+ })
562
+ )
563
+ )
564
+ ),
565
+ pause: (effect) =>
566
+ Effect.uninterruptibleMask((restore) =>
567
+ Effect.suspend(() => {
568
+ if (!held) return restore(effect)
569
+ held = false
570
+ return Semaphore.release(semaphore, 1).pipe(
571
+ Effect.flatMap(() => restore(effect)),
572
+ Effect.flatMap((value) =>
573
+ Effect.map(restore(Semaphore.take(semaphore, 1)), () => {
574
+ held = true
575
+ return value
576
+ })
577
+ )
578
+ )
579
+ })
580
+ )
581
+ }
582
+ }
583
+
584
+ /**
585
+ * The Effect-land state machine behind one `ClusterEntity` Durable Object.
586
+ * The class methods are one-line `Effect.runPromise` adapters over the
587
+ * effects returned here; all serialization, sessions, and waiters live in
588
+ * Effect primitives.
589
+ *
590
+ * @internal
591
+ */
592
+ export const makeEntityManager = (options: EntityManagerOptions): EntityManager => {
593
+ const storage = options.storage
594
+ const sql = storage.sql
595
+ // Serializes storage entry: envelope decode, persist-before-run, dedupe,
596
+ // duplicate resume, and alarm arming. Handler execution runs in forked
597
+ // fibers governed by the handler concurrency semaphore below.
598
+ const semaphore = Semaphore.makeUnsafe(1)
599
+ const sessions = new Map<string, Session>()
600
+ // Persisted discard/scheduled requests with an in-flight handler; guards
601
+ // against a second execution from replay or duplicate delivery in the same
602
+ // wake, the way `sessions` does for asks.
603
+ const pendingRuns = new Set<string>()
604
+ // Sessions can retain queued replies after their handler has finished, and
605
+ // can also belong to non-persisted requests. Count persisted handler fibers
606
+ // separately so only transitions touch the recovery alarm.
607
+ let inFlightPersisted = 0
608
+ const workerWaiters = new Map<string, Array<WorkerWaiter>>()
609
+ const replyRegistry = makeReplyRegistry()
610
+ const entitySql = makeEntitySql(storage)
611
+ // Every mailbox write runs in its own storage transaction and waits for any
612
+ // open user transaction, so a user rollback cannot take a reply with it.
613
+ const write = <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E> =>
614
+ entitySql.guard(withTransaction(storage, effect))
615
+ let runtime: EntityRuntime | undefined
616
+
617
+ const getRuntime = (registration: EntityRegistration): Effect.Effect<EntityRuntime> =>
618
+ runtime !== undefined ? Effect.succeed(runtime) : Effect.map(
619
+ makeEntityRuntime(
620
+ registration,
621
+ options.address,
622
+ () => crypto.randomUUID(),
623
+ options.entityName,
624
+ options.keepAlive,
625
+ replyRegistry,
626
+ entitySql
627
+ ),
628
+ (built) => runtime = built
629
+ )
630
+
631
+ const armEarliestAlarm = Effect.suspend(() => {
632
+ const deliverAt = earliestDeliverAt(sql)
633
+ return deliverAt === undefined ? Effect.void : armAlarm(storage, deliverAt)
634
+ })
635
+
636
+ const heartbeatMillis = () => keepAliveHeartbeatMillis(options.address.entityType)
637
+
638
+ const isRunning = (requestId: string) => sessions.has(requestId) || pendingRuns.has(requestId)
639
+
640
+ // Hot paths only ever move the alarm earlier. A heartbeat while a persisted
641
+ // row runs leaves an alarm behind for the restart that kills its handler.
642
+ const armHeartbeat = Effect.suspend(() => armAlarm(storage, Date.now() + heartbeatMillis()))
643
+
644
+ // Recomputes the single alarm from the whole entity state. Callers hold the
645
+ // entry permit, so a concurrent keep-alive write cannot slip in between the
646
+ // read and a `deleteAlarm`.
647
+ const rearm = Effect.suspend(() => {
648
+ const next = nextAlarmAt(sql, { now: Date.now(), heartbeatMillis: heartbeatMillis(), isRunning })
649
+ return Effect.flatMap(
650
+ Effect.promise(() => storage.getAlarm()),
651
+ (current) => {
652
+ if (next !== undefined) {
653
+ return current === next ? Effect.void : Effect.promise(() => storage.setAlarm(next))
654
+ }
655
+ return current === null ? Effect.void : Effect.promise(() => storage.deleteAlarm())
656
+ }
657
+ )
658
+ })
659
+
660
+ // Called under the entry permit, before forking the handler. Overlapping
661
+ // requests share the first heartbeat instead of reading the alarm per run.
662
+ const startPersisted = Effect.suspend(() => ++inFlightPersisted === 1 ? armHeartbeat : Effect.void)
663
+
664
+ // Handler finalizers hold the entry permit and finish mailbox writes before
665
+ // dropping the count. The last completion may move the alarm later to a
666
+ // scheduled delivery, retain keep-alive, or remove it entirely.
667
+ const finishPersisted = Effect.suspend(() => --inFlightPersisted === 0 ? rearm : Effect.void)
668
+
669
+ const removeSession = (requestId: string, session: Session) => {
670
+ if (!session.cancelling && sessions.get(requestId) === session) sessions.delete(requestId)
671
+ }
672
+
673
+ const signalSaved = (session: Session) => {
674
+ const saved = session.saved
675
+ if (saved === undefined) return
676
+ session.saved = undefined
677
+ Deferred.doneUnsafe(saved, Effect.void)
678
+ }
679
+
680
+ const storedNextReply = (requestId: string): Effect.Effect<ReadonlyArray<string>> =>
681
+ Effect.map(loadNextReply(sql, requestId), (nextReply) => nextReply === undefined ? [] : [nextReply.reply])
682
+
683
+ // Reads the next stored reply of a running persisted stream, waiting for
684
+ // the handler to save one when there is none yet. The waiter is registered
685
+ // before reading storage, so a reply saved in between still wakes it.
686
+ const awaitStoredReply = (requestId: string, session: Session): Effect.Effect<Effect.Effect<ReadonlyArray<string>>> =>
687
+ Effect.suspend(() => {
688
+ const saved = session.saved ??= Deferred.makeUnsafe<void>()
689
+ return Effect.map(
690
+ loadNextReply(sql, requestId),
691
+ (nextReply) =>
692
+ nextReply === undefined
693
+ ? Effect.andThen(Deferred.await(saved), storedNextReply(requestId))
694
+ : Effect.succeed([nextReply.reply])
695
+ )
696
+ })
697
+
698
+ const takeReply = (requestId: string, session: Session): Effect.Effect<ReadonlyArray<string>> =>
699
+ Queue.take(session.queue).pipe(
700
+ Effect.map((reply) => {
701
+ if (reply.terminal) removeSession(requestId, session)
702
+ return [reply.text]
703
+ }),
704
+ Pull.catchDone(() => Effect.succeed([]))
705
+ )
706
+
707
+ // Hands a scheduled reply to the pinned callers waiting in this wake and
708
+ // returns the reply targets stored now, which include any a duplicate added
709
+ // while the handler ran. Runs after the reply is saved: once the row is
710
+ // processed, a later duplicate reads the stored reply instead.
711
+ const settleScheduledReply = (
712
+ requestId: string,
713
+ reply: string,
714
+ replyTos: ReadonlyArray<ReplyTarget> | undefined
715
+ ): Effect.Effect<ReadonlyArray<ReplyTarget> | undefined> =>
716
+ Effect.map(loadMessage(sql, requestId), (message) => {
717
+ const waiters = workerWaiters.get(requestId)
718
+ if (waiters !== undefined) {
719
+ workerWaiters.delete(requestId)
720
+ for (const waiter of waiters) {
721
+ Deferred.doneUnsafe(waiter.deferred, Effect.succeed(success(requestId, [reply])))
722
+ }
723
+ }
724
+ return message === undefined ? replyTos : message.replyTos
725
+ })
726
+
727
+ const pushScheduledReply = (
728
+ reply: string,
729
+ replyTos: ReadonlyArray<ReplyTarget> | undefined
730
+ ): Effect.Effect<void> =>
731
+ Effect.suspend(() => {
732
+ if (replyTos === undefined) return Effect.void
733
+ const namespace = options.getNamespace()
734
+ if (namespace === undefined) {
735
+ return Effect.logError(
736
+ "Scheduled entity reply delivery failed",
737
+ new Error("CloudflareCluster: ClusterEntity export is unavailable for scheduled reply delivery")
738
+ )
739
+ }
740
+ return Effect.forEach(
741
+ replyTos,
742
+ ({ replyTo, requestId }) =>
743
+ Effect.promise(() => namespace.getByName(replyTo).deliverReply(requestId, reply)).pipe(
744
+ Effect.flatMap((delivered) =>
745
+ delivered
746
+ ? Effect.void
747
+ : Effect.logError(
748
+ "Scheduled entity reply delivery failed",
749
+ new Error(`Scheduled entity reply target is unavailable: ${replyTo}`)
750
+ )
751
+ ),
752
+ Effect.catchCause((cause) => Effect.logError("Scheduled entity reply delivery failed", cause))
753
+ ),
754
+ { concurrency: "unbounded", discard: true }
755
+ )
756
+ })
757
+
758
+ const deliverScheduledReply = (
759
+ requestId: string,
760
+ reply: string,
761
+ replyTos: ReadonlyArray<ReplyTarget> | undefined
762
+ ): Effect.Effect<void> =>
763
+ Effect.flatMap(
764
+ settleScheduledReply(requestId, reply, replyTos),
765
+ (targets) => pushScheduledReply(reply, targets)
766
+ )
767
+
768
+ // Runs one request under the entry permit up to forking its handler fiber,
769
+ // then returns a continuation that awaits the handler output. Callers run
770
+ // the continuation after the entry permit is released so handlers governed
771
+ // by the concurrency semaphore can interleave with later storage entries.
772
+ const run = Effect.fnUntraced(function*(
773
+ registration: EntityRegistration,
774
+ entityRuntime: EntityRuntime,
775
+ envelope: Envelope.Request.Any,
776
+ lastSentChunkText: string | undefined,
777
+ discard: boolean,
778
+ persisted: boolean,
779
+ runOptions?: RunOptions
780
+ ) {
781
+ const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps
782
+ let lastSentChunk = Option.none<Reply.Chunk<any>>()
783
+ if (lastSentChunkText !== undefined) {
784
+ const reply = yield* decodeReplyFor(rpc, registration.context, lastSentChunkText)
785
+ if (reply._tag === "Chunk") lastSentChunk = Option.some(reply)
786
+ }
787
+ const requestId = String(envelope.requestId)
788
+ if (!discard) {
789
+ const active = sessions.get(requestId)
790
+ if (active !== undefined) {
791
+ // A stream duplicate resumes from storage, as on other runners.
792
+ if (persisted && RpcSchema.isStreamSchema(rpc.successSchema)) {
793
+ return yield* awaitStoredReply(requestId, active)
794
+ }
795
+ const nextReply = persisted ? yield* loadNextReply(sql, requestId) : undefined
796
+ if (nextReply !== undefined) return Effect.succeed([nextReply.reply])
797
+ const fiber = active.fiber
798
+ if (!persisted || fiber === undefined) return Effect.succeed([])
799
+ // A unary duplicate waits for the handler outside the entry permit and
800
+ // then reads its stored reply, which wins even when post-reply handler
801
+ // cleanup failed.
802
+ return Effect.gen(function*() {
803
+ const exit = yield* Fiber.await(fiber)
804
+ const reply = yield* loadNextReply(sql, requestId)
805
+ if (reply !== undefined) return [reply.reply]
806
+ if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
807
+ return yield* Effect.die("Persisted unary handler finished without a reply")
808
+ })
809
+ }
810
+ }
811
+
812
+ const scheduled = runOptions?.scheduled === true
813
+ if (discard || scheduled) {
814
+ if (persisted) {
815
+ if (pendingRuns.has(requestId)) return Effect.succeed([])
816
+ pendingRuns.add(requestId)
817
+ yield* startPersisted
818
+ }
819
+ // No stream acks on this path, so the runtime's plain permit is enough.
820
+ const execute = entityRuntime.run(envelope, lastSentChunk, discard, (reply) =>
821
+ Effect.gen(function*() {
822
+ const encoded = yield* encodeReplyFor(registration, rpc, reply)
823
+ const save = persisted ? Effect.orDie(write(saveReply(sql, encoded))) : Effect.void
824
+ if (!scheduled || reply._tag !== "WithExit") return yield* save
825
+ // Saving the terminal reply, settling the waiters and reading the
826
+ // targets share the entry permit, so a duplicate entry either finds
827
+ // the stored reply or is registered in time to receive it.
828
+ const replyTos = yield* Semaphore.withPermit(
829
+ semaphore,
830
+ Effect.andThen(save, settleScheduledReply(requestId, encoded, runOptions?.replyTos))
831
+ )
832
+ yield* pushScheduledReply(encoded, replyTos)
833
+ }))
834
+ const fiber = yield* Effect.forkDetach(
835
+ execute.pipe(
836
+ Effect.onExit((exit) =>
837
+ !persisted ? Effect.void : Semaphore.withPermit(
838
+ semaphore,
839
+ Effect.suspend(() => {
840
+ pendingRuns.delete(requestId)
841
+ return discard && Exit.isSuccess(exit) ? write(completeTell(sql, requestId)) : Effect.void
842
+ }).pipe(Effect.ensuring(finishPersisted))
843
+ )
844
+ )
845
+ )
846
+ )
847
+ options.waitUntil(Fiber.await(fiber))
848
+ return Effect.as(Fiber.join(fiber), [])
849
+ }
850
+
851
+ const queue = yield* Queue.make<SessionReply, Cause.Done>()
852
+ const completeInterrupt = persisted
853
+ ? Effect.flatMap(
854
+ encodeReplyFor(
855
+ registration,
856
+ rpc,
857
+ new Reply.WithExit({
858
+ requestId: envelope.requestId,
859
+ id: crypto.randomUUID() as any,
860
+ exit: Exit.interrupt()
861
+ })
862
+ ),
863
+ (reply) =>
864
+ Effect.orDie(
865
+ write(Effect.andThen(clearReplies(sql, requestId), saveReply(sql, reply)))
866
+ )
867
+ )
868
+ : Effect.void
869
+ const session: Session = {
870
+ queue,
871
+ completeInterrupt,
872
+ uninterruptible: isUninterruptibleForServer(rpc.annotations),
873
+ cancelling: false,
874
+ ack: undefined,
875
+ fiber: undefined,
876
+ saved: undefined
877
+ }
878
+ sessions.set(requestId, session)
879
+ if (persisted) yield* startPersisted
880
+ const permit = makeHandlerPermit(entityRuntime.handlerSemaphore)
881
+ const respond = Effect.fnUntraced(function*(reply: Reply.Reply<any>) {
882
+ const encoded = yield* encodeReplyFor(registration, rpc, reply)
883
+ if (persisted) {
884
+ yield* Effect.orDie(write(saveReply(sql, encoded)))
885
+ signalSaved(session)
886
+ }
887
+ if (reply._tag === "Chunk") {
888
+ const acknowledged = Deferred.makeUnsafe<void>()
889
+ session.ack = { replyId: String(reply.id), deferred: acknowledged }
890
+ // A false offer means the session was interrupted and the queue
891
+ // ended; awaiting the acknowledgement would then never resume.
892
+ const offered = yield* Queue.offer(queue, { text: encoded, terminal: false })
893
+ if (offered) yield* permit.pause(Deferred.await(acknowledged))
894
+ else session.ack = undefined
895
+ } else {
896
+ yield* Queue.offer(queue, { text: encoded, terminal: true })
897
+ }
898
+ })
899
+ session.fiber = yield* Effect.forkDetach(
900
+ entityRuntime.run(envelope, lastSentChunk, discard, respond, permit).pipe(
901
+ Effect.onExit((exit) => {
902
+ const cleanup = Effect.sync(() => {
903
+ if (Exit.isSuccess(exit)) Queue.endUnsafe(queue)
904
+ else Queue.failCauseUnsafe(queue, exit.cause)
905
+ if (Queue.sizeUnsafe(queue) === 0 && session.ack === undefined) {
906
+ removeSession(requestId, session)
907
+ }
908
+ signalSaved(session)
909
+ })
910
+ return persisted
911
+ ? Semaphore.withPermit(semaphore, Effect.andThen(cleanup, finishPersisted))
912
+ : cleanup
913
+ })
914
+ )
915
+ )
916
+ options.waitUntil(Fiber.await(session.fiber))
917
+ return takeReply(requestId, session)
918
+ })
919
+
920
+ const runStored = (
921
+ registration: EntityRegistration,
922
+ entityRuntime: EntityRuntime,
923
+ envelopeText: string,
924
+ lastSentChunk: string | undefined,
925
+ discard: boolean,
926
+ runOptions?: RunOptions
927
+ ) =>
928
+ Effect.flatMap(
929
+ decodeRequest(registration, envelopeText),
930
+ (envelope) => run(registration, entityRuntime, envelope, lastSentChunk, discard, true, runOptions)
931
+ )
932
+
933
+ const completeReplayFailure = (
934
+ registration: EntityRegistration,
935
+ row: StoredMessage,
936
+ cause: Cause.Cause<unknown>
937
+ ): Effect.Effect<void> =>
938
+ Effect.suspend(() => peekEnvelopeTag(row.envelope)).pipe(
939
+ Effect.flatMap((tag) => {
940
+ if (row.discard || tag === undefined) {
941
+ return write(completeTell(sql, row.requestId))
942
+ }
943
+ const rpc = registration.entity.protocol.requests.get(tag) as Rpc.AnyWithProps | undefined
944
+ if (rpc === undefined) {
945
+ return write(completeTell(sql, row.requestId))
946
+ }
947
+ return Effect.flatMap(
948
+ encodeReplyFor(
949
+ registration,
950
+ rpc,
951
+ new Reply.WithExit({
952
+ requestId: row.requestId as any,
953
+ id: crypto.randomUUID() as any,
954
+ exit: Exit.failCause(cause)
955
+ })
956
+ ),
957
+ (reply) =>
958
+ write(saveReply(sql, reply)).pipe(
959
+ Effect.andThen(deliverScheduledReply(row.requestId, reply, row.replyTos))
960
+ )
961
+ ).pipe(
962
+ Effect.catchCause(() => write(completeTell(sql, row.requestId)))
963
+ )
964
+ })
965
+ )
966
+
967
+ // Phase one runs each row up to forking its handler under the entry
968
+ // permit; the returned fibers observe the handler output and route
969
+ // failures through `completeReplayFailure`.
970
+ const replayRows = (
971
+ registration: EntityRegistration,
972
+ entityRuntime: EntityRuntime,
973
+ rows: ReadonlyArray<StoredMessage>
974
+ ): Effect.Effect<Array<Fiber.Fiber<void>>> =>
975
+ rows.length === 0 ? Effect.succeed([]) : Effect.forEach(
976
+ rows,
977
+ (row) =>
978
+ // An active session or in-flight run already owns this request;
979
+ // replaying it would start a second execution in the same wake.
980
+ isRunning(row.requestId) ? Effect.succeed(undefined) : runStored(
981
+ registration,
982
+ entityRuntime,
983
+ row.envelope,
984
+ row.lastSentChunk,
985
+ row.discard,
986
+ row.deliverAt === undefined
987
+ ? undefined
988
+ : {
989
+ scheduled: true,
990
+ ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos })
991
+ }
992
+ ).pipe(
993
+ Effect.flatMap((continuation) =>
994
+ Effect.forkDetach(
995
+ Effect.catchCause(
996
+ Effect.asVoid(continuation),
997
+ (cause) => completeReplayFailure(registration, row, cause)
998
+ )
999
+ )
1000
+ ),
1001
+ Effect.catchCause((cause) => Effect.as(completeReplayFailure(registration, row, cause), undefined))
1002
+ )
1003
+ ).pipe(
1004
+ Effect.map((entries) => entries.filter((entry) => entry !== undefined)),
1005
+ Effect.tap((entries) =>
1006
+ Effect.sync(() => {
1007
+ if (entries.length > 0) options.waitUntil(Fiber.awaitAll(entries))
1008
+ })
1009
+ )
1010
+ )
1011
+
1012
+ // Registers the waiter eagerly (under the entry permit) and returns the
1013
+ // await for the caller to run once the permit is released.
1014
+ const delayedOutcome = (
1015
+ requestId: string,
1016
+ discard: boolean,
1017
+ replyTo: string | undefined,
1018
+ clientRequestId = requestId
1019
+ ): Effect.Effect<EntityInvokeResult> => {
1020
+ if (discard || replyTo !== undefined) return Effect.succeed(success(requestId, []))
1021
+ const deferred = Deferred.makeUnsafe<EntityInvokeResult>()
1022
+ const waiters = workerWaiters.get(requestId) ?? []
1023
+ waiters.push({ clientRequestId, deferred })
1024
+ workerWaiters.set(requestId, waiters)
1025
+ return Deferred.await(deferred)
1026
+ }
1027
+
1028
+ // Runs the storage entry under the caller-held permit and returns the
1029
+ // effect that produces the invoke result after the permit is released.
1030
+ const invokeEntry = (
1031
+ envelopeText: string,
1032
+ discard: boolean,
1033
+ delivery: EntityDeliveryOptions | undefined
1034
+ ): Effect.Effect<Effect.Effect<EntityInvokeResult>> => {
1035
+ const registration = getEntityRegistration(options.address.entityType)
1036
+ if (registration === undefined) {
1037
+ return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`)
1038
+ }
1039
+ return Effect.gen(function*() {
1040
+ const entityRuntime = yield* getRuntime(registration)
1041
+ // Replayed rows run on their own fibers. The handler semaphore already
1042
+ // orders them ahead of this request, so the caller does not wait for them.
1043
+ yield* Effect.flatMap(
1044
+ loadUnprocessed(sql),
1045
+ (rows) => replayRows(registration, entityRuntime, rows)
1046
+ )
1047
+ const finish = (
1048
+ requestId: string,
1049
+ continuation: Effect.Effect<ReadonlyArray<string>>
1050
+ ): Effect.Effect<EntityInvokeResult> => Effect.map(continuation, (replies) => success(requestId, replies))
1051
+ // A persisted tell must not pin its caller: the row is durable, so the
1052
+ // invoke result is complete once the handler is forked. Failures of the
1053
+ // forked machinery leave the row unprocessed for replay on a later
1054
+ // wake, so they are only logged here.
1055
+ const finishTell = (
1056
+ requestId: string,
1057
+ continuation: Effect.Effect<ReadonlyArray<string>>
1058
+ ): Effect.Effect<Effect.Effect<EntityInvokeResult>> =>
1059
+ Effect.as(
1060
+ Effect.forkDetach(Effect.catchCause(
1061
+ Effect.asVoid(continuation),
1062
+ (cause) => Effect.logError("Entity tell execution failed", cause)
1063
+ )),
1064
+ Effect.succeed(success(requestId, []))
1065
+ )
1066
+
1067
+ const envelope = yield* decodeRequest(registration, envelopeText)
1068
+ const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps
1069
+ const isPersisted = Context.get(rpc.annotations, Persisted)
1070
+ if (!isPersisted) {
1071
+ const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, false)
1072
+ return finish(String(envelope.requestId), continuation)
1073
+ }
1074
+
1075
+ const persistedResult = yield* Effect.result(
1076
+ write(
1077
+ persistRequest(
1078
+ sql,
1079
+ envelopeText,
1080
+ delivery?.primaryKey ?? Envelope.primaryKey(envelope),
1081
+ discard,
1082
+ delivery?.deliverAt,
1083
+ delivery?.replyTo
1084
+ )
1085
+ ).pipe(
1086
+ Effect.withSpan("CloudflareCluster.persist", {
1087
+ attributes: {
1088
+ entityType: registration.entity.type,
1089
+ entityId: String(options.address.entityId),
1090
+ rpc: envelope.tag
1091
+ }
1092
+ }, { captureStackTrace: false }),
1093
+ Effect.provideContext(registration.context)
1094
+ )
1095
+ )
1096
+ if (Result.isFailure(persistedResult)) {
1097
+ return Effect.succeed<EntityInvokeResult>(
1098
+ persistedResult.failure._tag === "MailboxFull"
1099
+ ? { _tag: "MailboxFull" }
1100
+ : { _tag: "EncodedMessageTooLarge" }
1101
+ )
1102
+ }
1103
+ const persisted: PersistResult = persistedResult.success
1104
+ if (persisted._tag === "Duplicate") {
1105
+ const original = yield* loadMessage(sql, persisted.originalId)
1106
+ if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared")
1107
+ if (original.discard && !discard) {
1108
+ return Effect.succeed<EntityInvokeResult>({ _tag: "AskDeduplicatedToTell" })
1109
+ }
1110
+ const nextReply = yield* loadNextReply(sql, persisted.originalId)
1111
+ if (nextReply !== undefined) {
1112
+ if (nextReply.kind === "WithExit") {
1113
+ const session = sessions.get(persisted.originalId)
1114
+ if (session !== undefined) removeSession(persisted.originalId, session)
1115
+ }
1116
+ return Effect.succeed(success(persisted.originalId, [nextReply.reply]))
1117
+ }
1118
+ if (persisted.processed) {
1119
+ return Effect.succeed(success(persisted.originalId, []))
1120
+ }
1121
+ if (
1122
+ delivery?.deliverAt !== undefined ||
1123
+ (original.deliverAt !== undefined && original.deliverAt > Date.now())
1124
+ ) {
1125
+ yield* armEarliestAlarm
1126
+ return delayedOutcome(
1127
+ persisted.originalId,
1128
+ discard,
1129
+ delivery?.replyTo,
1130
+ String(envelope.requestId)
1131
+ )
1132
+ }
1133
+ // A scheduled ask past its deadline replies through its waiters and
1134
+ // reply targets, not a session. The duplicate joins that execution,
1135
+ // starting it if it is not running yet; the waiter is registered
1136
+ // first so a run that settles at once still reaches it.
1137
+ if (original.deliverAt !== undefined && !original.discard) {
1138
+ const outcome = delayedOutcome(
1139
+ persisted.originalId,
1140
+ discard,
1141
+ delivery?.replyTo,
1142
+ String(envelope.requestId)
1143
+ )
1144
+ if (!isRunning(persisted.originalId)) {
1145
+ yield* replayRows(registration, entityRuntime, [original])
1146
+ }
1147
+ return outcome
1148
+ }
1149
+ const continuation = yield* runStored(
1150
+ registration,
1151
+ entityRuntime,
1152
+ original.envelope,
1153
+ original.lastSentChunk,
1154
+ original.discard
1155
+ )
1156
+ return original.discard
1157
+ ? yield* finishTell(persisted.originalId, continuation)
1158
+ : finish(persisted.originalId, continuation)
1159
+ }
1160
+ if (delivery?.deliverAt !== undefined) {
1161
+ yield* armEarliestAlarm
1162
+ return delayedOutcome(String(envelope.requestId), discard, delivery.replyTo)
1163
+ }
1164
+ const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, true)
1165
+ return discard
1166
+ ? yield* finishTell(String(envelope.requestId), continuation)
1167
+ : finish(String(envelope.requestId), continuation)
1168
+ })
1169
+ }
1170
+
1171
+ const invoke = (
1172
+ envelopeText: string,
1173
+ discard: boolean,
1174
+ delivery?: EntityDeliveryOptions | undefined
1175
+ ): Effect.Effect<EntityInvokeResult> =>
1176
+ Effect.flatten(Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery)))
1177
+
1178
+ // An acknowledgement with no session comes after a restart. The stream is
1179
+ // replayed from its last acknowledged chunk so the caller gets its next
1180
+ // reply instead of waiting on an alarm replay that waits for this ack.
1181
+ const resumeAcknowledged = (requestId: string): Effect.Effect<ReadonlyArray<string>> =>
1182
+ Effect.flatten(Semaphore.withPermit(
1183
+ semaphore,
1184
+ Effect.gen(function*() {
1185
+ const nextReply = yield* loadNextReply(sql, requestId)
1186
+ if (nextReply !== undefined) return Effect.succeed([nextReply.reply])
1187
+ const registration = getEntityRegistration(options.address.entityType)
1188
+ if (registration === undefined) return Effect.succeed([])
1189
+ // A session started since the ack was written owns the request.
1190
+ const session = sessions.get(requestId)
1191
+ if (session !== undefined) return yield* awaitStoredReply(requestId, session)
1192
+ if (pendingRuns.has(requestId)) return Effect.succeed([])
1193
+ const row = (yield* loadUnprocessed(sql)).find((row) => row.requestId === requestId)
1194
+ if (row === undefined || row.discard || row.deliverAt !== undefined) return Effect.succeed([])
1195
+ const entityRuntime = yield* getRuntime(registration)
1196
+ return yield* runStored(registration, entityRuntime, row.envelope, row.lastSentChunk, false).pipe(
1197
+ Effect.catchCause((cause) =>
1198
+ Effect.as(completeReplayFailure(registration, row, cause), storedNextReply(requestId))
1199
+ )
1200
+ )
1201
+ })
1202
+ ))
1203
+
1204
+ const acknowledge = (requestId: string, replyId: string): Effect.Effect<ReadonlyArray<string>> =>
1205
+ write(ackChunk(sql, requestId, replyId)).pipe(
1206
+ Effect.flatMap(() => {
1207
+ const session = sessions.get(requestId)
1208
+ if (session?.ack?.replyId === replyId) {
1209
+ const acknowledged = session.ack.deferred
1210
+ session.ack = undefined
1211
+ Deferred.doneUnsafe(acknowledged, Effect.void)
1212
+ return takeReply(requestId, session)
1213
+ }
1214
+ if (session === undefined) return resumeAcknowledged(requestId)
1215
+ // A session this ack does not match may be a replay that has not saved
1216
+ // its next reply yet; the caller will not ask again, so wait for it.
1217
+ return Effect.flatten(awaitStoredReply(requestId, session))
1218
+ })
1219
+ )
1220
+
1221
+ const interrupt = (storageRequestId: string, clientRequestId = storageRequestId): Effect.Effect<void> =>
1222
+ Effect.suspend(() => {
1223
+ const waiters = workerWaiters.get(storageRequestId)
1224
+ if (waiters !== undefined) {
1225
+ const remaining = waiters.filter((waiter) => {
1226
+ if (waiter.clientRequestId !== clientRequestId) return true
1227
+ Deferred.doneUnsafe(waiter.deferred, Effect.die(new Error("Delayed entity request interrupted")))
1228
+ return false
1229
+ })
1230
+ if (remaining.length === 0) workerWaiters.delete(storageRequestId)
1231
+ else workerWaiters.set(storageRequestId, remaining)
1232
+ }
1233
+ const session = sessions.get(storageRequestId)
1234
+ if (session === undefined || session.uninterruptible) return Effect.void
1235
+ // Keep ownership through handler finalization and the terminal write.
1236
+ // A failed write must leave the row owned rather than replayable.
1237
+ session.cancelling = true
1238
+ if (session.ack !== undefined) {
1239
+ Deferred.doneUnsafe(session.ack.deferred, Effect.void)
1240
+ session.ack = undefined
1241
+ }
1242
+ Queue.endUnsafe(session.queue)
1243
+ const stop = session.fiber === undefined ? Effect.void : Effect.asVoid(Fiber.interrupt(session.fiber))
1244
+ // The handler finalizer takes this same permit. Interrupt outside it.
1245
+ return Effect.andThen(
1246
+ stop,
1247
+ Semaphore.withPermit(
1248
+ semaphore,
1249
+ Effect.andThen(
1250
+ session.completeInterrupt,
1251
+ Effect.suspend(() => {
1252
+ if (sessions.get(storageRequestId) === session) sessions.delete(storageRequestId)
1253
+ return inFlightPersisted === 0 ? rearm : Effect.void
1254
+ })
1255
+ )
1256
+ )
1257
+ )
1258
+ })
1259
+
1260
+ const reset = (requestId: string): Effect.Effect<void> => write(clearReplies(sql, requestId))
1261
+
1262
+ const alarm = Effect.suspend(() => {
1263
+ const registration = getEntityRegistration(options.address.entityType)
1264
+ if (registration === undefined) {
1265
+ // A deploy that removed this entity type leaves its last alarm behind:
1266
+ // it fails here, Cloudflare retries it 6 times, and the chain stops. The
1267
+ // `cluster_entity_state` row is never cleaned up.
1268
+ return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`)
1269
+ }
1270
+ // The entry permit covers replay setup and alarm arming. The next alarm is
1271
+ // armed before awaiting the replayed handlers: a handler that outlives the
1272
+ // alarm wall-clock limit kills this invocation, and the chain must not
1273
+ // depend on code after it.
1274
+ return Effect.flatten(Semaphore.withPermit(
1275
+ semaphore,
1276
+ Effect.gen(function*() {
1277
+ const rows = (yield* loadUnprocessed(sql)).filter((row) => !isRunning(row.requestId))
1278
+ const keepAlive = loadEntityState(sql).keepAlive
1279
+ // A heartbeat left over after keep-alive was released must not
1280
+ // rebuild the entity; the `ensuring` below still settles the alarm.
1281
+ if (rows.length === 0 && !keepAlive) return Effect.void
1282
+ const entityRuntime = yield* getRuntime(registration)
1283
+ const fibers = yield* replayRows(registration, entityRuntime, rows)
1284
+ yield* rearm
1285
+ const awaitReplays = Effect.asVoid(Fiber.awaitAll(fibers))
1286
+ if (!keepAlive) return awaitReplays
1287
+ // The hold is never awaited: it does not resolve while held.
1288
+ return Effect.andThen(
1289
+ Effect.catchCause(entityRuntime.warm, (cause) => Effect.logError("Entity keep-alive rebuild failed", cause)),
1290
+ awaitReplays
1291
+ )
1292
+ })
1293
+ )).pipe(
1294
+ Effect.ensuring(Semaphore.withPermit(semaphore, rearm)),
1295
+ Effect.withSpan("CloudflareCluster.alarm", {
1296
+ attributes: {
1297
+ entityType: registration.entity.type,
1298
+ entityId: String(options.address.entityId)
1299
+ }
1300
+ }, { captureStackTrace: false }),
1301
+ Effect.provideContext(registration.context)
1302
+ )
1303
+ })
1304
+
1305
+ const saveKeepAliveState = (wanted: boolean): Effect.Effect<void> =>
1306
+ Semaphore.withPermit(
1307
+ semaphore,
1308
+ Effect.andThen(
1309
+ write(saveKeepAlive(sql, wanted)),
1310
+ wanted ? armHeartbeat : rearm
1311
+ )
1312
+ )
1313
+
1314
+ const deliverReply = (requestId: string, reply: string): Effect.Effect<boolean> =>
1315
+ Effect.sync(() => replyRegistry.deliver(requestId, reply))
1316
+
1317
+ return { invoke, acknowledge, interrupt, reset, alarm, deliverReply, saveKeepAlive: saveKeepAliveState }
1318
+ }