effect 4.0.0-rc.118 → 4.0.1

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 (254) hide show
  1. package/README.md +17 -4
  2. package/dist/Array.d.ts +25 -25
  3. package/dist/Array.d.ts.map +1 -1
  4. package/dist/Array.js +16 -16
  5. package/dist/Array.js.map +1 -1
  6. package/dist/Cache.d.ts.map +1 -1
  7. package/dist/Cache.js +20 -16
  8. package/dist/Cache.js.map +1 -1
  9. package/dist/Cause.d.ts.map +1 -1
  10. package/dist/Cause.js +1 -1
  11. package/dist/Cause.js.map +1 -1
  12. package/dist/Chunk.d.ts +26 -26
  13. package/dist/Chunk.d.ts.map +1 -1
  14. package/dist/Chunk.js +15 -15
  15. package/dist/Chunk.js.map +1 -1
  16. package/dist/Effect.d.ts +167 -39
  17. package/dist/Effect.d.ts.map +1 -1
  18. package/dist/Effect.js +16 -10
  19. package/dist/Effect.js.map +1 -1
  20. package/dist/HashRing.d.ts.map +1 -1
  21. package/dist/HashRing.js +13 -5
  22. package/dist/HashRing.js.map +1 -1
  23. package/dist/MutableHashMap.d.ts.map +1 -1
  24. package/dist/MutableHashMap.js +4 -22
  25. package/dist/MutableHashMap.js.map +1 -1
  26. package/dist/Option.d.ts +17 -17
  27. package/dist/Option.d.ts.map +1 -1
  28. package/dist/Option.js +6 -6
  29. package/dist/Option.js.map +1 -1
  30. package/dist/Pool.d.ts +9 -5
  31. package/dist/Pool.d.ts.map +1 -1
  32. package/dist/Pool.js +34 -41
  33. package/dist/Pool.js.map +1 -1
  34. package/dist/Queue.d.ts +19 -2
  35. package/dist/Queue.d.ts.map +1 -1
  36. package/dist/Queue.js +9 -4
  37. package/dist/Queue.js.map +1 -1
  38. package/dist/RcMap.d.ts.map +1 -1
  39. package/dist/RcMap.js +16 -1
  40. package/dist/RcMap.js.map +1 -1
  41. package/dist/Record.d.ts +14 -14
  42. package/dist/Record.d.ts.map +1 -1
  43. package/dist/Record.js +10 -10
  44. package/dist/Record.js.map +1 -1
  45. package/dist/Schema.d.ts +37 -21
  46. package/dist/Schema.d.ts.map +1 -1
  47. package/dist/Schema.js +29 -10
  48. package/dist/Schema.js.map +1 -1
  49. package/dist/SchemaAST.d.ts.map +1 -1
  50. package/dist/SchemaAST.js +116 -97
  51. package/dist/SchemaAST.js.map +1 -1
  52. package/dist/SchemaRepresentation.d.ts +3 -1
  53. package/dist/SchemaRepresentation.d.ts.map +1 -1
  54. package/dist/SchemaRepresentation.js +3 -1
  55. package/dist/SchemaRepresentation.js.map +1 -1
  56. package/dist/ScopedCache.d.ts.map +1 -1
  57. package/dist/ScopedCache.js +24 -16
  58. package/dist/ScopedCache.js.map +1 -1
  59. package/dist/Stream.d.ts +45 -6
  60. package/dist/Stream.d.ts.map +1 -1
  61. package/dist/Stream.js +55 -44
  62. package/dist/Stream.js.map +1 -1
  63. package/dist/ai/LanguageModel.d.ts +8 -0
  64. package/dist/ai/LanguageModel.d.ts.map +1 -1
  65. package/dist/ai/LanguageModel.js +13 -2
  66. package/dist/ai/LanguageModel.js.map +1 -1
  67. package/dist/ai/McpSchema.d.ts +402 -402
  68. package/dist/ai/McpSchema.d.ts.map +1 -1
  69. package/dist/ai/McpSchema.js +1 -1
  70. package/dist/ai/McpSchema.js.map +1 -1
  71. package/dist/ai/McpServer.d.ts.map +1 -1
  72. package/dist/ai/McpServer.js +11 -3
  73. package/dist/ai/McpServer.js.map +1 -1
  74. package/dist/ai/Response.d.ts.map +1 -1
  75. package/dist/ai/Response.js +22 -12
  76. package/dist/ai/Response.js.map +1 -1
  77. package/dist/ai/internal/mcpProtocol/v2024_11_05.js +1 -0
  78. package/dist/ai/internal/mcpProtocol/v2024_11_05.js.map +1 -1
  79. package/dist/ai/internal/mcpProtocol/v2025_03_26.js +1 -0
  80. package/dist/ai/internal/mcpProtocol/v2025_03_26.js.map +1 -1
  81. package/dist/ai/internal/mcpProtocol/v2025_06_18.js +1 -0
  82. package/dist/ai/internal/mcpProtocol/v2025_06_18.js.map +1 -1
  83. package/dist/ai/internal/mcpProtocol/v2025_11_25.js +1 -0
  84. package/dist/ai/internal/mcpProtocol/v2025_11_25.js.map +1 -1
  85. package/dist/ai/internal/mcpSchema/v2024_11_05.js +3 -1
  86. package/dist/ai/internal/mcpSchema/v2024_11_05.js.map +1 -1
  87. package/dist/cluster/ClusterWorkflowEngine.d.ts.map +1 -1
  88. package/dist/cluster/ClusterWorkflowEngine.js +16 -3
  89. package/dist/cluster/ClusterWorkflowEngine.js.map +1 -1
  90. package/dist/cluster/MessageStorage.d.ts +32 -8
  91. package/dist/cluster/MessageStorage.d.ts.map +1 -1
  92. package/dist/cluster/MessageStorage.js +2 -1
  93. package/dist/cluster/MessageStorage.js.map +1 -1
  94. package/dist/cluster/Sharding.d.ts +5 -2
  95. package/dist/cluster/Sharding.d.ts.map +1 -1
  96. package/dist/cluster/Sharding.js +15 -8
  97. package/dist/cluster/Sharding.js.map +1 -1
  98. package/dist/cluster/SqlMessageStorage.d.ts.map +1 -1
  99. package/dist/cluster/SqlMessageStorage.js +36 -16
  100. package/dist/cluster/SqlMessageStorage.js.map +1 -1
  101. package/dist/cluster/SqlRunnerStorage.d.ts.map +1 -1
  102. package/dist/cluster/SqlRunnerStorage.js +30 -44
  103. package/dist/cluster/SqlRunnerStorage.js.map +1 -1
  104. package/dist/cluster/internal/entityManager.js +63 -62
  105. package/dist/cluster/internal/entityManager.js.map +1 -1
  106. package/dist/cluster/internal/interruptors.js +8 -0
  107. package/dist/cluster/internal/interruptors.js.map +1 -1
  108. package/dist/cluster/internal/resourceMap.js +39 -14
  109. package/dist/cluster/internal/resourceMap.js.map +1 -1
  110. package/dist/cluster/internal/resourceRef.js +168 -87
  111. package/dist/cluster/internal/resourceRef.js.map +1 -1
  112. package/dist/http/HttpEffect.d.ts +1 -0
  113. package/dist/http/HttpEffect.d.ts.map +1 -1
  114. package/dist/http/HttpEffect.js +29 -22
  115. package/dist/http/HttpEffect.js.map +1 -1
  116. package/dist/http/HttpMiddleware.d.ts.map +1 -1
  117. package/dist/http/HttpMiddleware.js +3 -2
  118. package/dist/http/HttpMiddleware.js.map +1 -1
  119. package/dist/http/HttpStaticServer.d.ts.map +1 -1
  120. package/dist/http/HttpStaticServer.js +14 -0
  121. package/dist/http/HttpStaticServer.js.map +1 -1
  122. package/dist/http/HttpTraceContext.d.ts +1 -1
  123. package/dist/http/HttpTraceContext.d.ts.map +1 -1
  124. package/dist/http/HttpTraceContext.js +4 -4
  125. package/dist/http/HttpTraceContext.js.map +1 -1
  126. package/dist/http-api/HttpApiError.d.ts +1 -0
  127. package/dist/http-api/HttpApiError.d.ts.map +1 -1
  128. package/dist/http-api/HttpApiError.js +1 -0
  129. package/dist/http-api/HttpApiError.js.map +1 -1
  130. package/dist/internal/core.js +43 -2
  131. package/dist/internal/core.js.map +1 -1
  132. package/dist/internal/dateTime.js +1 -1
  133. package/dist/internal/dateTime.js.map +1 -1
  134. package/dist/internal/effect.js +138 -165
  135. package/dist/internal/effect.js.map +1 -1
  136. package/dist/internal/rcRef.js +59 -23
  137. package/dist/internal/rcRef.js.map +1 -1
  138. package/dist/internal/schema/annotations.js +1 -7
  139. package/dist/internal/schema/annotations.js.map +1 -1
  140. package/dist/internal/schema/codegen.js +56 -28
  141. package/dist/internal/schema/codegen.js.map +1 -1
  142. package/dist/internal/schema/fromJsonSchemaDocument.js +11 -8
  143. package/dist/internal/schema/fromJsonSchemaDocument.js.map +1 -1
  144. package/dist/internal/schema/toCodeDocument.js +10 -30
  145. package/dist/internal/schema/toCodeDocument.js.map +1 -1
  146. package/dist/internal/schema/toCodec.js +1 -15
  147. package/dist/internal/schema/toCodec.js.map +1 -1
  148. package/dist/internal/schema/toEquivalence.js +4 -3
  149. package/dist/internal/schema/toEquivalence.js.map +1 -1
  150. package/dist/internal/schema/toFormatter.js +4 -4
  151. package/dist/internal/schema/toFormatter.js.map +1 -1
  152. package/dist/reactivity/Atom.d.ts.map +1 -1
  153. package/dist/reactivity/Atom.js +6 -2
  154. package/dist/reactivity/Atom.js.map +1 -1
  155. package/dist/reactivity/AtomRpc.d.ts +3 -3
  156. package/dist/reactivity/AtomRpc.d.ts.map +1 -1
  157. package/dist/rpc/Rpc.d.ts +2 -2
  158. package/dist/rpc/Rpc.d.ts.map +1 -1
  159. package/dist/rpc/RpcClient.d.ts +7 -7
  160. package/dist/rpc/RpcClient.d.ts.map +1 -1
  161. package/dist/rpc/RpcClient.js +21 -16
  162. package/dist/rpc/RpcClient.js.map +1 -1
  163. package/dist/rpc/RpcMessage.d.ts +1 -1
  164. package/dist/rpc/RpcMessage.d.ts.map +1 -1
  165. package/dist/rpc/RpcServer.d.ts.map +1 -1
  166. package/dist/rpc/RpcServer.js +2 -2
  167. package/dist/rpc/RpcServer.js.map +1 -1
  168. package/dist/rpc/Utils.d.ts +2 -3
  169. package/dist/rpc/Utils.d.ts.map +1 -1
  170. package/dist/rpc/Utils.js +3 -4
  171. package/dist/rpc/Utils.js.map +1 -1
  172. package/dist/schema/SchemaCompiler/runtime.js +6 -1
  173. package/dist/schema/SchemaCompiler/runtime.js.map +1 -1
  174. package/dist/testing/TestSchema.d.ts +234 -6
  175. package/dist/testing/TestSchema.d.ts.map +1 -1
  176. package/dist/testing/TestSchema.js +244 -25
  177. package/dist/testing/TestSchema.js.map +1 -1
  178. package/dist/testing/index.d.ts +1 -0
  179. package/dist/testing/index.d.ts.map +1 -1
  180. package/dist/testing/index.js +1 -0
  181. package/dist/testing/index.js.map +1 -1
  182. package/dist/workflow/DurableDeferred.js +1 -1
  183. package/dist/workflow/DurableDeferred.js.map +1 -1
  184. package/dist/workflow/Workflow.js +1 -1
  185. package/dist/workflow/Workflow.js.map +1 -1
  186. package/dist/workflow/WorkflowEngine.d.ts +2 -0
  187. package/dist/workflow/WorkflowEngine.d.ts.map +1 -1
  188. package/dist/workflow/WorkflowEngine.js +1 -0
  189. package/dist/workflow/WorkflowEngine.js.map +1 -1
  190. package/package.json +1 -1
  191. package/src/Array.ts +31 -31
  192. package/src/Cache.ts +22 -16
  193. package/src/Cause.ts +1 -1
  194. package/src/Chunk.ts +29 -29
  195. package/src/Effect.ts +170 -39
  196. package/src/HashRing.ts +14 -5
  197. package/src/MutableHashMap.ts +4 -24
  198. package/src/Option.ts +19 -19
  199. package/src/Pool.ts +28 -36
  200. package/src/Queue.ts +26 -6
  201. package/src/RcMap.ts +26 -2
  202. package/src/Record.ts +20 -20
  203. package/src/Schema.ts +48 -24
  204. package/src/SchemaAST.ts +135 -115
  205. package/src/SchemaRepresentation.ts +3 -1
  206. package/src/ScopedCache.ts +27 -16
  207. package/src/Stream.ts +158 -88
  208. package/src/ai/LanguageModel.ts +27 -2
  209. package/src/ai/McpSchema.ts +3 -3
  210. package/src/ai/McpServer.ts +12 -3
  211. package/src/ai/Response.ts +27 -12
  212. package/src/ai/internal/mcpProtocol/v2024_11_05.ts +1 -0
  213. package/src/ai/internal/mcpProtocol/v2025_03_26.ts +1 -0
  214. package/src/ai/internal/mcpProtocol/v2025_06_18.ts +1 -0
  215. package/src/ai/internal/mcpProtocol/v2025_11_25.ts +1 -0
  216. package/src/ai/internal/mcpSchema/v2024_11_05.ts +4 -2
  217. package/src/cluster/ClusterWorkflowEngine.ts +26 -5
  218. package/src/cluster/MessageStorage.ts +22 -5
  219. package/src/cluster/Sharding.ts +32 -28
  220. package/src/cluster/SqlMessageStorage.ts +45 -21
  221. package/src/cluster/SqlRunnerStorage.ts +85 -113
  222. package/src/cluster/internal/entityManager.ts +68 -80
  223. package/src/cluster/internal/interruptors.ts +13 -0
  224. package/src/cluster/internal/resourceMap.ts +55 -17
  225. package/src/cluster/internal/resourceRef.ts +189 -96
  226. package/src/http/HttpEffect.ts +15 -9
  227. package/src/http/HttpMiddleware.ts +8 -2
  228. package/src/http/HttpStaticServer.ts +16 -0
  229. package/src/http/HttpTraceContext.ts +8 -4
  230. package/src/http-api/HttpApiError.ts +1 -0
  231. package/src/internal/core.ts +56 -2
  232. package/src/internal/dateTime.ts +3 -1
  233. package/src/internal/effect.ts +146 -173
  234. package/src/internal/rcRef.ts +83 -33
  235. package/src/internal/schema/annotations.ts +0 -9
  236. package/src/internal/schema/codegen.ts +90 -43
  237. package/src/internal/schema/fromJsonSchemaDocument.ts +5 -3
  238. package/src/internal/schema/toCodeDocument.ts +10 -47
  239. package/src/internal/schema/toCodec.ts +3 -16
  240. package/src/internal/schema/toEquivalence.ts +4 -5
  241. package/src/internal/schema/toFormatter.ts +4 -6
  242. package/src/reactivity/Atom.ts +6 -2
  243. package/src/reactivity/AtomRpc.ts +5 -5
  244. package/src/rpc/Rpc.ts +2 -2
  245. package/src/rpc/RpcClient.ts +35 -32
  246. package/src/rpc/RpcMessage.ts +1 -1
  247. package/src/rpc/RpcServer.ts +2 -1
  248. package/src/rpc/Utils.ts +4 -10
  249. package/src/schema/SchemaCompiler/runtime.ts +17 -1
  250. package/src/testing/TestSchema.ts +315 -63
  251. package/src/testing/index.ts +1 -0
  252. package/src/workflow/DurableDeferred.ts +1 -1
  253. package/src/workflow/Workflow.ts +1 -1
  254. package/src/workflow/WorkflowEngine.ts +4 -0
