effect 4.0.2 → 4.0.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 (243) hide show
  1. package/dist/Context.js +3 -3
  2. package/dist/Context.js.map +1 -1
  3. package/dist/Crypto.d.ts +33 -5
  4. package/dist/Crypto.d.ts.map +1 -1
  5. package/dist/Crypto.js +8 -2
  6. package/dist/Crypto.js.map +1 -1
  7. package/dist/DateTime.d.ts +9 -6
  8. package/dist/DateTime.d.ts.map +1 -1
  9. package/dist/DateTime.js +9 -6
  10. package/dist/DateTime.js.map +1 -1
  11. package/dist/Duration.d.ts +6 -6
  12. package/dist/Duration.d.ts.map +1 -1
  13. package/dist/Duration.js +27 -8
  14. package/dist/Duration.js.map +1 -1
  15. package/dist/FileSystem.d.ts +44 -2
  16. package/dist/FileSystem.d.ts.map +1 -1
  17. package/dist/FileSystem.js.map +1 -1
  18. package/dist/Schema.d.ts +473 -1
  19. package/dist/Schema.d.ts.map +1 -1
  20. package/dist/Schema.js +355 -4
  21. package/dist/Schema.js.map +1 -1
  22. package/dist/Stdio.d.ts +7 -1
  23. package/dist/Stdio.d.ts.map +1 -1
  24. package/dist/Stdio.js +1 -0
  25. package/dist/Stdio.js.map +1 -1
  26. package/dist/Stream.d.ts +11 -11
  27. package/dist/Stream.js +5 -5
  28. package/dist/ai/McpServer.d.ts +6 -0
  29. package/dist/ai/McpServer.d.ts.map +1 -1
  30. package/dist/ai/McpServer.js +65 -23
  31. package/dist/ai/McpServer.js.map +1 -1
  32. package/dist/ai/internal/mcpProtocol/v2026_07_28.js +5 -0
  33. package/dist/ai/internal/mcpProtocol/v2026_07_28.js.map +1 -1
  34. package/dist/ai/internal/mcpProtocol.js.map +1 -1
  35. package/dist/ai/internal/mcpRuntime.js +1 -0
  36. package/dist/ai/internal/mcpRuntime.js.map +1 -1
  37. package/dist/cli/CliOutput.d.ts +27 -0
  38. package/dist/cli/CliOutput.d.ts.map +1 -1
  39. package/dist/cli/CliOutput.js +33 -10
  40. package/dist/cli/CliOutput.js.map +1 -1
  41. package/dist/cli/Command.d.ts.map +1 -1
  42. package/dist/cli/Command.js +6 -3
  43. package/dist/cli/Command.js.map +1 -1
  44. package/dist/cli/Completions.d.ts +3 -3
  45. package/dist/cli/Completions.d.ts.map +1 -1
  46. package/dist/cli/Completions.js +8 -5
  47. package/dist/cli/Completions.js.map +1 -1
  48. package/dist/cli/GlobalFlag.d.ts +5 -4
  49. package/dist/cli/GlobalFlag.d.ts.map +1 -1
  50. package/dist/cli/GlobalFlag.js +3 -3
  51. package/dist/cli/GlobalFlag.js.map +1 -1
  52. package/dist/cli/Prompt.d.ts +17 -1
  53. package/dist/cli/Prompt.d.ts.map +1 -1
  54. package/dist/cli/Prompt.js +119 -114
  55. package/dist/cli/Prompt.js.map +1 -1
  56. package/dist/cli/internal/ansi.d.ts +0 -1
  57. package/dist/cli/internal/ansi.d.ts.map +1 -1
  58. package/dist/cli/internal/ansi.js +20 -3
  59. package/dist/cli/internal/ansi.js.map +1 -1
  60. package/dist/cli/internal/completions/powershell.d.ts +2 -0
  61. package/dist/cli/internal/completions/powershell.d.ts.map +1 -0
  62. package/dist/cli/internal/completions/powershell.js +273 -0
  63. package/dist/cli/internal/completions/powershell.js.map +1 -0
  64. package/dist/cli/internal/completions/zsh.js +3 -2
  65. package/dist/cli/internal/completions/zsh.js.map +1 -1
  66. package/dist/cli/internal/wizard.d.ts +3 -3
  67. package/dist/cli/internal/wizard.d.ts.map +1 -1
  68. package/dist/cli/internal/wizard.js +21 -18
  69. package/dist/cli/internal/wizard.js.map +1 -1
  70. package/dist/cluster/ClusterSchema.d.ts +28 -0
  71. package/dist/cluster/ClusterSchema.d.ts.map +1 -1
  72. package/dist/cluster/ClusterSchema.js +30 -0
  73. package/dist/cluster/ClusterSchema.js.map +1 -1
  74. package/dist/cluster/ClusterWorkflowEngine.d.ts.map +1 -1
  75. package/dist/cluster/ClusterWorkflowEngine.js +10 -18
  76. package/dist/cluster/ClusterWorkflowEngine.js.map +1 -1
  77. package/dist/cluster/MessageStorage.d.ts +18 -3
  78. package/dist/cluster/MessageStorage.d.ts.map +1 -1
  79. package/dist/cluster/MessageStorage.js +31 -11
  80. package/dist/cluster/MessageStorage.js.map +1 -1
  81. package/dist/cluster/Runners.d.ts.map +1 -1
  82. package/dist/cluster/Runners.js +8 -2
  83. package/dist/cluster/Runners.js.map +1 -1
  84. package/dist/cluster/Sharding.d.ts.map +1 -1
  85. package/dist/cluster/Sharding.js +181 -62
  86. package/dist/cluster/Sharding.js.map +1 -1
  87. package/dist/cluster/SqlMessageStorage.d.ts +59 -19
  88. package/dist/cluster/SqlMessageStorage.d.ts.map +1 -1
  89. package/dist/cluster/SqlMessageStorage.js +84 -55
  90. package/dist/cluster/SqlMessageStorage.js.map +1 -1
  91. package/dist/cluster/SqlRunnerStorage.d.ts +68 -19
  92. package/dist/cluster/SqlRunnerStorage.d.ts.map +1 -1
  93. package/dist/cluster/SqlRunnerStorage.js +191 -113
  94. package/dist/cluster/SqlRunnerStorage.js.map +1 -1
  95. package/dist/cluster/internal/entityManager.js +104 -37
  96. package/dist/cluster/internal/entityManager.js.map +1 -1
  97. package/dist/http/FindMyWay/internal/router.js +3 -6
  98. package/dist/http/FindMyWay/internal/router.js.map +1 -1
  99. package/dist/http/HttpClient.d.ts +6 -0
  100. package/dist/http/HttpClient.d.ts.map +1 -1
  101. package/dist/http/HttpClient.js +39 -2
  102. package/dist/http/HttpClient.js.map +1 -1
  103. package/dist/http/HttpTraceContext.d.ts.map +1 -1
  104. package/dist/http/HttpTraceContext.js +4 -2
  105. package/dist/http/HttpTraceContext.js.map +1 -1
  106. package/dist/internal/effect.js +12 -7
  107. package/dist/internal/effect.js.map +1 -1
  108. package/dist/internal/version.js +1 -1
  109. package/dist/net/AddressResolver.d.ts +145 -0
  110. package/dist/net/AddressResolver.d.ts.map +1 -0
  111. package/dist/net/AddressResolver.js +114 -0
  112. package/dist/net/AddressResolver.js.map +1 -0
  113. package/dist/net/Dns.d.ts +596 -0
  114. package/dist/net/Dns.d.ts.map +1 -0
  115. package/dist/net/Dns.js +549 -0
  116. package/dist/net/Dns.js.map +1 -0
  117. package/dist/net/Host.d.ts +320 -0
  118. package/dist/net/Host.d.ts.map +1 -0
  119. package/dist/net/Host.js +342 -0
  120. package/dist/net/Host.js.map +1 -0
  121. package/dist/net/IpInterface.d.ts +76 -3
  122. package/dist/net/IpInterface.d.ts.map +1 -1
  123. package/dist/net/IpInterface.js +53 -4
  124. package/dist/net/IpInterface.js.map +1 -1
  125. package/dist/net/IpNetwork.d.ts +82 -8
  126. package/dist/net/IpNetwork.d.ts.map +1 -1
  127. package/dist/net/IpNetwork.js +60 -4
  128. package/dist/net/IpNetwork.js.map +1 -1
  129. package/dist/net/NetAddress.d.ts +388 -35
  130. package/dist/net/NetAddress.d.ts.map +1 -1
  131. package/dist/net/NetAddress.js +350 -34
  132. package/dist/net/NetAddress.js.map +1 -1
  133. package/dist/net/index.d.ts +15 -0
  134. package/dist/net/index.d.ts.map +1 -1
  135. package/dist/net/index.js +15 -0
  136. package/dist/net/index.js.map +1 -1
  137. package/dist/observability/OtlpMetrics.d.ts.map +1 -1
  138. package/dist/observability/OtlpMetrics.js +15 -21
  139. package/dist/observability/OtlpMetrics.js.map +1 -1
  140. package/dist/observability/PrometheusMetrics.js +1 -0
  141. package/dist/observability/PrometheusMetrics.js.map +1 -1
  142. package/dist/persistence/PersistedQueue.d.ts +32 -3
  143. package/dist/persistence/PersistedQueue.d.ts.map +1 -1
  144. package/dist/persistence/PersistedQueue.js +75 -16
  145. package/dist/persistence/PersistedQueue.js.map +1 -1
  146. package/dist/rpc/RpcClient.d.ts +22 -6
  147. package/dist/rpc/RpcClient.d.ts.map +1 -1
  148. package/dist/rpc/RpcClient.js +12 -5
  149. package/dist/rpc/RpcClient.js.map +1 -1
  150. package/dist/rpc/RpcServer.d.ts +6 -0
  151. package/dist/rpc/RpcServer.d.ts.map +1 -1
  152. package/dist/rpc/RpcServer.js +42 -20
  153. package/dist/rpc/RpcServer.js.map +1 -1
  154. package/dist/socket/DatagramSocket.d.ts +456 -0
  155. package/dist/socket/DatagramSocket.d.ts.map +1 -0
  156. package/dist/socket/DatagramSocket.js +535 -0
  157. package/dist/socket/DatagramSocket.js.map +1 -0
  158. package/dist/socket/Socket.d.ts +14 -1
  159. package/dist/socket/Socket.d.ts.map +1 -1
  160. package/dist/socket/Socket.js +9 -1
  161. package/dist/socket/Socket.js.map +1 -1
  162. package/dist/socket/index.d.ts +5 -0
  163. package/dist/socket/index.d.ts.map +1 -1
  164. package/dist/socket/index.js +5 -0
  165. package/dist/socket/index.js.map +1 -1
  166. package/dist/sql/Migrator.d.ts.map +1 -1
  167. package/dist/sql/Migrator.js +7 -2
  168. package/dist/sql/Migrator.js.map +1 -1
  169. package/dist/sql/SqlClient.d.ts +7 -1
  170. package/dist/sql/SqlClient.d.ts.map +1 -1
  171. package/dist/sql/SqlClient.js +31 -6
  172. package/dist/sql/SqlClient.js.map +1 -1
  173. package/dist/testing/TestCrypto.d.ts +73 -0
  174. package/dist/testing/TestCrypto.d.ts.map +1 -0
  175. package/dist/testing/TestCrypto.js +87 -0
  176. package/dist/testing/TestCrypto.js.map +1 -0
  177. package/dist/testing/index.d.ts +5 -0
  178. package/dist/testing/index.d.ts.map +1 -1
  179. package/dist/testing/index.js +5 -0
  180. package/dist/testing/index.js.map +1 -1
  181. package/dist/workflow/DurableDeferred.d.ts +54 -0
  182. package/dist/workflow/DurableDeferred.d.ts.map +1 -1
  183. package/dist/workflow/DurableDeferred.js +78 -49
  184. package/dist/workflow/DurableDeferred.js.map +1 -1
  185. package/dist/workflow/WorkflowEngine.d.ts.map +1 -1
  186. package/dist/workflow/WorkflowEngine.js +2 -1
  187. package/dist/workflow/WorkflowEngine.js.map +1 -1
  188. package/package.json +1 -1
  189. package/src/Context.ts +3 -3
  190. package/src/Crypto.ts +43 -9
  191. package/src/DateTime.ts +9 -6
  192. package/src/Duration.ts +36 -16
  193. package/src/FileSystem.ts +48 -2
  194. package/src/Schema.ts +669 -5
  195. package/src/Stdio.ts +9 -2
  196. package/src/Stream.ts +11 -11
  197. package/src/ai/McpServer.ts +63 -23
  198. package/src/ai/internal/mcpProtocol/v2026_07_28.ts +5 -0
  199. package/src/ai/internal/mcpProtocol.ts +3 -0
  200. package/src/ai/internal/mcpRuntime.ts +2 -0
  201. package/src/cli/CliOutput.ts +33 -16
  202. package/src/cli/Command.ts +4 -3
  203. package/src/cli/Completions.ts +9 -6
  204. package/src/cli/GlobalFlag.ts +19 -11
  205. package/src/cli/Prompt.ts +159 -149
  206. package/src/cli/internal/ansi.ts +26 -3
  207. package/src/cli/internal/completions/powershell.ts +322 -0
  208. package/src/cli/internal/completions/zsh.ts +4 -2
  209. package/src/cli/internal/wizard.ts +34 -26
  210. package/src/cluster/ClusterSchema.ts +32 -0
  211. package/src/cluster/ClusterWorkflowEngine.ts +13 -24
  212. package/src/cluster/MessageStorage.ts +45 -14
  213. package/src/cluster/Runners.ts +8 -2
  214. package/src/cluster/Sharding.ts +207 -49
  215. package/src/cluster/SqlMessageStorage.ts +118 -84
  216. package/src/cluster/SqlRunnerStorage.ts +224 -128
  217. package/src/cluster/internal/entityManager.ts +151 -55
  218. package/src/http/FindMyWay/internal/router.ts +4 -7
  219. package/src/http/HttpClient.ts +56 -2
  220. package/src/http/HttpTraceContext.ts +8 -2
  221. package/src/internal/effect.ts +15 -13
  222. package/src/internal/version.ts +1 -1
  223. package/src/net/AddressResolver.ts +222 -0
  224. package/src/net/Dns.ts +1035 -0
  225. package/src/net/Host.ts +472 -0
  226. package/src/net/IpInterface.ts +117 -7
  227. package/src/net/IpNetwork.ts +116 -17
  228. package/src/net/NetAddress.ts +611 -76
  229. package/src/net/index.ts +18 -0
  230. package/src/observability/OtlpMetrics.ts +12 -23
  231. package/src/observability/PrometheusMetrics.ts +1 -0
  232. package/src/persistence/PersistedQueue.ts +146 -53
  233. package/src/rpc/RpcClient.ts +41 -19
  234. package/src/rpc/RpcServer.ts +60 -25
  235. package/src/socket/DatagramSocket.ts +919 -0
  236. package/src/socket/Socket.ts +22 -2
  237. package/src/socket/index.ts +6 -0
  238. package/src/sql/Migrator.ts +21 -5
  239. package/src/sql/SqlClient.ts +39 -15
  240. package/src/testing/TestCrypto.ts +94 -0
  241. package/src/testing/index.ts +6 -0
  242. package/src/workflow/DurableDeferred.ts +123 -48
  243. package/src/workflow/WorkflowEngine.ts +2 -1
