@alchemy.run/effect-cluster 0.0.0-stage → 2.0.0-beta.86

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 (233) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +105 -2
  3. package/lib/AWS/Database.d.ts +54 -0
  4. package/lib/AWS/Database.d.ts.map +1 -0
  5. package/lib/AWS/Database.js +96 -0
  6. package/lib/AWS/Database.js.map +1 -0
  7. package/lib/AWS/EcsPool.d.ts +56 -0
  8. package/lib/AWS/EcsPool.d.ts.map +1 -0
  9. package/lib/AWS/EcsPool.js +87 -0
  10. package/lib/AWS/EcsPool.js.map +1 -0
  11. package/lib/AWS/index.d.ts +7 -0
  12. package/lib/AWS/index.d.ts.map +1 -0
  13. package/lib/AWS/index.js +7 -0
  14. package/lib/AWS/index.js.map +1 -0
  15. package/lib/AWS/internal/Gateway.d.ts +23 -0
  16. package/lib/AWS/internal/Gateway.d.ts.map +1 -0
  17. package/lib/AWS/internal/Gateway.js +70 -0
  18. package/lib/AWS/internal/Gateway.js.map +1 -0
  19. package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
  20. package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
  21. package/lib/Cloudflare/DurableObjectPool.js +136 -0
  22. package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
  23. package/lib/Cloudflare/PoolWorker.d.ts +186 -0
  24. package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
  25. package/lib/Cloudflare/PoolWorker.js +75 -0
  26. package/lib/Cloudflare/PoolWorker.js.map +1 -0
  27. package/lib/Cloudflare/index.d.ts +7 -0
  28. package/lib/Cloudflare/index.d.ts.map +1 -0
  29. package/lib/Cloudflare/index.js +7 -0
  30. package/lib/Cloudflare/index.js.map +1 -0
  31. package/lib/Cloudflare/internal/Objects.d.ts +38 -0
  32. package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
  33. package/lib/Cloudflare/internal/Objects.js +102 -0
  34. package/lib/Cloudflare/internal/Objects.js.map +1 -0
  35. package/lib/Cron.d.ts +143 -0
  36. package/lib/Cron.d.ts.map +1 -0
  37. package/lib/Cron.js +83 -0
  38. package/lib/Cron.js.map +1 -0
  39. package/lib/Database.d.ts +95 -0
  40. package/lib/Database.d.ts.map +1 -0
  41. package/lib/Database.js +38 -0
  42. package/lib/Database.js.map +1 -0
  43. package/lib/Docker/Database.d.ts +42 -0
  44. package/lib/Docker/Database.d.ts.map +1 -0
  45. package/lib/Docker/Database.js +53 -0
  46. package/lib/Docker/Database.js.map +1 -0
  47. package/lib/Docker/SwarmPool.d.ts +44 -0
  48. package/lib/Docker/SwarmPool.d.ts.map +1 -0
  49. package/lib/Docker/SwarmPool.js +56 -0
  50. package/lib/Docker/SwarmPool.js.map +1 -0
  51. package/lib/Docker/index.d.ts +7 -0
  52. package/lib/Docker/index.d.ts.map +1 -0
  53. package/lib/Docker/index.js +7 -0
  54. package/lib/Docker/index.js.map +1 -0
  55. package/lib/Entity.d.ts +348 -0
  56. package/lib/Entity.d.ts.map +1 -0
  57. package/lib/Entity.js +308 -0
  58. package/lib/Entity.js.map +1 -0
  59. package/lib/Fly/Database.d.ts +50 -0
  60. package/lib/Fly/Database.d.ts.map +1 -0
  61. package/lib/Fly/Database.js +56 -0
  62. package/lib/Fly/Database.js.map +1 -0
  63. package/lib/Fly/MachinePool.d.ts +39 -0
  64. package/lib/Fly/MachinePool.d.ts.map +1 -0
  65. package/lib/Fly/MachinePool.js +51 -0
  66. package/lib/Fly/MachinePool.js.map +1 -0
  67. package/lib/Fly/index.d.ts +7 -0
  68. package/lib/Fly/index.d.ts.map +1 -0
  69. package/lib/Fly/index.js +7 -0
  70. package/lib/Fly/index.js.map +1 -0
  71. package/lib/GCP/ComputeEnginePool.d.ts +41 -0
  72. package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
  73. package/lib/GCP/ComputeEnginePool.js +172 -0
  74. package/lib/GCP/ComputeEnginePool.js.map +1 -0
  75. package/lib/GCP/Database.d.ts +48 -0
  76. package/lib/GCP/Database.d.ts.map +1 -0
  77. package/lib/GCP/Database.js +115 -0
  78. package/lib/GCP/Database.js.map +1 -0
  79. package/lib/GCP/index.d.ts +7 -0
  80. package/lib/GCP/index.d.ts.map +1 -0
  81. package/lib/GCP/index.js +7 -0
  82. package/lib/GCP/index.js.map +1 -0
  83. package/lib/Hetzner/Database.d.ts +48 -0
  84. package/lib/Hetzner/Database.d.ts.map +1 -0
  85. package/lib/Hetzner/Database.js +91 -0
  86. package/lib/Hetzner/Database.js.map +1 -0
  87. package/lib/Hetzner/ServerPool.d.ts +35 -0
  88. package/lib/Hetzner/ServerPool.d.ts.map +1 -0
  89. package/lib/Hetzner/ServerPool.js +98 -0
  90. package/lib/Hetzner/ServerPool.js.map +1 -0
  91. package/lib/Hetzner/index.d.ts +7 -0
  92. package/lib/Hetzner/index.d.ts.map +1 -0
  93. package/lib/Hetzner/index.js +7 -0
  94. package/lib/Hetzner/index.js.map +1 -0
  95. package/lib/Host.d.ts +60 -0
  96. package/lib/Host.d.ts.map +1 -0
  97. package/lib/Host.js +31 -0
  98. package/lib/Host.js.map +1 -0
  99. package/lib/Kubernetes/Database.d.ts +40 -0
  100. package/lib/Kubernetes/Database.d.ts.map +1 -0
  101. package/lib/Kubernetes/Database.js +56 -0
  102. package/lib/Kubernetes/Database.js.map +1 -0
  103. package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
  104. package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
  105. package/lib/Kubernetes/DeploymentPool.js +49 -0
  106. package/lib/Kubernetes/DeploymentPool.js.map +1 -0
  107. package/lib/Kubernetes/index.d.ts +7 -0
  108. package/lib/Kubernetes/index.d.ts.map +1 -0
  109. package/lib/Kubernetes/index.js +7 -0
  110. package/lib/Kubernetes/index.js.map +1 -0
  111. package/lib/Pool.d.ts +66 -0
  112. package/lib/Pool.d.ts.map +1 -0
  113. package/lib/Pool.js +47 -0
  114. package/lib/Pool.js.map +1 -0
  115. package/lib/Providers.d.ts +19 -0
  116. package/lib/Providers.d.ts.map +1 -0
  117. package/lib/Providers.js +31 -0
  118. package/lib/Providers.js.map +1 -0
  119. package/lib/ShardGroups.d.ts +24 -0
  120. package/lib/ShardGroups.d.ts.map +1 -0
  121. package/lib/ShardGroups.js +19 -0
  122. package/lib/ShardGroups.js.map +1 -0
  123. package/lib/Singleton.d.ts +110 -0
  124. package/lib/Singleton.d.ts.map +1 -0
  125. package/lib/Singleton.js +60 -0
  126. package/lib/Singleton.js.map +1 -0
  127. package/lib/Workflow.d.ts +251 -0
  128. package/lib/Workflow.d.ts.map +1 -0
  129. package/lib/Workflow.js +288 -0
  130. package/lib/Workflow.js.map +1 -0
  131. package/lib/index.d.ts +12 -0
  132. package/lib/index.d.ts.map +1 -0
  133. package/lib/index.js +12 -0
  134. package/lib/index.js.map +1 -0
  135. package/lib/internal/Bootstrap.d.ts +28 -0
  136. package/lib/internal/Bootstrap.d.ts.map +1 -0
  137. package/lib/internal/Bootstrap.js +257 -0
  138. package/lib/internal/Bootstrap.js.map +1 -0
  139. package/lib/internal/EntityRuntime.d.ts +40 -0
  140. package/lib/internal/EntityRuntime.d.ts.map +1 -0
  141. package/lib/internal/EntityRuntime.js +698 -0
  142. package/lib/internal/EntityRuntime.js.map +1 -0
  143. package/lib/internal/Gateway.d.ts +82 -0
  144. package/lib/internal/Gateway.d.ts.map +1 -0
  145. package/lib/internal/Gateway.js +218 -0
  146. package/lib/internal/Gateway.js.map +1 -0
  147. package/lib/internal/HostedPlatform.d.ts +36 -0
  148. package/lib/internal/HostedPlatform.d.ts.map +1 -0
  149. package/lib/internal/HostedPlatform.js +36 -0
  150. package/lib/internal/HostedPlatform.js.map +1 -0
  151. package/lib/internal/HostedRuntime.d.ts +58 -0
  152. package/lib/internal/HostedRuntime.d.ts.map +1 -0
  153. package/lib/internal/HostedRuntime.js +72 -0
  154. package/lib/internal/HostedRuntime.js.map +1 -0
  155. package/lib/internal/Naming.d.ts +12 -0
  156. package/lib/internal/Naming.d.ts.map +1 -0
  157. package/lib/internal/Naming.js +40 -0
  158. package/lib/internal/Naming.js.map +1 -0
  159. package/lib/internal/ObjectSharding.d.ts +141 -0
  160. package/lib/internal/ObjectSharding.d.ts.map +1 -0
  161. package/lib/internal/ObjectSharding.js +337 -0
  162. package/lib/internal/ObjectSharding.js.map +1 -0
  163. package/lib/internal/PoolHost.d.ts +80 -0
  164. package/lib/internal/PoolHost.d.ts.map +1 -0
  165. package/lib/internal/PoolHost.js +203 -0
  166. package/lib/internal/PoolHost.js.map +1 -0
  167. package/lib/internal/PoolHostService.d.ts +25 -0
  168. package/lib/internal/PoolHostService.d.ts.map +1 -0
  169. package/lib/internal/PoolHostService.js +10 -0
  170. package/lib/internal/PoolHostService.js.map +1 -0
  171. package/lib/internal/Wire.d.ts +60 -0
  172. package/lib/internal/Wire.d.ts.map +1 -0
  173. package/lib/internal/Wire.js +36 -0
  174. package/lib/internal/Wire.js.map +1 -0
  175. package/lib/internal/WorkflowGateway.d.ts +36 -0
  176. package/lib/internal/WorkflowGateway.d.ts.map +1 -0
  177. package/lib/internal/WorkflowGateway.js +124 -0
  178. package/lib/internal/WorkflowGateway.js.map +1 -0
  179. package/lib/internal/external-runner.d.ts +2 -0
  180. package/lib/internal/external-runner.d.ts.map +1 -0
  181. package/lib/internal/external-runner.js +7 -0
  182. package/lib/internal/external-runner.js.map +1 -0
  183. package/lib/internal/runner.d.ts +2 -0
  184. package/lib/internal/runner.d.ts.map +1 -0
  185. package/lib/internal/runner.js +5 -0
  186. package/lib/internal/runner.js.map +1 -0
  187. package/package.json +143 -3
  188. package/src/AWS/Database.ts +139 -0
  189. package/src/AWS/EcsPool.ts +118 -0
  190. package/src/AWS/index.ts +6 -0
  191. package/src/AWS/internal/Gateway.ts +82 -0
  192. package/src/Cloudflare/DurableObjectPool.ts +198 -0
  193. package/src/Cloudflare/PoolWorker.ts +124 -0
  194. package/src/Cloudflare/index.ts +6 -0
  195. package/src/Cloudflare/internal/Objects.ts +160 -0
  196. package/src/Cron.ts +202 -0
  197. package/src/Database.ts +116 -0
  198. package/src/Docker/Database.ts +94 -0
  199. package/src/Docker/SwarmPool.ts +83 -0
  200. package/src/Docker/index.ts +6 -0
  201. package/src/Entity.ts +621 -0
  202. package/src/Fly/Database.ts +102 -0
  203. package/src/Fly/MachinePool.ts +72 -0
  204. package/src/Fly/index.ts +6 -0
  205. package/src/GCP/ComputeEnginePool.ts +203 -0
  206. package/src/GCP/Database.ts +168 -0
  207. package/src/GCP/index.ts +6 -0
  208. package/src/Hetzner/Database.ts +142 -0
  209. package/src/Hetzner/ServerPool.ts +116 -0
  210. package/src/Hetzner/index.ts +6 -0
  211. package/src/Host.ts +68 -0
  212. package/src/Kubernetes/Database.ts +89 -0
  213. package/src/Kubernetes/DeploymentPool.ts +77 -0
  214. package/src/Kubernetes/index.ts +6 -0
  215. package/src/Pool.ts +104 -0
  216. package/src/Providers.ts +49 -0
  217. package/src/ShardGroups.ts +41 -0
  218. package/src/Singleton.ts +153 -0
  219. package/src/Workflow.ts +556 -0
  220. package/src/index.ts +11 -0
  221. package/src/internal/Bootstrap.ts +368 -0
  222. package/src/internal/EntityRuntime.ts +996 -0
  223. package/src/internal/Gateway.ts +366 -0
  224. package/src/internal/HostedPlatform.ts +83 -0
  225. package/src/internal/HostedRuntime.ts +118 -0
  226. package/src/internal/Naming.ts +44 -0
  227. package/src/internal/ObjectSharding.ts +578 -0
  228. package/src/internal/PoolHost.ts +330 -0
  229. package/src/internal/PoolHostService.ts +25 -0
  230. package/src/internal/Wire.ts +83 -0
  231. package/src/internal/WorkflowGateway.ts +188 -0
  232. package/src/internal/external-runner.ts +10 -0
  233. package/src/internal/runner.ts +5 -0
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Effect Cluster on durable objects, in Effect's shape: there are no shards
3
+ * or runners to place an instance on, because a durable object (a Cloudflare
4
+ * Durable Object, a Rivet actor) already is one instance per name. Every
5
+ * entity instance is the object named by its `(type, id)`, every workflow
6
+ * execution the object named by its `(workflow, execution)`, every singleton
7
+ * and cron the object named after it.
8
+ *
9
+ * `Sharding` is the seam: Effect Cluster's own client (`Entity.client`), the
10
+ * gateway and one entity calling another all ask it for a client, and this
11
+ * one sends each call to the entity's object. A host supplies the objects:
12
+ * their storage and alarm, and stubs to call them by name.
13
+ *
14
+ * @internal
15
+ */
16
+ import { makeEntrypointLayer } from "alchemy/Runtime";
17
+ import { stackFromEnv } from "alchemy/Runtime/Bootstrap";
18
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
19
+ import { Self } from "alchemy/Self";
20
+ import type { ObjectImpl } from "alchemy/Workers/DurableObjectEngine";
21
+ import * as Cause from "effect/Cause";
22
+ import * as Clock from "effect/Clock";
23
+ import {
24
+ ClusterCron,
25
+ ClusterSchema,
26
+ type Entity as EffectEntity,
27
+ EntityAddress,
28
+ EntityId,
29
+ EntityType,
30
+ RunnerAddress,
31
+ Sharding,
32
+ ShardId,
33
+ Snowflake,
34
+ } from "effect/cluster";
35
+ import {
36
+ AlreadyProcessingMessage,
37
+ MailboxFull,
38
+ PersistenceError,
39
+ } from "effect/cluster/ClusterError";
40
+ import * as Context from "effect/Context";
41
+ import * as Cron from "effect/Cron";
42
+ import * as SecureRandom from "effect/crypto/SecureRandom";
43
+ import * as Data from "effect/Data";
44
+ import * as DateTime from "effect/DateTime";
45
+ import * as Duration from "effect/Duration";
46
+ import * as Effect from "effect/Effect";
47
+ import * as Exit from "effect/Exit";
48
+ import type * as Fiber from "effect/Fiber";
49
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
50
+ import * as Headers from "effect/http/Headers";
51
+ import * as Layer from "effect/Layer";
52
+ import * as Option from "effect/Option";
53
+ import { type Rpc, RpcClient, RpcSchema } from "effect/rpc";
54
+ import * as Schedule from "effect/Schedule";
55
+ import type * as Scope from "effect/Scope";
56
+ import * as Stream from "effect/Stream";
57
+ import type { Workflow as EffectWorkflow, WorkflowEngine } from "effect/workflow";
58
+ import type { HostedCron } from "../Cron.ts";
59
+ import { type HostedEntity, PlatformDeclaration } from "../Entity.ts";
60
+ import { RunnerHost } from "../Host.ts";
61
+ import type { HostedSingleton } from "../Singleton.ts";
62
+ import type { HostedWorkflow } from "../Workflow.ts";
63
+ import { type EntityStorage, makeEntityRuntime } from "./EntityRuntime.ts";
64
+ import { HasNoPool, type HostedRuntimeContext } from "./HostedRuntime.ts";
65
+ import {
66
+ type Call,
67
+ chunkCodec,
68
+ DEFECT,
69
+ decode,
70
+ encode,
71
+ entityObjectName,
72
+ exitCodec,
73
+ type Frame,
74
+ parseEntityObjectName,
75
+ payloadCodec,
76
+ type Rejection,
77
+ type Reply,
78
+ } from "./Wire.ts";
79
+
80
+ type AnyEntity = EffectEntity.Entity<string, Rpc.Any>;
81
+
82
+ type Hosted = HostedEntity | HostedWorkflow | HostedSingleton | HostedCron;
83
+
84
+ /** A class this pool hosts was asked for, but nothing registered it. */
85
+ export class NotHosted extends Data.TaggedError("Cluster.NotHosted")<{
86
+ readonly message: string;
87
+ }> {}
88
+
89
+ /**
90
+ * What a pool's process registers, for its objects to run: each object reads
91
+ * the class it runs from here when it is constructed.
92
+ */
93
+ export const registry = {
94
+ entities: new Map<string, HostedEntity>(),
95
+ scheduled: new Map<string, HostedSingleton | HostedCron>(),
96
+ workflows: new Map<string, ObjectImpl<unknown>>(),
97
+ sharding: undefined as Sharding.Sharding["Service"] | undefined,
98
+ };
99
+
100
+ // ---------------------------------------------------------------- names
101
+
102
+ /** The object a workflow execution runs in: `${class}:${executionId}`. */
103
+ export const workflowObjectName = (className: string, executionId: string) =>
104
+ `${className}:${executionId}`;
105
+
106
+ /** The workflow class an object named by {@link workflowObjectName} runs. */
107
+ export const workflowClassOf = (objectName: string) => objectName.slice(0, objectName.indexOf(":"));
108
+
109
+ /** The object a singleton or cron runs in: `singleton:${name}` or `cron:${name}`. */
110
+ export const scheduledObjectName = (kind: "singleton" | "cron", name: string) => `${kind}:${name}`;
111
+
112
+ // ---------------------------------------------------------------- wire
113
+
114
+ /** Call an object, turning a defect it returned as data back into a defect. */
115
+ const call = <A>(effect: Effect.Effect<unknown, unknown>): Effect.Effect<A> =>
116
+ effect.pipe(
117
+ Effect.orDie,
118
+ Effect.flatMap((value) =>
119
+ value !== null && typeof value === "object" && DEFECT in value
120
+ ? Effect.die(new Error(String((value as Record<string, unknown>)[DEFECT])))
121
+ : Effect.succeed(value as A),
122
+ ),
123
+ );
124
+
125
+ /** Answer a defect as data, since an object's caller can't be sent a cause. */
126
+ export const reportDefects = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
127
+ effect.pipe(
128
+ Effect.catchCause((cause) => Effect.succeed({ [DEFECT]: Cause.pretty(cause) } as unknown as A)),
129
+ );
130
+
131
+ // ---------------------------------------------------------------- sharding
132
+
133
+ /** What an entity's object answers: calls and replies as JSON text. */
134
+ export interface EntityObjectStub {
135
+ readonly ask: (call: string) => Effect.Effect<string, unknown>;
136
+ readonly tell: (call: string) => Effect.Effect<string, unknown>;
137
+ readonly stream: (call: string) => Stream.Stream<string, unknown>;
138
+ readonly interrupt: (requestId: string) => Effect.Effect<void, unknown>;
139
+ }
140
+
141
+ /** What a singleton's or cron's object answers. */
142
+ export interface ScheduledObjectStub {
143
+ readonly ensure: () => Effect.Effect<void, unknown>;
144
+ }
145
+
146
+ export interface Namespace<Stub> {
147
+ readonly getByName: (name: string) => Stub;
148
+ }
149
+
150
+ /** What differs between the hosts whose objects back a cluster. */
151
+ export interface ObjectHost {
152
+ /** The shard group every address on this host is in: the host's name. */
153
+ readonly group: string;
154
+ /**
155
+ * Whether a failed call to an object can be sent again: the host knows a
156
+ * transient failure from one the object answered with.
157
+ */
158
+ readonly isRetryable: (error: unknown) => boolean;
159
+ }
160
+
161
+ /** The address of an entity instance on `host`: its one shard is the host itself. */
162
+ const addressOf = (host: ObjectHost, type: string, id: string) =>
163
+ EntityAddress.make({
164
+ shardId: ShardId.make(host.group, 1),
165
+ entityType: EntityType.make(type),
166
+ entityId: EntityId.make(id),
167
+ });
168
+
169
+ /** A rejection the object answered with, as Effect Cluster's error. */
170
+ const rejectionError = (address: EntityAddress.EntityAddress, rejection: Rejection) => {
171
+ switch (rejection._tag) {
172
+ case "MailboxFull":
173
+ return MailboxFull.make({ address });
174
+ case "AlreadyProcessingMessage":
175
+ return AlreadyProcessingMessage.make({ address, envelopeId: Snowflake.Snowflake(0n) });
176
+ case "PersistenceError":
177
+ return PersistenceError.make({ cause: rejection.message });
178
+ }
179
+ };
180
+
181
+ /**
182
+ * A client for one entity instance: each RPC is sent to the entity's object
183
+ * and answered by it, with the caller's headers, as Effect Cluster's own
184
+ * client sends it to the runner that owns the entity.
185
+ *
186
+ * A call the object never answered fails with `PersistenceError`; a
187
+ * persisted one is sent again first, to a fresh stub, since the object
188
+ * stores it under the same request id and runs it once. Interrupting a call
189
+ * interrupts its handler.
190
+ */
191
+ export const clientOf = (
192
+ entity: AnyEntity,
193
+ entityId: string,
194
+ objects: () => Namespace<EntityObjectStub>,
195
+ host: ObjectHost,
196
+ /** Whether the pool hosts the entity: checked before any object is woken. */
197
+ hosts: (type: string) => boolean = () => true,
198
+ ) => {
199
+ const address = addressOf(host, entity.type, entityId);
200
+ const stub = () => objects().getByName(entityObjectName(entity.type, entityId));
201
+ const hosted = Effect.suspend(() =>
202
+ hosts(entity.type)
203
+ ? Effect.void
204
+ : Effect.die(
205
+ new NotHosted({ message: `This pool hosts no Cluster.Entity '${entity.type}'.` }),
206
+ ),
207
+ );
208
+ const methods: Record<string, unknown> = {};
209
+ for (const [tag, rpc_] of entity.protocol.requests) {
210
+ const rpc = rpc_ as unknown as Rpc.AnyWithProps;
211
+ const persisted = Context.get(rpc.annotations, ClusterSchema.Persisted);
212
+ const message = Effect.fn(function* (
213
+ payload: unknown,
214
+ options?: { readonly headers?: Headers.Input },
215
+ ) {
216
+ yield* hosted;
217
+ const headers = Headers.merge(
218
+ yield* RpcClient.CurrentHeaders,
219
+ Headers.fromInput(options?.headers ?? {}),
220
+ );
221
+ const sent: Call = {
222
+ requestId: SecureRandom.uuidV4Unsafe(),
223
+ tag,
224
+ payload: yield* encode(payloadCodec(rpc), payload),
225
+ headers: Object.fromEntries(Object.entries(headers)),
226
+ };
227
+ return sent;
228
+ });
229
+ /** Send `sent`, again on a fresh stub while a persisted call's failure is transient. */
230
+ const send = <A>(
231
+ method: (target: EntityObjectStub) => Effect.Effect<A, unknown>,
232
+ ): Effect.Effect<A, PersistenceError> =>
233
+ Effect.suspend(() => method(stub())).pipe(
234
+ Effect.retry({
235
+ while: (error) => persisted && host.isRetryable(error),
236
+ schedule: Schedule.exponential("100 millis"),
237
+ times: 3,
238
+ }),
239
+ Effect.mapError((cause) => PersistenceError.make({ cause })),
240
+ );
241
+ /** Interrupting the caller interrupts the handler. */
242
+ const interruptible = <A, E>(sent: Call, effect: Effect.Effect<A, E>) =>
243
+ Effect.onInterrupt(effect, () => Effect.ignore(stub().interrupt(sent.requestId)));
244
+ const answer = (reply: Reply) =>
245
+ Effect.gen(function* () {
246
+ if (DEFECT in reply) return yield* Effect.die(new Error(reply[DEFECT]));
247
+ if ("rejected" in reply) return yield* rejectionError(address, reply.rejected);
248
+ const exit = (yield* decode(exitCodec(rpc), reply.exit)) as Exit.Exit<unknown, unknown>;
249
+ return yield* exit;
250
+ });
251
+ methods[tag] = RpcSchema.isStreamSchema(rpc.successSchema)
252
+ ? (payload: unknown, options?: { readonly headers?: Headers.Input }) =>
253
+ Stream.unwrap(
254
+ Effect.map(message(payload, options), (sent) =>
255
+ stub()
256
+ .stream(JSON.stringify(sent))
257
+ .pipe(
258
+ Stream.mapError((cause) => PersistenceError.make({ cause })),
259
+ Stream.mapEffect((frame) =>
260
+ Effect.gen(function* () {
261
+ const parsed = JSON.parse(frame) as Frame;
262
+ if ("chunk" in parsed) {
263
+ return yield* Effect.forEach(parsed.chunk, (value) =>
264
+ decode(chunkCodec(rpc), value),
265
+ );
266
+ }
267
+ yield* answer(parsed);
268
+ return [];
269
+ }),
270
+ ),
271
+ Stream.flatMap((values) => Stream.fromIterable(values)),
272
+ Stream.onExit((exit) =>
273
+ Exit.hasInterrupts(exit)
274
+ ? Effect.ignore(stub().interrupt(sent.requestId))
275
+ : Effect.void,
276
+ ),
277
+ ),
278
+ ),
279
+ )
280
+ : (
281
+ payload: unknown,
282
+ options?: { readonly discard?: boolean; readonly headers?: Headers.Input },
283
+ ) =>
284
+ Effect.gen(function* () {
285
+ const sent = yield* message(payload, options);
286
+ const text = JSON.stringify(sent);
287
+ if (options?.discard === true) {
288
+ const reply = yield* send((target) => call<string>(target.tell(text)));
289
+ const parsed = JSON.parse(reply) as Partial<Extract<Reply, { rejected: Rejection }>>;
290
+ if (parsed.rejected !== undefined) {
291
+ return yield* rejectionError(address, parsed.rejected);
292
+ }
293
+ return;
294
+ }
295
+ const reply = yield* interruptible(
296
+ sent,
297
+ send((target) => call<string>(target.ask(text))),
298
+ );
299
+ return yield* answer(JSON.parse(reply) as Reply);
300
+ });
301
+ }
302
+ return methods;
303
+ };
304
+
305
+ const unused = (operation: string) =>
306
+ Effect.die(new NotHosted({ message: `Sharding.${operation} is not used on durable objects.` }));
307
+
308
+ /** `Sharding` for a process whose entities are durable objects. */
309
+ export const makeObjectSharding = (
310
+ objects: () => Namespace<EntityObjectStub>,
311
+ host: ObjectHost,
312
+ ): Sharding.Sharding["Service"] =>
313
+ ({
314
+ getShardId: (_entityId: string, group: string) => ShardId.make(group, 1),
315
+ hasShardId: () => true,
316
+ getSnowflake: Effect.map(Clock.currentTimeMillis, (now) =>
317
+ Snowflake.Snowflake(BigInt(now) << 22n),
318
+ ),
319
+ isShutdown: Effect.succeed(false),
320
+ makeClient: (entity: AnyEntity) =>
321
+ Effect.succeed((entityId: string) =>
322
+ clientOf(entity, entityId, objects, host, (type) => registry.entities.has(type)),
323
+ ),
324
+ getRegistrationEvents: Stream.empty,
325
+ registerEntity: () => unused("registerEntity"),
326
+ registerSingleton: () => unused("registerSingleton"),
327
+ send: () => unused("send"),
328
+ sendOutgoing: () => unused("sendOutgoing"),
329
+ notify: () => unused("notify"),
330
+ reset: () => Effect.succeed(false),
331
+ pollStorage: Effect.void,
332
+ activeEntityCount: Effect.sync(() => registry.entities.size),
333
+ }) as unknown as Sharding.Sharding["Service"];
334
+
335
+ /** The services everything hosted runs with, beyond its own: the cluster's `Sharding`. */
336
+ const withSharding = (context: Context.Context<never>) =>
337
+ registry.sharding === undefined
338
+ ? context
339
+ : Context.add(context, Sharding.Sharding, registry.sharding);
340
+
341
+ // ---------------------------------------------------------------- entities
342
+
343
+ /**
344
+ * One entity instance, in the object named `name`: the instance runs as
345
+ * Effect Cluster's runners run it (see `EntityRuntime.ts`), and answers its
346
+ * callers in JSON text.
347
+ */
348
+ export const makeEntityObject = Effect.fn(function* (options: {
349
+ readonly name: string;
350
+ readonly host: ObjectHost;
351
+ readonly storage: EntityStorage;
352
+ /** The object's own services, which its handlers run with. */
353
+ readonly runtime: Context.Context<RuntimeContext>;
354
+ readonly maxEncodedBytes?: number;
355
+ readonly waitUntil: (effect: Effect.Effect<unknown>) => Effect.Effect<void>;
356
+ }) {
357
+ const { type, id } = parseEntityObjectName(options.name);
358
+ const hosted = registry.entities.get(type);
359
+ if (hosted === undefined) {
360
+ return yield* Effect.die(
361
+ new NotHosted({ message: `This pool hosts no Cluster.Entity '${type}'.` }),
362
+ );
363
+ }
364
+ const entity = yield* makeEntityRuntime({
365
+ hosted,
366
+ address: addressOf(options.host, type, id),
367
+ runnerAddress: RunnerAddress.make(options.name, 0),
368
+ context: Context.merge(withSharding(hosted.context), options.runtime),
369
+ storage: options.storage,
370
+ maxEncodedBytes: options.maxEncodedBytes,
371
+ waitUntil: options.waitUntil,
372
+ });
373
+ return {
374
+ ask: (message: string) => reportDefects(entity.ask(JSON.parse(message) as Call)),
375
+ tell: (message: string) => reportDefects(entity.tell(JSON.parse(message) as Call)),
376
+ stream: (message: string) => entity.stream(JSON.parse(message) as Call),
377
+ interrupt: (requestId: string) => entity.interrupt(requestId),
378
+ alarm: () => entity.alarm,
379
+ };
380
+ });
381
+
382
+ // ---------------------------------------------------------------- singletons and crons
383
+
384
+ /** How often a singleton's object checks that it is still running. */
385
+ const SINGLETON_HEARTBEAT = 10_000;
386
+
387
+ /**
388
+ * One singleton or cron, in the object named `name`, kept awake by its
389
+ * alarm. A singleton's object runs its fiber and checks on it every few
390
+ * seconds, restarting it if its object was restarted; a cron's object wakes
391
+ * at each scheduled time and runs the job once.
392
+ */
393
+ export const makeScheduledObject = Effect.fn(function* (options: {
394
+ readonly name: string;
395
+ readonly storage: Pick<EntityStorage, "exec" | "setAlarm">;
396
+ readonly runtime: Context.Context<RuntimeContext>;
397
+ }) {
398
+ const { exec, setAlarm } = options.storage;
399
+ const hosted = registry.scheduled.get(options.name.slice(options.name.indexOf(":") + 1));
400
+ if (hosted === undefined) {
401
+ return yield* Effect.die(
402
+ new NotHosted({ message: `This pool hosts no singleton or cron '${options.name}'.` }),
403
+ );
404
+ }
405
+ yield* exec(
406
+ "CREATE TABLE IF NOT EXISTS cluster_schedule (key TEXT PRIMARY KEY, value INTEGER NOT NULL)",
407
+ );
408
+ const context = Context.merge(withSharding(hosted.context), options.runtime);
409
+
410
+ if (hosted.kind === "singleton") {
411
+ let fiber: Fiber.Fiber<unknown, unknown> | undefined;
412
+ const beat = Effect.gen(function* () {
413
+ if (fiber === undefined || fiber.pollUnsafe() !== undefined) {
414
+ fiber = Effect.runForkWith(context)(
415
+ hosted.run.pipe(
416
+ Effect.andThen(Effect.never),
417
+ Effect.scoped,
418
+ Effect.tapCause((cause) => Effect.logError("Singleton failed", cause)),
419
+ // The object's next heartbeat starts it again.
420
+ Effect.ignore,
421
+ ),
422
+ );
423
+ }
424
+ yield* setAlarm((yield* Clock.currentTimeMillis) + SINGLETON_HEARTBEAT);
425
+ });
426
+ return { ensure: () => reportDefects(beat), alarm: () => beat };
427
+ }
428
+
429
+ const nextAfter = (time: number) => Cron.next(hosted.cron, new Date(time)).getTime();
430
+ const readNext = Effect.map(
431
+ exec<{ value: number }>("SELECT value FROM cluster_schedule WHERE key = 'next'"),
432
+ ([row]) => row?.value,
433
+ );
434
+ const writeNext = (next: number) =>
435
+ Effect.andThen(
436
+ exec("INSERT OR REPLACE INTO cluster_schedule (key, value) VALUES ('next', ?)", next),
437
+ setAlarm(next),
438
+ );
439
+ const fire = Effect.gen(function* () {
440
+ const due = yield* readNext;
441
+ const now = yield* Clock.currentTimeMillis;
442
+ if (due === undefined) return yield* writeNext(nextAfter(now));
443
+ if (due > now) return yield* setAlarm(due);
444
+ // A run due longer ago than `skipIfOlderThan` (after an outage) is skipped.
445
+ const skipAfter = hosted.options.skipIfOlderThan;
446
+ const stale =
447
+ skipAfter !== undefined && now - due > Duration.toMillis(Duration.fromInputUnsafe(skipAfter));
448
+ if (!stale) {
449
+ yield* hosted.run.pipe(
450
+ Effect.scoped,
451
+ Effect.provideService(ClusterCron.CurrentScheduledTime, DateTime.makeUnsafe(due)),
452
+ Effect.provideContext(context),
453
+ Effect.tapCause((cause) => Effect.logError("Cron run failed", cause)),
454
+ Effect.ignore,
455
+ );
456
+ }
457
+ yield* writeNext(
458
+ nextAfter(hosted.options.calculateNextRunFromPrevious ? due : Math.max(now, due)),
459
+ );
460
+ });
461
+ // Its alarm is set again even when its next time is known: the alarm may
462
+ // be what was lost.
463
+ const ensure = Effect.gen(function* () {
464
+ const due = yield* readNext;
465
+ yield* due === undefined
466
+ ? writeNext(nextAfter(yield* Clock.currentTimeMillis))
467
+ : setAlarm(Math.max(due, yield* Clock.currentTimeMillis));
468
+ });
469
+ return { ensure: () => reportDefects(ensure), alarm: () => fire };
470
+ });
471
+
472
+ /** Wake every singleton and cron, so their objects start their alarms. */
473
+ export const wakeScheduled = (objects: Namespace<ScheduledObjectStub>) =>
474
+ Effect.forEach(
475
+ registry.scheduled.values(),
476
+ (hosted) =>
477
+ objects.getByName(scheduledObjectName(hosted.kind, hosted.name)).ensure().pipe(Effect.ignore),
478
+ { concurrency: "unbounded", discard: true },
479
+ );
480
+
481
+ // ---------------------------------------------------------------- hosting
482
+
483
+ /** A class the pool hosts: its name, and its module's export. */
484
+ export interface HostedDefinition {
485
+ readonly name: string;
486
+ readonly value: unknown;
487
+ }
488
+
489
+ // oxlint-disable-next-line effecttsgo/service-not-as-class
490
+ const entrypointTag = Context.Service<any, any>(Self.key);
491
+
492
+ /**
493
+ * Build each class's Construction phase, keeping what its runtime context
494
+ * hosts. A Layer that hosts its entities itself (a Domain model's) provides
495
+ * no class: it hands them to `RunnerHost`, with a Layer to launch beside
496
+ * them once the workflow engine and Sharding are up.
497
+ */
498
+ const buildHosted = (definitions: ReadonlyArray<HostedDefinition>, scope: Scope.Scope) =>
499
+ Effect.gen(function* () {
500
+ const hosted: Array<Hosted> = [];
501
+ const launched: Array<Layer.Layer<never, never, any>> = [];
502
+ const runner = RunnerHost.of({
503
+ host: (entity, layer) =>
504
+ Effect.sync(() => {
505
+ if (!hosted.some((h) => h.kind === "entity" && h.entity.type === entity.entity.type))
506
+ hosted.push(entity);
507
+ launched.push(layer);
508
+ }),
509
+ });
510
+ for (const { value } of definitions) {
511
+ const declaration =
512
+ (value as { [PlatformDeclaration]?: unknown } | undefined)?.[PlatformDeclaration] ?? value;
513
+ const context = yield* Layer.buildWithScope(
514
+ makeEntrypointLayer(entrypointTag, declaration),
515
+ scope,
516
+ ).pipe(Effect.provideService(RunnerHost, runner));
517
+ const self = Context.getOption(context, entrypointTag);
518
+ if (Option.isNone(self)) continue;
519
+ const runtime = self.value.RuntimeContext as HostedRuntimeContext<Hosted>;
520
+ const built = runtime.hosted();
521
+ if (built === undefined) {
522
+ return yield* Effect.die(
523
+ new HasNoPool({
524
+ message: `${runtime.Type} '${runtime.id}' did not finish its Construction phase.`,
525
+ }),
526
+ );
527
+ }
528
+ hosted.push(built);
529
+ }
530
+ return { hosted, launched };
531
+ });
532
+
533
+ /**
534
+ * Build every class a pool hosts and register it for the pool's objects:
535
+ * entities, singletons and crons by name, and each workflow on `engine`,
536
+ * whose executions are objects too. Runs once per process, in `scope`.
537
+ */
538
+ export const hostClasses = Effect.fn(function* (options: {
539
+ readonly definitions: ReadonlyArray<HostedDefinition>;
540
+ readonly scope: Scope.Scope;
541
+ readonly sharding: Sharding.Sharding["Service"];
542
+ /** The workflow engine, which registers each workflow's class in {@link registry}. */
543
+ readonly engine: Layer.Layer<WorkflowEngine.WorkflowEngine>;
544
+ }) {
545
+ registry.sharding = options.sharding;
546
+ const { hosted, launched } = yield* buildHosted(options.definitions, options.scope).pipe(
547
+ Effect.provide(Layer.mergeAll(FetchHttpClient.layer, stackFromEnv)),
548
+ );
549
+ const entities = hosted.filter((entry): entry is HostedEntity => entry.kind === "entity");
550
+ const workflows = hosted.filter((entry): entry is HostedWorkflow => entry.kind === "workflow");
551
+ for (const entry of entities) registry.entities.set(entry.entity.type, entry);
552
+ for (const entry of hosted) {
553
+ if (entry.kind === "singleton" || entry.kind === "cron") {
554
+ registry.scheduled.set(entry.name, entry);
555
+ }
556
+ }
557
+ // Each handler runs with the services its Construction phase had, and the
558
+ // cluster's Sharding.
559
+ const sharding = Layer.succeed(Sharding.Sharding, options.sharding);
560
+ const workflowLayers = workflows.map((entry) =>
561
+ (entry.workflow as EffectWorkflow.Workflow<string, any, any, any>).toLayer(
562
+ (payload, executionId) =>
563
+ entry
564
+ .execute(payload, executionId)
565
+ .pipe(
566
+ Effect.provideContext(Context.add(entry.context, Sharding.Sharding, options.sharding)),
567
+ ) as Effect.Effect<any, any, never>,
568
+ ),
569
+ );
570
+ const services = yield* Layer.buildWithScope(
571
+ Layer.mergeAll(...(workflowLayers as [Layer.Layer<never>]), ...launched).pipe(
572
+ Layer.provideMerge(options.engine),
573
+ Layer.provideMerge(sharding),
574
+ ),
575
+ options.scope,
576
+ );
577
+ return { entities, workflows, services };
578
+ });