@@ -299,7 +299,14 @@ export const make = Effect.gen(function*() {
299
299
  )
300
300
 
301
301
  if (Option.isNone(maybeSuspended)) return
302
- yield* sharding.reset(Snowflake.Snowflake(maybeSuspended.value.requestId))
302
+ if (
303
+ !(yield* sharding.reset(
304
+ Snowflake.Snowflake(maybeSuspended.value.requestId),
305
+ { expectedReplyId: Snowflake.Snowflake(maybeSuspended.value.id) }
306
+ ))
307
+ ) {
308
+ return yield* Effect.fail(new Error("Failed to reset workflow run"))
309
+ }
303
310
  yield* sharding.pollStorage
304
311
  })
305
312
 
@@ -404,7 +411,7 @@ export const make = Effect.gen(function*() {
404
411
  // Replays reuse the request id but track a fresh set of awaits.
405
412
  let currentRun: {
406
413
  readonly request: Entity.Request<any>
407
- readonly awaitedDeferreds: ReadonlySet<string>
414
+ readonly instance: WorkflowEngine.WorkflowInstance["Service"]
408
415
  } | undefined
409
416
  // Concurrent wakes share one wait for the current run to publish its reply.
410
417
  const resumeGate = Semaphore.makeUnsafe(1)
@@ -421,7 +428,7 @@ export const make = Effect.gen(function*() {
421
428
  return {
422
429
  run: (request: Entity.Request<any>) => {
423
430
  const instance = WorkflowEngine.WorkflowInstance.initial(workflow, executionId)
424
- currentRun = { request, awaitedDeferreds: instance.awaitedDeferreds }
431
+ currentRun = { request, instance }
425
432
  const parent = (request.payload as any)[payloadParentKey] as
426
433
  | { workflowName: string; executionId: string }
427
434
  | undefined
@@ -515,9 +522,13 @@ export const make = Effect.gen(function*() {
515
522
  // registered yet, a later await will see the result just stored.
516
523
  // Check when the wake runs and skip preemption too: interrupting
517
524
  // a later await would require waiting for its reply before reset.
525
+ // A run completing its own deferred also reads it from the cache
526
+ // when it awaits later, so that completion never wakes the run.
518
527
  const wake = Effect.suspend(() =>
519
- currentRun && !currentRun.awaitedDeferreds.has(payload.name)
520
- ? ensureSuccess(resume(workflow, executionId))
528
+ currentRun && !currentRun.instance.awaitedDeferreds.has(payload.name)
529
+ ? currentRun.instance.completedDeferreds.has(payload.name)
530
+ ? Effect.void
531
+ : ensureSuccess(resume(workflow, executionId))
521
532
  : deferredState.deferredDone(executionId, payload.name).pipe(Effect.andThen(resumeCurrentRun))
522
533
  )
523
534
  // An asynchronous reply releases the RPC concurrency permit while
@@ -696,6 +707,16 @@ export const make = Effect.gen(function*() {
696
707
 
697
708
  deferredDone: Effect.fnUntraced(
698
709
  function*({ deferredName, executionId, exit, workflowName }) {
710
+ const instance = yield* Effect.serviceOption(WorkflowEngine.WorkflowInstance)
711
+ // Activities run with their own instance, so their completions still
712
+ // wake a run that suspended before reaching the await. An interrupt
713
+ // must wake the run even when it signals itself.
714
+ if (
715
+ Option.isSome(instance) && instance.value.executionId === executionId &&
716
+ deferredName !== InterruptSignal.name
717
+ ) {
718
+ instance.value.completedDeferreds.add(deferredName)
719
+ }
699
720
  const workflow = workflows.get(workflowName)
700
721
  if (workflow) {
701
722
  return yield* Effect.orDie(sendDiscard({
@@ -74,9 +74,16 @@ export class MessageStorage extends Context.Service<MessageStorage, {
74
74
  ) => Effect.Effect<void, PersistenceError | MalformedMessage>
75
75
 
76
76
  /**
77
- * Clear the `Reply`s for the given request id.
77
+ * Clear replies for the given request id. When options.expectedReplyId is
78
+ * provided, clear only if it is still the latest reply at the storage boundary.
79
+ * Without it, clear unconditionally.
80
+ * Custom storage implementations must honor this condition to protect
81
+ * workflows from stale concurrent resumes.
78
82
  */
79
- readonly clearReplies: (requestId: Snowflake.Snowflake) => Effect.Effect<void, PersistenceError>
83
+ readonly clearReplies: (
84
+ requestId: Snowflake.Snowflake,
85
+ options?: { readonly expectedReplyId?: Snowflake.Snowflake | undefined }
86
+ ) => Effect.Effect<void, PersistenceError>
80
87
 
81
88
  /**
82
89
  * Retrieves the replies for the specified requests.
@@ -350,9 +357,16 @@ export type Encoded = {
350
357
  readonly saveReply: (reply: Reply.Encoded) => Effect.Effect<void, PersistenceError>
351
358
 
352
359
  /**
353
- * Remove the replies for the specified request.
360
+ * Remove the replies for the specified request. If options.expectedReplyId
361
+ * is provided, compare it with the latest reply atomically before clearing.
362
+ * Without it, clear unconditionally.
363
+ * Custom storage implementations must honor this condition to protect
364
+ * workflows from stale concurrent resumes.
354
365
  */
355
- readonly clearReplies: (requestId: Snowflake.Snowflake) => Effect.Effect<void, PersistenceError>
366
+ readonly clearReplies: (
367
+ requestId: Snowflake.Snowflake,
368
+ options?: { readonly expectedReplyId?: Snowflake.Snowflake | undefined }
369
+ ) => Effect.Effect<void, PersistenceError>
356
370
 
357
371
  /**
358
372
  * Retrieves the request id for the specified primary key.
@@ -1054,10 +1068,13 @@ export class MemoryDriver extends Context.Service<MemoryDriver>()("effect/cluste
1054
1068
  replyIds.add(reply.id)
1055
1069
  replyLatch.openUnsafe()
1056
1070
  }),
1057
- clearReplies: (id) =>
1071
+ clearReplies: (id, options) =>
1058
1072
  Effect.sync(() => {
1059
1073
  const entry = requests.get(String(id))
1060
1074
  if (!entry) return
1075
+ if (
1076
+ options?.expectedReplyId !== undefined && entry.replies.at(-1)?.id !== String(options.expectedReplyId)
1077
+ ) return
1061
1078
  entry.replies = []
1062
1079
  entry.lastReceivedChunk = undefined
1063
1080
  unprocessed.add(entry.envelope)
@@ -195,9 +195,13 @@ export class Sharding extends Context.Service<Sharding, {
195
195
  >
196
196
 
197
197
  /**
198
- * Reset the state of a message
198
+ * Reset the state of a message. When options.expectedReplyId is provided,
199
+ * reset only if it is still the latest reply. Otherwise reset unconditionally.
199
200
  */
200
- readonly reset: (requestId: Snowflake.Snowflake) => Effect.Effect<boolean>
201
+ readonly reset: (
202
+ requestId: Snowflake.Snowflake,
203
+ options?: { readonly expectedReplyId?: Snowflake.Snowflake | undefined }
204
+ ) => Effect.Effect<boolean>
201
205
 
202
206
  /**
203
207
  * Trigger a storage read, which will read all unprocessed messages.
@@ -545,30 +549,27 @@ const make = Effect.gen(function*() {
545
549
  return Effect.logInfo("Shard lock storage has recovered")
546
550
  })
547
551
 
548
- const refreshShardLocks = Effect.suspend(() =>
549
- runnerStorage.refresh(selfAddress, [
550
- ...acquiredShards,
551
- ...releasingShards
552
- ])
553
- ).pipe(
554
- Effect.flatMap((acquired) => {
555
- for (const shardId of acquiredShards) {
556
- if (!acquired.includes(shardId)) {
557
- MutableHashSet.remove(acquiredShards, shardId)
558
- MutableHashSet.add(releasingShards, shardId)
559
- }
552
+ const refreshShardLocks = Effect.gen(function*() {
553
+ const refreshed = [...acquiredShards, ...releasingShards]
554
+ const acquired = yield* runnerStorage.refresh(selfAddress, refreshed)
555
+ // Shards acquired during the request are absent from its response.
556
+ for (const shardId of refreshed) {
557
+ if (MutableHashSet.has(acquiredShards, shardId) && !acquired.includes(shardId)) {
558
+ MutableHashSet.remove(acquiredShards, shardId)
559
+ MutableHashSet.add(releasingShards, shardId)
560
560
  }
561
- for (let i = 0; i < acquired.length; i++) {
562
- const shardId = acquired[i]
563
- if (!MutableHashSet.has(selfShards, shardId)) {
564
- MutableHashSet.remove(acquiredShards, shardId)
565
- MutableHashSet.add(releasingShards, shardId)
566
- }
561
+ }
562
+ for (let i = 0; i < acquired.length; i++) {
563
+ const shardId = acquired[i]
564
+ if (!MutableHashSet.has(selfShards, shardId)) {
565
+ MutableHashSet.remove(acquiredShards, shardId)
566
+ MutableHashSet.add(releasingShards, shardId)
567
567
  }
568
- return MutableHashSet.size(releasingShards) > 0
569
- ? activeShardsLatch.open
570
- : Effect.void
571
- }),
568
+ }
569
+ if (MutableHashSet.size(releasingShards) > 0) {
570
+ yield* activeShardsLatch.open
571
+ }
572
+ }).pipe(
572
573
  Effect.retry({
573
574
  times: 5,
574
575
  schedule: Schedule.spaced(50)
@@ -1208,8 +1209,8 @@ const make = Effect.gen(function*() {
1208
1209
  )
1209
1210
  }
1210
1211
 
1211
- const reset: Sharding["Service"]["reset"] = (requestId) =>
1212
- Effect.matchCause(storage.clearReplies(requestId), {
1212
+ const reset: Sharding["Service"]["reset"] = (requestId, options) =>
1213
+ Effect.matchCause(storage.clearReplies(requestId, options), {
1213
1214
  onSuccess: () => true,
1214
1215
  onFailure: () => false
1215
1216
  })
@@ -1458,8 +1459,10 @@ const make = Effect.gen(function*() {
1458
1459
  // for durable messages, we ignore interrupts on shutdown or as a
1459
1460
  // result of a shard being resassigned
1460
1461
  const caller = Context.getOption(entry.context, CurrentAddress).valueOrUndefined
1462
+ const singletonShard = Context.getOption(entry.context, SingletonShardTag).valueOrUndefined
1461
1463
  const isTransientInterrupt = MutableRef.get(isShutdown) ||
1462
- (caller !== undefined && ActiveTeardown.isActive(caller))
1464
+ (caller !== undefined && ActiveTeardown.isActive(caller)) ||
1465
+ (singletonShard !== undefined && ActiveTeardown.isShardActive(singletonShard))
1463
1466
  if (isTransientInterrupt && Context.get(entry.message.annotations, Persisted)) {
1464
1467
  return Effect.void
1465
1468
  }
@@ -1588,7 +1591,7 @@ const make = Effect.gen(function*() {
1588
1591
  Effect.andThen(Effect.never),
1589
1592
  Effect.scoped,
1590
1593
  Effect.provideService(CurrentLogAnnotations, {}),
1591
- Effect.provideContext(services),
1594
+ Effect.provideContext(Context.add(services, SingletonShardTag, address.shardId)),
1592
1595
  Effect.orDie,
1593
1596
  Effect.interruptible
1594
1597
  ) as Effect.Effect<never>
@@ -1851,3 +1854,4 @@ export const layer: Layer.Layer<
1851
1854
  // Utilities
1852
1855
 
1853
1856
  const ClientAddressTag = Context.Service<EntityAddress>("effect/cluster/Sharding/ClientAddress")
1857
+ const SingletonShardTag = Context.Service<ShardId>("effect/cluster/Sharding/SingletonShard")
@@ -29,6 +29,7 @@ import * as Migrator from "../sql/Migrator.ts"
29
29
  import * as SqlClient from "../sql/SqlClient.ts"
30
30
  import type { Row } from "../sql/SqlConnection.ts"
31
31
  import { isSqlError, type SqlError } from "../sql/SqlError.ts"
32
+ import type { Fragment } from "../sql/Statement.ts"
32
33
  import { PersistenceError } from "./ClusterError.ts"
33
34
  import type * as EntityAddress from "./EntityAddress.ts"
34
35
  import type * as Envelope from "./Envelope.ts"
@@ -494,6 +495,28 @@ export const makeEncoded: (options?: {
494
495
  ? Effect.succeed([])
495
496
  : getUnprocessedMessagesForDialect(shardIds, now, unprocessedFilters(options))
496
497
 
498
+ const resetUnprocessed = sql.onDialectOrElse({
499
+ // Match the claim query's lock order to avoid deadlocks during resets.
500
+ pg: () => (filter: Fragment) =>
501
+ sql`
502
+ WITH messages AS (
503
+ SELECT id FROM ${messagesTableSql}
504
+ WHERE processed = ${sqlFalse} AND ${filter}
505
+ ORDER BY rowid ASC
506
+ FOR UPDATE
507
+ )
508
+ UPDATE ${messagesTableSql}
509
+ SET last_read = NULL
510
+ WHERE id IN (SELECT id FROM messages)
511
+ `,
512
+ orElse: () => (filter: Fragment) =>
513
+ sql`
514
+ UPDATE ${messagesTableSql}
515
+ SET last_read = NULL
516
+ WHERE processed = ${sqlFalse} AND ${filter}
517
+ `
518
+ })
519
+
497
520
  const encoded: MessageStorage.Encoded = {
498
521
  saveEnvelope: ({ deliverAt, envelope, primaryKey }) =>
499
522
  Effect.suspend(() => {
@@ -576,14 +599,25 @@ export const makeEncoded: (options?: {
576
599
  ),
577
600
 
578
601
  clearReplies: Effect.fnUntraced(
579
- function*(requestId) {
580
- yield* sql`DELETE FROM ${repliesTableSql} WHERE request_id = ${String(requestId)} AND kind = 0`
581
- yield* sql`DELETE FROM ${messagesTableSql} WHERE request_id = ${
582
- String(requestId)
583
- } AND kind = ${messageKindInterrupt}`
584
- yield* sql`UPDATE ${messagesTableSql} SET processed = ${sqlFalse}, last_reply_id = NULL, last_read = NULL WHERE request_id = ${
585
- String(requestId)
586
- }`
602
+ function*(requestId, options) {
603
+ const expectedReplyId = options?.expectedReplyId
604
+ if (expectedReplyId === undefined) {
605
+ yield* sql`DELETE FROM ${repliesTableSql} WHERE request_id = ${String(requestId)} AND kind = 0`
606
+ yield* sql`DELETE FROM ${messagesTableSql} WHERE request_id = ${
607
+ String(requestId)
608
+ } AND kind = ${messageKindInterrupt}`
609
+ yield* sql`UPDATE ${messagesTableSql} SET processed = ${sqlFalse}, last_reply_id = NULL, last_read = NULL WHERE request_id = ${
610
+ String(requestId)
611
+ }`
612
+ return
613
+ }
614
+ const id = String(requestId)
615
+ const expected = String(expectedReplyId)
616
+ // Check at the storage boundary, not at the earlier workflow read.
617
+ yield* sql`DELETE FROM ${repliesTableSql} WHERE request_id = ${id} AND kind = 0
618
+ AND EXISTS (SELECT 1 FROM ${messagesTableSql} WHERE id = ${id} AND last_reply_id = ${expected})`
619
+ yield* sql`UPDATE ${messagesTableSql} SET processed = ${sqlFalse}, last_reply_id = NULL, last_read = NULL
620
+ WHERE request_id = ${id} AND last_reply_id = ${expected}`
587
621
  },
588
622
  sql.withTransaction,
589
623
  PersistenceError.refail,
@@ -705,11 +739,7 @@ export const makeEncoded: (options?: {
705
739
  resetAddresses: (addresses) =>
706
740
  addresses.length === 0
707
741
  ? Effect.void
708
- : sql`
709
- UPDATE ${messagesTableSql}
710
- SET last_read = NULL
711
- WHERE processed = ${sqlFalse}
712
- AND (${
742
+ : resetUnprocessed(sql`(${
713
743
  sql.or(
714
744
  groupAddresses(addresses).map(
715
745
  (group) =>
@@ -720,8 +750,7 @@ export const makeEncoded: (options?: {
720
750
  ])
721
751
  )
722
752
  )
723
- })
724
- `.pipe(
753
+ })`).pipe(
725
754
  Effect.asVoid,
726
755
  PersistenceError.refail,
727
756
  withTracerDisabled
@@ -750,12 +779,7 @@ export const makeEncoded: (options?: {
750
779
  ),
751
780
 
752
781
  resetShards: (shardIds) =>
753
- sql`
754
- UPDATE ${messagesTableSql}
755
- SET last_read = NULL
756
- WHERE processed = ${sqlFalse}
757
- AND shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))})
758
- `.pipe(
782
+ resetUnprocessed(sql`shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))})`).pipe(
759
783
  Effect.asVoid,
760
784
  PersistenceError.refail,
761
785
  withTracerDisabled
@@ -11,13 +11,14 @@
11
11
  * @since 4.0.0
12
12
  */
13
13
  import * as Arr from "../Array.ts"
14
+ import * as Cause from "../Cause.ts"
14
15
  import * as Duration from "../Duration.ts"
15
16
  import * as Effect from "../Effect.ts"
16
- import * as Exit from "../Exit.ts"
17
17
  import * as Fiber from "../Fiber.ts"
18
18
  import * as Layer from "../Layer.ts"
19
19
  import * as Scope from "../Scope.ts"
20
20
  import * as SqlClient from "../sql/SqlClient.ts"
21
+ import type { Connection } from "../sql/SqlConnection.ts"
21
22
  import type { SqlError } from "../sql/SqlError.ts"
22
23
  import type * as Statement from "../sql/Statement.ts"
23
24
  import { PersistenceError } from "./ClusterError.ts"
@@ -156,47 +157,28 @@ export const make = Effect.fnUntraced(function*(options: {
156
157
  )
157
158
  })
158
159
 
159
- let lockConnRebuilding = false
160
- // Incremented every time the reserved connection is replaced, so failures
161
- // from operations that ran on an already replaced connection do not trigger
162
- // another rebuild.
163
- let lockConnGeneration = 0
164
- const rebuildLockConn = (generation: number) => {
165
- if (
166
- !lockConn ||
167
- lockConnRebuilding ||
168
- generation !== lockConnGeneration ||
169
- lockConn.state.current._tag === "Closed"
170
- ) return Effect.void
171
- lockConnRebuilding = true
172
- // The rebuild starts by closing the previous scope, releasing the
173
- // unresponsive connection back to the pool. Bound it with `withDeadline`
174
- // so a release that never completes cannot leave `lockConnRebuilding` set
175
- // forever, which would disable every subsequent rebuild.
176
- return withDeadline(lockConn.rebuildUnsafe()).pipe(
160
+ const rebuildLockConn = (from?: readonly [Connection, number]) => {
161
+ const rebuild = lockConn?.rebuildUnsafe({ from })
162
+ if (!rebuild) return Effect.void
163
+ // Bound release so a stalled connection cannot block subsequent rebuilds.
164
+ return withDeadline(rebuild).pipe(
177
165
  Effect.exit,
178
- Effect.tap((exit) =>
179
- Effect.sync(() => {
180
- if (Exit.isSuccess(exit) && lockConn.state.current._tag === "Acquired") {
181
- lockConnGeneration++
182
- }
183
- })
184
- ),
185
- Effect.ensuring(Effect.sync(() => {
186
- lockConnRebuilding = false
187
- })),
188
166
  Effect.forkIn(layerScope, { startImmediately: true }),
189
167
  Effect.asVoid
190
168
  )
191
169
  }
192
- // Rebuild the reserved connection when `effect` fails on it. Failures keep
193
- // scheduling rebuilds, so a rebuilt connection that is also unresponsive is
194
- // replaced again.
195
- const onErrorRebuildLockConn = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
196
- Effect.suspend(() => {
197
- const generation = lockConnGeneration
198
- return Effect.onError(effect, () => rebuildLockConn(generation))
199
- })
170
+ // SQL failures replace the held connection; deadlines are handled by withLockOperationDeadline.
171
+ const useLockConn = <A, E, R>(
172
+ f: (conn: Connection, pid: number) => Effect.Effect<A, E, R>
173
+ ): Effect.Effect<A, E, R> =>
174
+ Effect.flatMap(
175
+ lockConn!.await,
176
+ (held) =>
177
+ Effect.onError(
178
+ f(held[0], held[1]),
179
+ (cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : rebuildLockConn(held)
180
+ )
181
+ )
200
182
 
201
183
  const runnersTable = table("runners")
202
184
  const runnersTableSql = sql(runnersTable)
@@ -378,30 +360,21 @@ export const make = Effect.fnUntraced(function*(options: {
378
360
  const execWithLockConn = <A>(effect: Statement.Statement<A>): Effect.Effect<unknown, SqlError> => {
379
361
  if (!lockConn) return effect
380
362
  const [query, params] = effect.compile()
381
- return lockConn.await.pipe(
382
- Effect.flatMap(([conn]) => conn.executeRaw(query, params)),
383
- onErrorRebuildLockConn
384
- )
363
+ return useLockConn((conn) => conn.executeRaw(query, params))
385
364
  }
386
365
  const execWithLockConnUnprepared = <A>(
387
366
  effect: Statement.Statement<A>
388
367
  ): Effect.Effect<ReadonlyArray<ReadonlyArray<any>>, SqlError> => {
389
368
  if (!lockConn) return effect.values
390
369
  const [query, params] = effect.compile()
391
- return lockConn.await.pipe(
392
- Effect.flatMap(([conn]) => conn.executeUnprepared(query, params, undefined)),
393
- onErrorRebuildLockConn
394
- )
370
+ return useLockConn((conn) => conn.executeUnprepared(query, params, undefined))
395
371
  }
396
372
  const execWithLockConnValues = <A>(
397
373
  effect: Statement.Statement<A>
398
374
  ): Effect.Effect<ReadonlyArray<ReadonlyArray<any>>, SqlError> => {
399
375
  if (!lockConn) return effect.values
400
376
  const [query, params] = effect.compile()
401
- return lockConn.await.pipe(
402
- Effect.flatMap(([conn]) => conn.executeValues(query, params)),
403
- onErrorRebuildLockConn
404
- )
377
+ return useLockConn((conn) => conn.executeValues(query, params))
405
378
  }
406
379
 
407
380
  const acquireLock = sql.onDialectOrElse({
@@ -425,33 +398,33 @@ export const make = Effect.fnUntraced(function*(options: {
425
398
  )
426
399
  }
427
400
  }
428
- return Effect.fnUntraced(function*(_address: string, shardIds: ReadonlyArray<string>) {
429
- const [conn, pid] = yield* lockConn!.await
430
- const acquiredShardIds: Array<string> = []
431
- const toAcquire = new Map(shardIds.map((shardId) => [lockNumbers.get(shardId)!, shardId]))
432
- const takenLocks = yield* conn.executeValues(
433
- `SELECT objid FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objsubid = 2 AND pid = ${pid} ORDER BY objid`,
434
- []
435
- )
436
- for (let i = 0; i < takenLocks.length; i++) {
437
- const lockNum = takenLocks[i][0] as number
438
- const shardId = toAcquire.get(lockNum)
439
- if (shardId === undefined) continue
440
- acquiredShardIds.push(shardId)
441
- toAcquire.delete(lockNum)
442
- }
443
- if (toAcquire.size === 0) {
444
- return acquiredShardIds
445
- }
446
- const rows = yield* conn.executeUnprepared(`SELECT ${pgLocks(toAcquire)}`, [], undefined)
447
- const results = rows[0] as Record<string, boolean>
448
- for (const shardId in results) {
449
- if (results[shardId]) {
401
+ return (_address: string, shardIds: ReadonlyArray<string>) =>
402
+ useLockConn(Effect.fnUntraced(function*(conn, pid) {
403
+ const acquiredShardIds: Array<string> = []
404
+ const toAcquire = new Map(shardIds.map((shardId) => [lockNumbers.get(shardId)!, shardId]))
405
+ const takenLocks = yield* conn.executeValues(
406
+ `SELECT objid FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objsubid = 2 AND pid = ${pid} ORDER BY objid`,
407
+ []
408
+ )
409
+ for (let i = 0; i < takenLocks.length; i++) {
410
+ const lockNum = takenLocks[i][0] as number
411
+ const shardId = toAcquire.get(lockNum)
412
+ if (shardId === undefined) continue
450
413
  acquiredShardIds.push(shardId)
414
+ toAcquire.delete(lockNum)
451
415
  }
452
- }
453
- return acquiredShardIds
454
- }, onErrorRebuildLockConn)
416
+ if (toAcquire.size === 0) {
417
+ return acquiredShardIds
418
+ }
419
+ const rows = yield* conn.executeUnprepared(`SELECT ${pgLocks(toAcquire)}`, [], undefined)
420
+ const results = rows[0] as Record<string, boolean>
421
+ for (const shardId in results) {
422
+ if (results[shardId]) {
423
+ acquiredShardIds.push(shardId)
424
+ }
425
+ }
426
+ return acquiredShardIds
427
+ }))
455
428
  },
456
429
 
457
430
  mysql: () => {
@@ -471,31 +444,31 @@ export const make = Effect.fnUntraced(function*(options: {
471
444
  )
472
445
  }
473
446
  }
474
- return Effect.fnUntraced(function*(_address: string, shardIds: ReadonlyArray<string>) {
475
- const [conn, pid] = yield* lockConn!.await
476
- const takenLocks = (yield* conn.executeValues(`SELECT ${allMySqlTakenLocks}`, []))[0] as Array<number | null>
477
- const acquiredShardIds: Array<string> = []
478
- const toAcquire: Array<string> = []
479
- for (let i = 0; i < shardIds.length; i++) {
480
- const shardId = shardIds[i]
481
- const lockTakenBy = takenLocks[shardIdsIndex.get(shardId)!]
482
- if (lockTakenBy === pid) {
483
- acquiredShardIds.push(shardId)
484
- } else if (shardIds.includes(shardId)) {
485
- toAcquire.push(shardId)
447
+ return (_address: string, shardIds: ReadonlyArray<string>) =>
448
+ useLockConn(Effect.fnUntraced(function*(conn, pid) {
449
+ const takenLocks = (yield* conn.executeValues(`SELECT ${allMySqlTakenLocks}`, []))[0] as Array<number | null>
450
+ const acquiredShardIds: Array<string> = []
451
+ const toAcquire: Array<string> = []
452
+ for (let i = 0; i < shardIds.length; i++) {
453
+ const shardId = shardIds[i]
454
+ const lockTakenBy = takenLocks[shardIdsIndex.get(shardId)!]
455
+ if (lockTakenBy === pid) {
456
+ acquiredShardIds.push(shardId)
457
+ } else if (shardIds.includes(shardId)) {
458
+ toAcquire.push(shardId)
459
+ }
486
460
  }
487
- }
488
- if (toAcquire.length === 0) {
489
- return acquiredShardIds
490
- }
491
- const results = (yield* conn.executeValues(`SELECT ${mysqlLocks(toAcquire)}`, []))[0] as Array<number>
492
- for (let i = 0; i < results.length; i++) {
493
- if (results[i] === 1) {
494
- acquiredShardIds.push(toAcquire[i])
461
+ if (toAcquire.length === 0) {
462
+ return acquiredShardIds
495
463
  }
496
- }
497
- return acquiredShardIds
498
- }, onErrorRebuildLockConn)
464
+ const results = (yield* conn.executeValues(`SELECT ${mysqlLocks(toAcquire)}`, []))[0] as Array<number>
465
+ for (let i = 0; i < results.length; i++) {
466
+ if (results[i] === 1) {
467
+ acquiredShardIds.push(toAcquire[i])
468
+ }
469
+ }
470
+ return acquiredShardIds
471
+ }))
499
472
  },
500
473
 
501
474
  mssql: () => (address: string, shardIds: ReadonlyArray<string>) => {
@@ -647,8 +620,16 @@ export const make = Effect.fnUntraced(function*(options: {
647
620
  `.pipe(execWithLockConnValues, Effect.map((rows) => rows.map((row) => row[0] as string)))
648
621
  })
649
622
 
623
+ // On failure, replace the connection ready at entry. If none was ready,
624
+ // retry only a failed rebuild, leaving pending or newly ready connections alone.
650
625
  const withLockOperationDeadline = <A, E, R>(operation: Effect.Effect<A, E, R>) =>
651
- onErrorRebuildLockConn(withDeadline(operation))
626
+ Effect.suspend(() => {
627
+ const ready = lockConn?.getUnsafe()
628
+ return Effect.onError(withDeadline(operation), () => {
629
+ if (ready !== undefined) return rebuildLockConn(ready)
630
+ return lockConn?.getUnsafe() === undefined ? rebuildLockConn() : Effect.void
631
+ })
632
+ })
652
633
 
653
634
  const releaseShard = sql.onDialectOrElse({
654
635
  pg: () => {
@@ -656,11 +637,10 @@ export const make = Effect.fnUntraced(function*(options: {
656
637
  return (address: string, shardId: string) =>
657
638
  sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}`.pipe(execWithLockConn)
658
639
  }
659
- return Effect.fnUntraced(
660
- function*(_address, shardId) {
640
+ return (_address: string, shardId: string) =>
641
+ useLockConn(Effect.fnUntraced(function*(conn) {
661
642
  const lockNum = lockNumbers.get(shardId)!
662
643
  for (let i = 0; i < 5; i++) {
663
- const [conn] = yield* lockConn!.await
664
644
  yield* conn.executeRaw(`SELECT pg_advisory_unlock(${pgLockNamespace}, ${lockNum})`, [])
665
645
  const takenLocks = yield* conn.executeValues(
666
646
  `SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objid = ${lockNum} AND objsubid = 2 AND pid = pg_backend_pid()`,
@@ -668,23 +648,18 @@ export const make = Effect.fnUntraced(function*(options: {
668
648
  )
669
649
  if (takenLocks.length === 0) return
670
650
  }
671
- const [conn] = yield* lockConn!.await
672
651
  yield* conn.executeRaw(`SELECT pg_advisory_unlock_all()`, [])
673
- },
674
- onErrorRebuildLockConn,
675
- Effect.asVoid
676
- )
652
+ }))
677
653
  },
678
654
  mysql: () => {
679
655
  if (disableAdvisoryLocks) {
680
656
  return (address: string, shardId: string) =>
681
657
  sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}`.pipe(execWithLockConn)
682
658
  }
683
- return Effect.fnUntraced(
684
- function*(_address, shardId) {
659
+ return (_address: string, shardId: string) =>
660
+ useLockConn(Effect.fnUntraced(function*(conn, pid) {
685
661
  const lockName = lockNames.get(shardId)!
686
662
  while (true) {
687
- const [conn, pid] = yield* lockConn!.await
688
663
  yield* conn.executeRaw(`SELECT RELEASE_LOCK('${lockName}')`, [])
689
664
  const takenLocks = yield* conn.executeValues(
690
665
  `SELECT IS_USED_LOCK('${lockName}')`,
@@ -692,10 +667,7 @@ export const make = Effect.fnUntraced(function*(options: {
692
667
  )
693
668
  if (takenLocks.length === 0 || takenLocks[0][0] !== pid) return
694
669
  }
695
- },
696
- onErrorRebuildLockConn,
697
- Effect.asVoid
698
- )
670
+ }))
699
671
  },
700
672
  orElse: () => (address: string, shardId: string) =>
701
673
  sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}`