@@ -207,7 +207,12 @@ export class MessageStorage extends Context.Service<MessageStorage, {
207
207
  ) => Effect.Effect<void, PersistenceError>
208
208
 
209
209
  /**
210
- * Used to wrap requests with transactions.
210
+ * Wraps requests in storage transactions.
211
+ *
212
+ * `WithExit` replies saved in a transaction notify callers after the outermost
213
+ * commit; stream chunks notify immediately. Replies saved in a failed
214
+ * transaction must be discarded. The wrapper may fail only with the wrapped
215
+ * effect's own failure or a defect.
211
216
  */
212
217
  readonly withTransaction: <A, E, R>(
213
218
  effect: Effect.Effect<A, E, R>
@@ -513,6 +518,10 @@ export type EncodedRepliesOptions<A> = {
513
518
  readonly cursor: Option.Option<A>
514
519
  }
515
520
 
521
+ const TransactionNotifications = Context.Reference<
522
+ Array<Effect.Effect<void, PersistenceError | MalformedMessage>> | undefined
523
+ >("effect/cluster/MessageStorage/TransactionNotifications", { defaultValue: () => undefined })
524
+
516
525
  /**
517
526
  * Wraps a concrete message storage implementation with reply-handler management.
518
527
  *
@@ -555,6 +564,20 @@ export const make = (
555
564
  const replyHandlersShard = new Map<string, Set<ReplyHandler>>()
556
565
  return MessageStorage.of({
557
566
  ...storage,
567
+ // Nested transactions defer notifications to the outermost commit.
568
+ withTransaction: (effect) =>
569
+ TransactionNotifications.use((outer) => {
570
+ const notifications: Array<Effect.Effect<void, PersistenceError | MalformedMessage>> = []
571
+ return storage.withTransaction(Effect.provideService(effect, TransactionNotifications, notifications)).pipe(
572
+ Effect.tap(() =>
573
+ outer
574
+ ? Effect.sync(() => {
575
+ outer.push(...notifications)
576
+ })
577
+ : Effect.orDie(Effect.forEach(notifications, identity, { discard: true }))
578
+ )
579
+ )
580
+ }),
558
581
  registerReplyHandler: (message) => {
559
582
  const requestId = message.envelope.requestId
560
583
  return Effect.callback<void, EntityNotAssignedToRunner>((resume) => {
@@ -619,20 +642,28 @@ export const make = (
619
642
  saveReply(reply) {
620
643
  const requestId = reply.reply.requestId
621
644
  return Effect.flatMap(storage.saveReply(reply), (persisted) => {
622
- const handlers = replyHandlers.get(requestId)
623
- if (!handlers) {
624
- return Effect.void
625
- } else if (persisted.reply._tag === "WithExit") {
626
- replyHandlers.delete(requestId)
627
- for (let i = 0; i < handlers.length; i++) {
628
- const handler = handlers[i]
629
- handler.shardSet.delete(handler)
630
- handler.resume(Effect.void)
645
+ const notify = Effect.suspend(() => {
646
+ const handlers = replyHandlers.get(requestId)
647
+ if (!handlers) {
648
+ return Effect.void
649
+ } else if (persisted.reply._tag === "WithExit") {
650
+ replyHandlers.delete(requestId)
651
+ for (let i = 0; i < handlers.length; i++) {
652
+ const handler = handlers[i]
653
+ handler.shardSet.delete(handler)
654
+ handler.resume(Effect.void)
655
+ }
631
656
  }
632
- }
633
- return handlers.length === 1
634
- ? handlers[0].respond(persisted)
635
- : Effect.forEach(handlers, (handler) => handler.respond(persisted))
657
+ return handlers.length === 1
658
+ ? handlers[0].respond(persisted)
659
+ : Effect.forEach(handlers, (handler) => handler.respond(persisted), { discard: true })
660
+ })
661
+ if (persisted.reply._tag !== "WithExit") return notify
662
+ return TransactionNotifications.use((notifications) =>
663
+ notifications === undefined ? notify : Effect.sync(() => {
664
+ notifications.push(notify)
665
+ })
666
+ )
636
667
  })
637
668
  }
638
669
  })
@@ -282,6 +282,10 @@ export const make: (
282
282
  const reply = entry.replies[i]
283
283
  // we have reached the end
284
284
  if (reply._tag === "WithExit") {
285
+ // A reply can immediately reset and resend the same request. Do not
286
+ // let that sender attach to this completed reply loop.
287
+ storageRequests.delete(message.envelope.requestId)
288
+ waitingStorageRequests.delete(message.envelope.requestId)
285
289
  for (const message of entry.messages) {
286
290
  yield* message.respond(reply)
287
291
  }
@@ -304,8 +308,10 @@ export const make: (
304
308
  effect,
305
309
  Effect.sync(() => {
306
310
  const entry = storageRequests.get(message.envelope.requestId)
307
- if (!entry || entry.messages.size > 1) {
308
- entry?.messages.delete(message)
311
+ // An old waiter must not clean up a reset request's new reply loop.
312
+ if (!entry?.messages.has(message)) return
313
+ if (entry.messages.size > 1) {
314
+ entry.messages.delete(message)
309
315
  return
310
316
  }
311
317
  storageRequests.delete(message.envelope.requestId)
@@ -277,6 +277,13 @@ const make = Effect.gen(function*() {
277
277
  // the active shards are the ones that we have acquired the lock for
278
278
  const acquiredShards = MutableHashSet.empty<ShardId>()
279
279
  const activeShardsLatch = yield* Latch.make(false)
280
+ // open once shutdown starts handing every shard off
281
+ const handoffLatch = Latch.makeUnsafe(false)
282
+ // open once the handoff has released every shard lock
283
+ const handedOffLatch = Latch.makeUnsafe(false)
284
+ // serializes runner registration with unregistration, so a registration
285
+ // in flight cannot re-advertise a runner that is shutting down
286
+ const withRegistrationLock = Semaphore.makeUnsafe(1).withPermits(1)
280
287
 
281
288
  const events = yield* PubSub.unbounded<ShardingRegistrationEvent>()
282
289
  const getRegistrationEvents: Stream.Stream<ShardingRegistrationEvent> = Stream.fromPubSub(events)
@@ -295,6 +302,33 @@ const make = Effect.gen(function*() {
295
302
  return MutableHashSet.has(acquiredShards, address.shardId)
296
303
  }
297
304
 
305
+ function isAwaitingShardLock(shardId: ShardId): boolean {
306
+ return !MutableRef.get(isShutdown) &&
307
+ MutableHashSet.has(selfShards, shardId) &&
308
+ !MutableHashSet.has(acquiredShards, shardId)
309
+ }
310
+
311
+ // Requests for a shard assigned to this runner wait for its lock, for at most
312
+ // `shardLockExpiration`, instead of bouncing while the previous owner drains.
313
+ // Each notification opens the current latch and replaces it, so a waiter that
314
+ // checked its shard before a notification cannot miss it.
315
+ let shardLocksChanged = Latch.makeUnsafe(false)
316
+ const notifyShardLockWaiters = Effect.suspend(() => {
317
+ const latch = shardLocksChanged
318
+ shardLocksChanged = Latch.makeUnsafe(false)
319
+ return latch.open
320
+ })
321
+ const awaitShardLock = (shardId: ShardId): Effect.Effect<void> => {
322
+ const loop: Effect.Effect<void> = Effect.suspend(() =>
323
+ isAwaitingShardLock(shardId) ? Effect.andThen(shardLocksChanged.await, loop) : Effect.void
324
+ )
325
+ return Effect.suspend(() =>
326
+ isAwaitingShardLock(shardId)
327
+ ? Effect.asVoid(Effect.timeoutOption(loop, config.shardLockExpiration))
328
+ : Effect.void
329
+ )
330
+ }
331
+
298
332
  yield* Scope.addFinalizer(
299
333
  shardingScope,
300
334
  Effect.logDebug("Shutdown complete").pipe(Effect.annotateLogs({
@@ -310,6 +344,8 @@ const make = Effect.gen(function*() {
310
344
  // allow them to move to another runner.
311
345
 
312
346
  const releasingShards = MutableHashSet.empty<ShardId>()
347
+ // Hands every held shard off on shutdown, set when this is a runner
348
+ let handoff: Effect.Effect<void> = Effect.void
313
349
  // Shards whose entities must be force interrupted and locks fully released
314
350
  // before normal reacquisition.
315
351
  const forceReleasingShards = MutableHashSet.empty<ShardId>()
@@ -321,8 +357,37 @@ const make = Effect.gen(function*() {
321
357
  return Effect.ignore(runnerStorage.releaseAll(selfAddress))
322
358
  })
323
359
 
360
+ // Shard releases fork into a child scope that closes after the shutdown
361
+ // handoff, as forks into the closing sharding scope never run.
362
+ const releaseScope = yield* Scope.fork(shardingScope)
363
+ // Interrupt the shards' entities on every entity manager that is open
364
+ const forkInterruptShards = Effect.fnUntraced(function*(shardIds: Iterable<ShardId>, force: boolean) {
365
+ const fibers = Arr.empty<Fiber.Fiber<void>>()
366
+ for (const shardId of shardIds) {
367
+ for (const state of entityManagers.values()) {
368
+ if (state.status === "closed") continue
369
+ fibers.push(
370
+ yield* Effect.forkIn(state.manager.interruptShard(shardId, { force }), releaseScope, {
371
+ startImmediately: true
372
+ })
373
+ )
374
+ }
375
+ }
376
+ return fibers
377
+ })
324
378
  const releaseShardsMap = yield* FiberMap.make<ShardId>()
325
379
  let forcedShardReleaseRunning = false
380
+ // Called from the acquisition loop only, so no `acquire` is in flight
381
+ const checkHandedOff = () => {
382
+ if (
383
+ handoffLatch.isOpen() &&
384
+ MutableHashSet.size(acquiredShards) === 0 &&
385
+ MutableHashSet.size(releasingShards) === 0 &&
386
+ !forcedShardReleasePending()
387
+ ) {
388
+ handedOffLatch.openUnsafe()
389
+ }
390
+ }
326
391
  // Interrupt the shards' entities, wait for lock health, run the storage
327
392
  // release, then clear the shards' bookkeeping.
328
393
  const runShardRelease = Effect.fnUntraced(function*<E>(
@@ -330,14 +395,11 @@ const make = Effect.gen(function*() {
330
395
  force: boolean,
331
396
  release: Effect.Effect<void, E>
332
397
  ) {
333
- const fibers = Arr.empty<Fiber.Fiber<void>>()
334
- for (const shardId of shardIds) {
335
- for (const state of entityManagers.values()) {
336
- if (state.status === "closed") continue
337
- fibers.push(yield* Effect.forkScoped(state.manager.interruptShard(shardId, { force })))
338
- }
339
- }
340
- yield* Fiber.joinAll(fibers)
398
+ yield* Fiber.joinAll(yield* forkInterruptShards(shardIds, force))
399
+ // stop the singletons of shards that are no longer acquired, and wait
400
+ // for those on the released shards
401
+ yield* startSyncSingletons
402
+ yield* awaitStoppingSingletons(shardIds)
341
403
  yield* shardLocksHealthyLatch.await
342
404
  yield* release
343
405
  for (const shardId of shardIds) {
@@ -351,6 +413,8 @@ const make = Effect.gen(function*() {
351
413
  interrupt: MutableRef.get(isShutdown)
352
414
  })
353
415
  }
416
+ // let the acquisition loop check whether the handoff has finished
417
+ if (handoffLatch.isOpen()) activeShardsLatch.openUnsafe()
354
418
  })
355
419
  const retryShardRelease =
356
420
  (annotations: { readonly fiber: string; readonly shardId?: ShardId }) =>
@@ -402,7 +466,7 @@ const make = Effect.gen(function*() {
402
466
  forcedShardReleaseRunning = false
403
467
  activeShardsLatch.openUnsafe()
404
468
  })),
405
- Effect.forkIn(shardingScope),
469
+ Effect.forkIn(releaseScope),
406
470
  Effect.asVoid
407
471
  )
408
472
  })
@@ -422,9 +486,11 @@ const make = Effect.gen(function*() {
422
486
  yield* activeShardsLatch.await
423
487
  activeShardsLatch.closeUnsafe()
424
488
 
425
- // if a shard is no longer assigned to this runner, we release it
489
+ // if a shard is no longer assigned to this runner, or the runner is
490
+ // handing every shard off, we release it
491
+ const handingOff = handoffLatch.isOpen()
426
492
  for (const shardId of acquiredShards) {
427
- if (MutableHashSet.has(selfShards, shardId)) continue
493
+ if (!handingOff && MutableHashSet.has(selfShards, shardId)) continue
428
494
  MutableHashSet.remove(acquiredShards, shardId)
429
495
  MutableHashSet.add(releasingShards, shardId)
430
496
  }
@@ -433,6 +499,7 @@ const make = Effect.gen(function*() {
433
499
  yield* Effect.forkIn(syncSingletons, shardingScope)
434
500
  yield* releaseShards
435
501
  }
502
+ checkHandedOff()
436
503
 
437
504
  if (!shardLocksHealthyLatch.isOpen()) {
438
505
  continue
@@ -444,6 +511,11 @@ const make = Effect.gen(function*() {
444
511
  continue
445
512
  }
446
513
 
514
+ // a runner that is shutting down takes no new shards
515
+ if (isShutdown.current) {
516
+ continue
517
+ }
518
+
447
519
  // if a shard has been assigned to this runner, we acquire it
448
520
  const unacquiredShards = MutableHashSet.empty<ShardId>()
449
521
  for (const shardId of selfShards) {
@@ -484,13 +556,15 @@ const make = Effect.gen(function*() {
484
556
  MutableHashSet.add(acquiredShards, shardId)
485
557
  }
486
558
  if (acquired.length > 0) {
559
+ yield* notifyShardLockWaiters
487
560
  yield* storageReadLatch.open
488
561
  yield* Effect.forkIn(syncSingletons, shardingScope)
489
562
 
490
563
  // update metrics
491
564
  ClusterMetrics.shards.updateUnsafe(BigInt(MutableHashSet.size(acquiredShards)), Context.empty())
492
565
  }
493
- yield* Effect.sleep(1000)
566
+ // the shutdown handoff releases the shards acquired here without waiting
567
+ yield* Effect.raceFirst(Effect.sleep(1000), handoffLatch.await)
494
568
  activeShardsLatch.openUnsafe()
495
569
  }
496
570
  }).pipe(
@@ -519,19 +593,11 @@ const make = Effect.gen(function*() {
519
593
  activeShardsLatch.openUnsafe()
520
594
 
521
595
  return Effect.gen(function*() {
596
+ yield* notifyShardLockWaiters
522
597
  yield* Effect.logError("Shard lock storage is unhealthy", cause)
523
598
  yield* Effect.forkIn(syncSingletons, shardingScope, { startImmediately: true })
524
599
 
525
- for (const shardId of affectedShards) {
526
- for (const state of entityManagers.values()) {
527
- if (state.status === "closed") continue
528
- yield* Effect.forkIn(
529
- state.manager.interruptShard(shardId, { force: true }),
530
- shardingScope,
531
- { startImmediately: true }
532
- )
533
- }
534
- }
600
+ yield* forkInterruptShards(affectedShards, true)
535
601
  activeShardsLatch.openUnsafe()
536
602
  })
537
603
  })
@@ -598,6 +664,18 @@ const make = Effect.gen(function*() {
598
664
  Effect.forever,
599
665
  Effect.forkIn(shardingScope)
600
666
  )
667
+
668
+ // Release every held shard through the per-shard path. Their entities are
669
+ // forced to stop, so the handoff does not wait out graceful termination.
670
+ // This is best effort: the wait is bounded, and if it times out, the final
671
+ // `releaseAll` attempts to release the remaining locks.
672
+ handoff = Effect.suspend(() => {
673
+ if (handoffLatch.openUnsafe()) activeShardsLatch.openUnsafe()
674
+ return forkInterruptShards([...acquiredShards, ...releasingShards], true)
675
+ }).pipe(
676
+ Effect.andThen(Effect.timeoutOption(handedOffLatch.await, shardLockInterval)),
677
+ Effect.asVoid
678
+ )
601
679
  }
602
680
 
603
681
  // --- Storage inbox ---
@@ -1036,16 +1114,21 @@ const make = Effect.gen(function*() {
1036
1114
 
1037
1115
  // --- Sending messages ---
1038
1116
 
1039
- const sendLocal = <M extends Message.Outgoing<any> | Message.Incoming<any>>(message: M) =>
1040
- Effect.suspend(function loop(): Effect.Effect<
1117
+ const sendLocal = <M extends Message.Outgoing<any> | Message.Incoming<any>>(message: M) => {
1118
+ const address = message.envelope.address
1119
+ const isRequest = message._tag === "IncomingRequest" || message._tag === "OutgoingRequest"
1120
+ const send = Effect.suspend(function loop(): Effect.Effect<
1041
1121
  void,
1042
1122
  | EntityNotAssignedToRunner
1043
1123
  | MailboxFull
1044
1124
  | AlreadyProcessingMessage
1045
1125
  | (M extends Message.Incoming<any> ? never : PersistenceError)
1046
1126
  > {
1047
- const address = message.envelope.address
1048
- if (!isEntityOnLocalShards(address)) {
1127
+ if (
1128
+ !isEntityOnLocalShards(address) &&
1129
+ // Entities draining from a released shard still accept interrupts and acks
1130
+ (isRequest || !MutableHashSet.has(releasingShards, address.shardId))
1131
+ ) {
1049
1132
  return Effect.fail(new EntityNotAssignedToRunner({ address }))
1050
1133
  }
1051
1134
  const state = entityManagers.get(address.entityType)
@@ -1064,6 +1147,8 @@ const make = Effect.gen(function*() {
1064
1147
  simulateRemoteSerialization: config.simulateRemoteSerialization
1065
1148
  }) as any
1066
1149
  })
1150
+ return isRequest ? Effect.andThen(awaitShardLock(address.shardId), send) : send
1151
+ }
1067
1152
 
1068
1153
  type PendingNotification = {
1069
1154
  resume: (_: Effect.Effect<void, EntityNotAssignedToRunner>) => void
@@ -1129,7 +1214,10 @@ const make = Effect.gen(function*() {
1129
1214
  function sendOutgoing(
1130
1215
  message: Message.Outgoing<any>,
1131
1216
  discard: boolean,
1132
- retries?: number
1217
+ retries?: number,
1218
+ // The runner serving the request, for its interrupts and acks. Later
1219
+ // attempts fall back to the current shard assignment.
1220
+ runner?: RunnerAddress
1133
1221
  ): Effect.Effect<
1134
1222
  void,
1135
1223
  MailboxFull | AlreadyProcessingMessage | PersistenceError | EntityNotAssignedToRunner
@@ -1172,7 +1260,9 @@ const make = Effect.gen(function*() {
1172
1260
  if (shouldFail && MutableRef.get(isShutdown)) {
1173
1261
  return Effect.fail(new EntityNotAssignedToRunner({ address }))
1174
1262
  }
1175
- const maybeRunner = MutableHashMap.get(shardAssignments, address.shardId)
1263
+ const maybeRunner = runner !== undefined
1264
+ ? Option.some(runner)
1265
+ : MutableHashMap.get(shardAssignments, address.shardId)
1176
1266
  const runnerIsLocal = Option.isSome(maybeRunner) && isLocalRunner(maybeRunner.value)
1177
1267
  if (isPersisted) {
1178
1268
  return runnerIsLocal
@@ -1180,6 +1270,9 @@ const make = Effect.gen(function*() {
1180
1270
  : runnersService.notify({ address: maybeRunner, message, discard })
1181
1271
  } else if (Option.isNone(maybeRunner)) {
1182
1272
  return Effect.fail(new EntityNotAssignedToRunner({ address }))
1273
+ } else if (message._tag === "OutgoingRequest") {
1274
+ const entry = clientRequests.get(message.envelope.requestId)
1275
+ if (entry) entry.runner = maybeRunner.value
1183
1276
  }
1184
1277
  return runnerIsLocal
1185
1278
  ? sendLocal(message)
@@ -1250,9 +1343,12 @@ const make = Effect.gen(function*() {
1250
1343
  while (true) {
1251
1344
  // Ensure the current runner is registered
1252
1345
  if (selfRunner && !isShutdown.current && !MutableHashMap.has(allRunners, selfRunner)) {
1253
- yield* Effect.logDebug("Registering runner", selfRunner)
1254
- const machineId = yield* withTimeout(runnerStorage.register(selfRunner, true))
1255
- yield* snowflakeGen.setMachineId(machineId)
1346
+ yield* withRegistrationLock(Effect.gen(function*() {
1347
+ if (isShutdown.current) return
1348
+ yield* Effect.logDebug("Registering runner", selfRunner)
1349
+ const machineId = yield* withTimeout(runnerStorage.register(selfRunner, true))
1350
+ yield* snowflakeGen.setMachineId(machineId)
1351
+ }))
1256
1352
  }
1257
1353
 
1258
1354
  const runners = yield* withTimeout(runnerStorage.getRunners)
@@ -1324,6 +1420,7 @@ const make = Effect.gen(function*() {
1324
1420
  })
1325
1421
  yield* Effect.logDebug("New shard assignments", selfShards)
1326
1422
  activeShardsLatch.openUnsafe()
1423
+ yield* notifyShardLockWaiters
1327
1424
 
1328
1425
  // update metrics
1329
1426
  if (selfRunner) {
@@ -1361,6 +1458,8 @@ const make = Effect.gen(function*() {
1361
1458
  readonly context: Context.Context<never>
1362
1459
  readonly message: Message.OutgoingRequest<any>
1363
1460
  lastChunkId?: Snowflake.Snowflake
1461
+ // the runner a volatile request was last sent to
1462
+ runner?: RunnerAddress
1364
1463
  }
1365
1464
  const clientRequests = new Map<Snowflake.Snowflake, ClientRequestEntry>()
1366
1465
 
@@ -1445,7 +1544,9 @@ const make = Effect.gen(function*() {
1445
1544
  }),
1446
1545
  rpc: entry.rpc
1447
1546
  }),
1448
- false
1547
+ false,
1548
+ undefined,
1549
+ entry.runner
1449
1550
  )
1450
1551
  }
1451
1552
  case "Interrupt": {
@@ -1476,7 +1577,8 @@ const make = Effect.gen(function*() {
1476
1577
  rpc: entry.rpc
1477
1578
  }),
1478
1579
  false,
1479
- 3
1580
+ 3,
1581
+ entry.runner
1480
1582
  ))
1481
1583
  }
1482
1584
  }
@@ -1569,6 +1671,47 @@ const make = Effect.gen(function*() {
1569
1671
  const singletonFibers = yield* FiberMap.make<SingletonAddress>()
1570
1672
  const withSingletonLock = Semaphore.makeUnsafe(1).withPermits(1)
1571
1673
 
1674
+ // Singletons that are stopping. They count as running, so they are not
1675
+ // restarted, and shard releases wait for them. Their teardown is awaited
1676
+ // without the singleton lock, as it can register or stop other singletons.
1677
+ const stoppingSingletons = MutableHashMap.empty<SingletonAddress, Fiber.Fiber<void>>()
1678
+ const isSingletonRegistered = (address: SingletonAddress) => {
1679
+ const map = singletons.get(address.shardId)
1680
+ return map !== undefined && MutableHashMap.has(map, address)
1681
+ }
1682
+
1683
+ // Called with the singleton lock held. Starts stopping the singleton and
1684
+ // returns the wait for its teardown, to run without the lock.
1685
+ const stopSingletonUnsafe = (address: SingletonAddress, activeTeardown: boolean): Effect.Effect<void> => {
1686
+ const stopping = MutableHashMap.get(stoppingSingletons, address)
1687
+ if (Option.isSome(stopping)) return Fiber.join(stopping.value)
1688
+ const fiber = FiberMap.getUnsafe(singletonFibers, address)
1689
+ if (Option.isNone(fiber)) return Effect.void
1690
+ const interrupt = Fiber.interrupt(fiber.value)
1691
+ const stop = Effect.runForkWith(services)(
1692
+ activeTeardown ? ActiveTeardown.aroundShard(address.shardId, interrupt) : interrupt
1693
+ )
1694
+ MutableHashMap.set(stoppingSingletons, address, stop)
1695
+ stop.addObserver(() => {
1696
+ MutableHashMap.remove(stoppingSingletons, address)
1697
+ // a registration made while the previous singleton was stopping
1698
+ if (isSingletonRegistered(address)) runFork(syncSingletons)
1699
+ })
1700
+ return Fiber.join(stop)
1701
+ }
1702
+
1703
+ // Waits for the singletons of the given shards that are still stopping
1704
+ const awaitStoppingSingletons = (shardIds: ReadonlyArray<ShardId>) =>
1705
+ Effect.suspend(() => {
1706
+ const stops = Arr.empty<Fiber.Fiber<void>>()
1707
+ MutableHashMap.forEach(stoppingSingletons, (stop, address) => {
1708
+ if (shardIds.some((shardId) => Equal.equals(shardId, address.shardId))) {
1709
+ stops.push(stop)
1710
+ }
1711
+ })
1712
+ return Fiber.joinAll(stops)
1713
+ })
1714
+
1572
1715
  const registerSingleton: Sharding["Service"]["registerSingleton"] = Effect.fnUntraced(
1573
1716
  function*(name, run, options) {
1574
1717
  const shardGroup = options?.shardGroup ?? "default"
@@ -1599,32 +1742,38 @@ const make = Effect.gen(function*() {
1599
1742
 
1600
1743
  yield* PubSub.publish(events, SingletonRegistered({ address }))
1601
1744
 
1602
- // start if we are on the right shard
1603
- if (MutableHashSet.has(acquiredShards, address.shardId)) {
1745
+ // start if we are on the right shard, unless a previous registration of
1746
+ // this singleton is still stopping
1747
+ if (
1748
+ MutableHashSet.has(acquiredShards, address.shardId) &&
1749
+ !MutableHashMap.has(stoppingSingletons, address)
1750
+ ) {
1604
1751
  yield* Effect.logDebug("Starting singleton", address)
1605
1752
  yield* FiberMap.run(singletonFibers, address, wrappedRun)
1606
1753
  }
1607
1754
 
1608
- yield* Effect.addFinalizer(() => {
1609
- const map = singletons.get(address.shardId)!
1610
- MutableHashMap.remove(map, address)
1611
- return FiberMap.remove(singletonFibers, address)
1612
- })
1755
+ yield* Effect.addFinalizer(() =>
1756
+ withSingletonLock(Effect.sync(() => {
1757
+ MutableHashMap.remove(singletons.get(address.shardId)!, address)
1758
+ return stopSingletonUnsafe(address, false)
1759
+ })).pipe(Effect.flatten)
1760
+ )
1613
1761
  },
1614
1762
  withSingletonLock
1615
1763
  )
1616
1764
 
1617
- const syncSingletons = withSingletonLock(Effect.gen(function*() {
1765
+ // Starts and stops singletons to match the acquired shards, returning the
1766
+ // waits for the stopped singletons
1767
+ const startSyncSingletons = withSingletonLock(Effect.gen(function*() {
1768
+ const stops = Arr.empty<Effect.Effect<void>>()
1618
1769
  for (const [shardId, map] of singletons) {
1619
1770
  for (const [address, run] of map) {
1620
- const running = FiberMap.hasUnsafe(singletonFibers, address)
1771
+ const stopping = MutableHashMap.has(stoppingSingletons, address)
1772
+ const running = stopping || FiberMap.hasUnsafe(singletonFibers, address)
1621
1773
  const shouldBeRunning = MutableHashSet.has(acquiredShards, shardId)
1622
1774
  if (running && !shouldBeRunning) {
1623
- yield* Effect.logDebug("Stopping singleton", address)
1624
- yield* ActiveTeardown.aroundShard(
1625
- address.shardId,
1626
- FiberMap.remove(singletonFibers, address)
1627
- )
1775
+ if (!stopping) yield* Effect.logDebug("Stopping singleton", address)
1776
+ stops.push(stopSingletonUnsafe(address, true))
1628
1777
  } else if (!running && shouldBeRunning) {
1629
1778
  yield* Effect.logDebug("Starting singleton", address)
1630
1779
  yield* FiberMap.run(singletonFibers, address, run)
@@ -1635,8 +1784,15 @@ const make = Effect.gen(function*() {
1635
1784
  BigInt(yield* FiberMap.size(singletonFibers)),
1636
1785
  Context.empty()
1637
1786
  )
1787
+ return stops
1638
1788
  }))
1639
1789
 
1790
+ const syncSingletons: Effect.Effect<void> = Effect.flatMap(
1791
+ startSyncSingletons,
1792
+ // wait for the stopped singletons without the lock
1793
+ (stops) => Effect.all(stops, { discard: true })
1794
+ )
1795
+
1640
1796
  // --- Entities ---
1641
1797
 
1642
1798
  const reaper = yield* EntityReaper
@@ -1777,12 +1933,14 @@ const make = Effect.gen(function*() {
1777
1933
 
1778
1934
  if (isShutdown.current) return
1779
1935
  MutableRef.set(isShutdown, true)
1936
+ yield* notifyShardLockWaiters
1780
1937
  if (selfRunner) {
1781
- yield* Effect.ignore(runnerStorage.unregister(selfRunner.address))
1938
+ yield* Effect.ignore(withRegistrationLock(runnerStorage.unregister(selfRunner.address)))
1782
1939
  }
1783
1940
  })
1784
1941
 
1785
- yield* Scope.addFinalizerExit(shardingScope, shutdown)
1942
+ // hand the shards off before the loops stop
1943
+ yield* Scope.addFinalizerExit(shardingScope, (exit) => Effect.andThen(shutdown(exit), handoff))
1786
1944
 
1787
1945
  const activeEntityCount = Effect.gen(function*() {
1788
1946
  let count = 0