@effect/platform-cloudflare 0.0.0-stage → 4.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +434 -0
- package/CLAUDE.md +434 -0
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/ai-docs/README.md +44 -0
- package/ai-docs/package.json +36 -0
- package/ai-docs/src/01_effect/01_basics/01_effect-gen.ts +30 -0
- package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +52 -0
- package/ai-docs/src/01_effect/01_basics/10_creating-effects.ts +74 -0
- package/ai-docs/src/01_effect/01_basics/index.md +7 -0
- package/ai-docs/src/01_effect/02_schema/10_schema-basics.ts +43 -0
- package/ai-docs/src/01_effect/02_schema/index.md +7 -0
- package/ai-docs/src/01_effect/03_services/01_service.ts +45 -0
- package/ai-docs/src/01_effect/03_services/10_reference.ts +10 -0
- package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +70 -0
- package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +66 -0
- package/ai-docs/src/01_effect/03_services/index.md +5 -0
- package/ai-docs/src/01_effect/04_errors/01_error-handling.ts +30 -0
- package/ai-docs/src/01_effect/04_errors/10_catch-tags.ts +24 -0
- package/ai-docs/src/01_effect/04_errors/20_reason-errors.ts +64 -0
- package/ai-docs/src/01_effect/04_errors/index.md +1 -0
- package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +105 -0
- package/ai-docs/src/01_effect/05_resources/20_layer-side-effects.ts +31 -0
- package/ai-docs/src/01_effect/05_resources/30_layer-map.ts +86 -0
- package/ai-docs/src/01_effect/05_resources/index.md +3 -0
- package/ai-docs/src/01_effect/06_running/10_run-main.ts +30 -0
- package/ai-docs/src/01_effect/06_running/20_layer-launch.ts +27 -0
- package/ai-docs/src/01_effect/06_running/index.md +1 -0
- package/ai-docs/src/01_effect/07_pubsub/10_pubsub.ts +56 -0
- package/ai-docs/src/01_effect/07_pubsub/index.md +3 -0
- package/ai-docs/src/03_stream/10_creating-streams.ts +103 -0
- package/ai-docs/src/03_stream/20_consuming-streams.ts +137 -0
- package/ai-docs/src/03_stream/30_encoding.ts +163 -0
- package/ai-docs/src/03_stream/index.md +4 -0
- package/ai-docs/src/04_integration/10_managed-runtime.ts +129 -0
- package/ai-docs/src/04_integration/index.md +5 -0
- package/ai-docs/src/05_batching/10_request-resolver.ts +89 -0
- package/ai-docs/src/05_batching/index.md +3 -0
- package/ai-docs/src/06_schedule/10_schedules.ts +111 -0
- package/ai-docs/src/06_schedule/index.md +3 -0
- package/ai-docs/src/07_datetime/10_creating-and-formatting.ts +30 -0
- package/ai-docs/src/07_datetime/20_time-zones.ts +44 -0
- package/ai-docs/src/07_datetime/index.md +5 -0
- package/ai-docs/src/08_observability/10_logging.ts +66 -0
- package/ai-docs/src/08_observability/20_otlp-tracing.ts +95 -0
- package/ai-docs/src/08_observability/index.md +7 -0
- package/ai-docs/src/09_testing/10_effect-tests.ts +55 -0
- package/ai-docs/src/09_testing/20_layer-tests.ts +138 -0
- package/ai-docs/src/09_testing/index.md +1 -0
- package/ai-docs/src/10_predicate/01_basics.ts +14 -0
- package/ai-docs/src/10_predicate/index.md +9 -0
- package/ai-docs/src/40_sql/10_basics.ts +175 -0
- package/ai-docs/src/40_sql/index.md +6 -0
- package/ai-docs/src/50_http-client/10_basics.ts +102 -0
- package/ai-docs/src/50_http-client/index.md +3 -0
- package/ai-docs/src/51_http-server/10_basics.ts +118 -0
- package/ai-docs/src/51_http-server/20_testing.ts +100 -0
- package/ai-docs/src/51_http-server/fixtures/api/Api.ts +14 -0
- package/ai-docs/src/51_http-server/fixtures/api/Authorization.ts +36 -0
- package/ai-docs/src/51_http-server/fixtures/api/System.ts +10 -0
- package/ai-docs/src/51_http-server/fixtures/api/Users.ts +107 -0
- package/ai-docs/src/51_http-server/fixtures/domain/User.ts +21 -0
- package/ai-docs/src/51_http-server/fixtures/domain/UserErrors.ts +22 -0
- package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +37 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +80 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users.ts +183 -0
- package/ai-docs/src/51_http-server/index.md +3 -0
- package/ai-docs/src/52_graphql/10_basics.ts +117 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +118 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +124 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/ai-docs/src/60_child-process/10_working-with-child-processes.ts +117 -0
- package/ai-docs/src/60_child-process/index.md +3 -0
- package/ai-docs/src/70_cli/10_basics.ts +162 -0
- package/ai-docs/src/70_cli/index.md +5 -0
- package/ai-docs/src/71_ai/10_language-model.ts +156 -0
- package/ai-docs/src/71_ai/20_tools.ts +226 -0
- package/ai-docs/src/71_ai/30_chat.ts +158 -0
- package/ai-docs/src/71_ai/fixtures/domain/LaunchPlan.ts +9 -0
- package/ai-docs/src/71_ai/index.md +5 -0
- package/ai-docs/src/80_cluster/10_entities.ts +97 -0
- package/ai-docs/src/80_cluster/index.md +4 -0
- package/ai-docs/src/index.md +10 -0
- package/ai-docs/tsconfig.json +28 -0
- package/dist/AlchemyCloudflareCluster.d.ts +134 -0
- package/dist/AlchemyCloudflareCluster.d.ts.map +1 -0
- package/dist/AlchemyCloudflareCluster.js +111 -0
- package/dist/AlchemyCloudflareCluster.js.map +1 -0
- package/dist/CloudflareCluster.d.ts +196 -0
- package/dist/CloudflareCluster.d.ts.map +1 -0
- package/dist/CloudflareCluster.js +533 -0
- package/dist/CloudflareCluster.js.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts +279 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.js +295 -0
- package/dist/CloudflareDurableObjectPrograms.js.map +1 -0
- package/dist/CloudflareDurableObjects.d.ts +149 -0
- package/dist/CloudflareDurableObjects.d.ts.map +1 -0
- package/dist/CloudflareDurableObjects.js +337 -0
- package/dist/CloudflareDurableObjects.js.map +1 -0
- package/dist/CloudflarePersistedQueue.d.ts +36 -0
- package/dist/CloudflarePersistedQueue.d.ts.map +1 -0
- package/dist/CloudflarePersistedQueue.js +122 -0
- package/dist/CloudflarePersistedQueue.js.map +1 -0
- package/dist/CloudflareWorkflowEngine.d.ts +72 -0
- package/dist/CloudflareWorkflowEngine.d.ts.map +1 -0
- package/dist/CloudflareWorkflowEngine.js +187 -0
- package/dist/CloudflareWorkflowEngine.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/alchemyCluster.d.ts +2 -0
- package/dist/internal/alchemyCluster.d.ts.map +1 -0
- package/dist/internal/alchemyCluster.js +75 -0
- package/dist/internal/alchemyCluster.js.map +1 -0
- package/dist/internal/boundedMap.d.ts +3 -0
- package/dist/internal/boundedMap.d.ts.map +1 -0
- package/dist/internal/boundedMap.js +15 -0
- package/dist/internal/boundedMap.js.map +1 -0
- package/dist/internal/cluster.d.ts +57 -0
- package/dist/internal/cluster.d.ts.map +1 -0
- package/dist/internal/cluster.js +2 -0
- package/dist/internal/cluster.js.map +1 -0
- package/dist/internal/clusterName.d.ts +2 -0
- package/dist/internal/clusterName.d.ts.map +1 -0
- package/dist/internal/clusterName.js +16 -0
- package/dist/internal/clusterName.js.map +1 -0
- package/dist/internal/entityKeepAlive.d.ts +2 -0
- package/dist/internal/entityKeepAlive.d.ts.map +1 -0
- package/dist/internal/entityKeepAlive.js +73 -0
- package/dist/internal/entityKeepAlive.js.map +1 -0
- package/dist/internal/entityMailbox.d.ts +2 -0
- package/dist/internal/entityMailbox.d.ts.map +1 -0
- package/dist/internal/entityMailbox.js +160 -0
- package/dist/internal/entityMailbox.js.map +1 -0
- package/dist/internal/entityRegistry.d.ts +16 -0
- package/dist/internal/entityRegistry.d.ts.map +1 -0
- package/dist/internal/entityRegistry.js +22 -0
- package/dist/internal/entityRegistry.js.map +1 -0
- package/dist/internal/entityReply.d.ts +2 -0
- package/dist/internal/entityReply.d.ts.map +1 -0
- package/dist/internal/entityReply.js +35 -0
- package/dist/internal/entityReply.js.map +1 -0
- package/dist/internal/entityRuntime.d.ts +2 -0
- package/dist/internal/entityRuntime.d.ts.map +1 -0
- package/dist/internal/entityRuntime.js +823 -0
- package/dist/internal/entityRuntime.js.map +1 -0
- package/dist/internal/entitySql.d.ts +2 -0
- package/dist/internal/entitySql.d.ts.map +1 -0
- package/dist/internal/entitySql.js +29 -0
- package/dist/internal/entitySql.js.map +1 -0
- package/dist/internal/entityStorage.d.ts +2 -0
- package/dist/internal/entityStorage.d.ts.map +1 -0
- package/dist/internal/entityStorage.js +98 -0
- package/dist/internal/entityStorage.js.map +1 -0
- package/dist/internal/entityWire.d.ts +2 -0
- package/dist/internal/entityWire.d.ts.map +1 -0
- package/dist/internal/entityWire.js +146 -0
- package/dist/internal/entityWire.js.map +1 -0
- package/dist/internal/queueRuntime.d.ts +2 -0
- package/dist/internal/queueRuntime.d.ts.map +1 -0
- package/dist/internal/queueRuntime.js +88 -0
- package/dist/internal/queueRuntime.js.map +1 -0
- package/dist/internal/queueStorage.d.ts +2 -0
- package/dist/internal/queueStorage.d.ts.map +1 -0
- package/dist/internal/queueStorage.js +129 -0
- package/dist/internal/queueStorage.js.map +1 -0
- package/dist/internal/registry.d.ts +10 -0
- package/dist/internal/registry.d.ts.map +1 -0
- package/dist/internal/registry.js +24 -0
- package/dist/internal/registry.js.map +1 -0
- package/dist/internal/singletonRegistry.d.ts +2 -0
- package/dist/internal/singletonRegistry.d.ts.map +1 -0
- package/dist/internal/singletonRegistry.js +9 -0
- package/dist/internal/singletonRegistry.js.map +1 -0
- package/dist/internal/singletonRuntime.d.ts +2 -0
- package/dist/internal/singletonRuntime.d.ts.map +1 -0
- package/dist/internal/singletonRuntime.js +42 -0
- package/dist/internal/singletonRuntime.js.map +1 -0
- package/dist/internal/singletonStorage.d.ts +2 -0
- package/dist/internal/singletonStorage.d.ts.map +1 -0
- package/dist/internal/singletonStorage.js +34 -0
- package/dist/internal/singletonStorage.js.map +1 -0
- package/dist/internal/transport.d.ts +2 -0
- package/dist/internal/transport.d.ts.map +1 -0
- package/dist/internal/transport.js +49 -0
- package/dist/internal/transport.js.map +1 -0
- package/dist/internal/workflowRegistry.d.ts +2 -0
- package/dist/internal/workflowRegistry.d.ts.map +1 -0
- package/dist/internal/workflowRegistry.js +29 -0
- package/dist/internal/workflowRegistry.js.map +1 -0
- package/dist/internal/workflowRuntime.d.ts +2 -0
- package/dist/internal/workflowRuntime.d.ts.map +1 -0
- package/dist/internal/workflowRuntime.js +322 -0
- package/dist/internal/workflowRuntime.js.map +1 -0
- package/dist/internal/workflowStorage.d.ts +2 -0
- package/dist/internal/workflowStorage.d.ts.map +1 -0
- package/dist/internal/workflowStorage.js +113 -0
- package/dist/internal/workflowStorage.js.map +1 -0
- package/dist/internal/workflowWire.d.ts +2 -0
- package/dist/internal/workflowWire.d.ts.map +1 -0
- package/dist/internal/workflowWire.js +52 -0
- package/dist/internal/workflowWire.js.map +1 -0
- package/package.json +74 -3
- package/src/AlchemyCloudflareCluster.ts +200 -0
- package/src/CloudflareCluster.ts +704 -0
- package/src/CloudflareDurableObjectPrograms.ts +543 -0
- package/src/CloudflareDurableObjects.ts +430 -0
- package/src/CloudflarePersistedQueue.ts +189 -0
- package/src/CloudflareWorkflowEngine.ts +220 -0
- package/src/index.ts +38 -0
- package/src/internal/alchemyCluster.ts +120 -0
- package/src/internal/boundedMap.ts +15 -0
- package/src/internal/cluster.ts +57 -0
- package/src/internal/clusterName.ts +19 -0
- package/src/internal/entityKeepAlive.ts +121 -0
- package/src/internal/entityMailbox.ts +324 -0
- package/src/internal/entityRegistry.ts +46 -0
- package/src/internal/entityReply.ts +54 -0
- package/src/internal/entityRuntime.ts +1318 -0
- package/src/internal/entitySql.ts +60 -0
- package/src/internal/entityStorage.ts +168 -0
- package/src/internal/entityWire.ts +204 -0
- package/src/internal/queueRuntime.ts +174 -0
- package/src/internal/queueStorage.ts +255 -0
- package/src/internal/registry.ts +31 -0
- package/src/internal/singletonRegistry.ts +21 -0
- package/src/internal/singletonRuntime.ts +88 -0
- package/src/internal/singletonStorage.ts +64 -0
- package/src/internal/transport.ts +67 -0
- package/src/internal/workflowRegistry.ts +78 -0
- package/src/internal/workflowRuntime.ts +443 -0
- package/src/internal/workflowStorage.ts +234 -0
- package/src/internal/workflowWire.ts +123 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import * as Cause from "effect/Cause";
|
|
2
|
+
import * as ClusterMetrics from "effect/cluster/ClusterMetrics";
|
|
3
|
+
import { isUninterruptibleForServer, Persisted } from "effect/cluster/ClusterSchema";
|
|
4
|
+
import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/cluster/Entity";
|
|
5
|
+
import * as Envelope from "effect/cluster/Envelope";
|
|
6
|
+
import * as Reply from "effect/cluster/Reply";
|
|
7
|
+
import * as RunnerAddress from "effect/cluster/RunnerAddress";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
import * as Deferred from "effect/Deferred";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as Exit from "effect/Exit";
|
|
12
|
+
import * as Fiber from "effect/Fiber";
|
|
13
|
+
import { identity } from "effect/Function";
|
|
14
|
+
import * as Metric from "effect/Metric";
|
|
15
|
+
import * as Option from "effect/Option";
|
|
16
|
+
import * as Pull from "effect/Pull";
|
|
17
|
+
import * as Queue from "effect/Queue";
|
|
18
|
+
import * as Result from "effect/Result";
|
|
19
|
+
import * as Rpc from "effect/rpc/Rpc";
|
|
20
|
+
import * as RpcMessage from "effect/rpc/RpcMessage";
|
|
21
|
+
import * as RpcSchema from "effect/rpc/RpcSchema";
|
|
22
|
+
import * as RpcServer from "effect/rpc/RpcServer";
|
|
23
|
+
import * as Scope from "effect/Scope";
|
|
24
|
+
import * as Semaphore from "effect/Semaphore";
|
|
25
|
+
import { encodeName } from "./clusterName.js";
|
|
26
|
+
import { ackChunk, clearReplies, completeTell, loadMessage, loadNextReply, loadUnprocessed, persistRequest, saveReply } from "./entityMailbox.js";
|
|
27
|
+
import { getEntityRegistration, keepAliveHeartbeatMillis } from "./entityRegistry.js";
|
|
28
|
+
import { CurrentEntityName, CurrentReplyRegistry, makeReplyRegistry } from "./entityReply.js";
|
|
29
|
+
import { makeEntitySql } from "./entitySql.js";
|
|
30
|
+
import { armAlarm, earliestDeliverAt, loadEntityState, nextAlarmAt, saveKeepAlive, withTransaction } from "./entityStorage.js";
|
|
31
|
+
import { decodeReplyFor, decodeRequest, encodeReplyFor, peekEnvelopeTag } from "./entityWire.js";
|
|
32
|
+
/** @internal */
|
|
33
|
+
export const makeEntityRuntime = /*#__PURE__*/Effect.fnUntraced(function* (registration, address, nextId, entityName = encodeName(address.entityType, address.entityId), keepAlive, replyRegistry, entitySql) {
|
|
34
|
+
let cached;
|
|
35
|
+
let building;
|
|
36
|
+
const metricContext = Context.merge(registration.context, Metric.CurrentMetricAttributes.context({
|
|
37
|
+
type: registration.entity.type
|
|
38
|
+
}));
|
|
39
|
+
// Bounds concurrent handler executions from the entity's `concurrency`
|
|
40
|
+
// build option; `"unbounded"` leaves handlers unlimited.
|
|
41
|
+
const concurrency = registration.options?.concurrency ?? 1;
|
|
42
|
+
const handlerSemaphore = concurrency === "unbounded" ? undefined : Semaphore.makeUnsafe(concurrency);
|
|
43
|
+
const serverRequests = new Map();
|
|
44
|
+
let nextServerRequestId = 0;
|
|
45
|
+
// Set while `server.write` runs; a request the server rejects before it
|
|
46
|
+
// forks a handler is answered with a `Defect` that carries no request id.
|
|
47
|
+
let writing;
|
|
48
|
+
const onFromServer = response => {
|
|
49
|
+
switch (response._tag) {
|
|
50
|
+
case "Exit":
|
|
51
|
+
{
|
|
52
|
+
const request = serverRequests.get(Number(response.requestId));
|
|
53
|
+
if (request !== undefined) Deferred.doneUnsafe(request.exit, Effect.succeed(response.exit));
|
|
54
|
+
return Effect.void;
|
|
55
|
+
}
|
|
56
|
+
case "Chunk":
|
|
57
|
+
{
|
|
58
|
+
const request = serverRequests.get(Number(response.requestId));
|
|
59
|
+
if (request === undefined || request.discard || request.interrupted) return Effect.void;
|
|
60
|
+
const progress = request.progress;
|
|
61
|
+
const reply = new Reply.Chunk({
|
|
62
|
+
requestId: request.envelope.requestId,
|
|
63
|
+
id: nextId(),
|
|
64
|
+
sequence: progress.sequence++,
|
|
65
|
+
values: response.values
|
|
66
|
+
});
|
|
67
|
+
return request.respond(reply).pipe(Effect.tap(() => Effect.sync(() => {
|
|
68
|
+
progress.lastSentChunk = Option.some(reply);
|
|
69
|
+
})),
|
|
70
|
+
// The server ends the request without an exit after a failed chunk
|
|
71
|
+
// write, so the failure becomes the request's exit here.
|
|
72
|
+
Effect.onError(cause => Effect.sync(() => {
|
|
73
|
+
if (!Cause.hasInterruptsOnly(cause)) {
|
|
74
|
+
Deferred.doneUnsafe(request.exit, Effect.succeed(Exit.failCause(cause)));
|
|
75
|
+
}
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
case "Defect":
|
|
79
|
+
{
|
|
80
|
+
if (writing !== undefined) Deferred.doneUnsafe(writing.exit, Effect.succeed(Exit.die(response.defect)));
|
|
81
|
+
return Effect.void;
|
|
82
|
+
}
|
|
83
|
+
case "ClientEnd":
|
|
84
|
+
return Effect.void;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
// Closes a retired server once nothing holds a lease on it. `closed` is set
|
|
88
|
+
// synchronously, so a lease taken afterwards moves to the current build.
|
|
89
|
+
const closeIfDrained = handle => Effect.suspend(() => {
|
|
90
|
+
if (!handle.retired || handle.leases > 0 || handle.closed) return Effect.void;
|
|
91
|
+
handle.closed = true;
|
|
92
|
+
return Scope.close(handle.scope, Exit.void);
|
|
93
|
+
});
|
|
94
|
+
const invalidate = Effect.fnUntraced(function* () {
|
|
95
|
+
if (cached === undefined) return;
|
|
96
|
+
const scope = cached.scope;
|
|
97
|
+
const server = cached.server;
|
|
98
|
+
cached = undefined;
|
|
99
|
+
server.retired = true;
|
|
100
|
+
yield* closeIfDrained(server);
|
|
101
|
+
// The closing build's holders are gone with it. `EntityResource` skips
|
|
102
|
+
// `keepAlive(false)` while its scope shuts down, so keeping the count
|
|
103
|
+
// would leak a holder into the rebuild.
|
|
104
|
+
keepAlive?.resetHolders();
|
|
105
|
+
yield* Scope.close(scope, Exit.void).pipe(Effect.ensuring(Effect.sync(() => {
|
|
106
|
+
ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext);
|
|
107
|
+
})));
|
|
108
|
+
});
|
|
109
|
+
const buildHandlers = Effect.fnUntraced(function* () {
|
|
110
|
+
const scope = yield* Scope.make();
|
|
111
|
+
let context = registration.context.pipe(Context.add(CurrentAddress, address), Context.add(CurrentRunnerAddress, RunnerAddress.make(`${address.entityType}/${address.entityId}`, 0)), Context.add(CurrentEntityName, entityName), Context.add(Scope.Scope, scope));
|
|
112
|
+
if (keepAlive !== undefined) {
|
|
113
|
+
context = Context.add(context, KeepAliveHandler, keepAlive.update);
|
|
114
|
+
}
|
|
115
|
+
if (replyRegistry !== undefined) {
|
|
116
|
+
context = Context.add(context, CurrentReplyRegistry, replyRegistry);
|
|
117
|
+
}
|
|
118
|
+
const {
|
|
119
|
+
handlers,
|
|
120
|
+
server
|
|
121
|
+
} = yield* Effect.gen(function* () {
|
|
122
|
+
if (entitySql !== undefined) {
|
|
123
|
+
context = Context.merge(context, yield* Scope.provide(entitySql.make, scope));
|
|
124
|
+
}
|
|
125
|
+
const handlers = yield* Effect.provideContext(registration.build, context);
|
|
126
|
+
// The server sees each handler's result before it takes a permit; a
|
|
127
|
+
// forked result is marked here so its request skips the entity permit.
|
|
128
|
+
const serverHandlers = {};
|
|
129
|
+
for (const tag of Object.keys(handlers)) {
|
|
130
|
+
const handler = handlers[tag];
|
|
131
|
+
serverHandlers[tag] = (request, {
|
|
132
|
+
requestId
|
|
133
|
+
}) => {
|
|
134
|
+
const result = handler(request);
|
|
135
|
+
if (Rpc.isWrapper(result) && result.fork) {
|
|
136
|
+
const pending = serverRequests.get(Number(requestId));
|
|
137
|
+
if (pending !== undefined) pending.fork = true;
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const handlersContext = yield* Effect.provideContext(registration.entity.protocol.toHandlers(serverHandlers), context);
|
|
143
|
+
const serverScope = yield* Scope.make();
|
|
144
|
+
// Middleware services come from the registration context: `Entity.toLayer`
|
|
145
|
+
// requires them, and `registerEntity` captures that context.
|
|
146
|
+
const server = yield* RpcServer.makeNoSerialization(registration.entity.protocol, {
|
|
147
|
+
onFromServer,
|
|
148
|
+
concurrency: "unbounded",
|
|
149
|
+
disableTracing: true,
|
|
150
|
+
disableClientAcks: true,
|
|
151
|
+
disableFatalDefects: true
|
|
152
|
+
}).pipe(Effect.provideContext(Context.add(Context.merge(context, handlersContext), Scope.Scope, serverScope)));
|
|
153
|
+
const handle = {
|
|
154
|
+
server,
|
|
155
|
+
scope: serverScope,
|
|
156
|
+
leases: 0,
|
|
157
|
+
retired: false,
|
|
158
|
+
closed: false
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
handlers,
|
|
162
|
+
server: handle
|
|
163
|
+
};
|
|
164
|
+
}).pipe(Effect.tapCause(cause => Scope.close(scope, Exit.failCause(cause))));
|
|
165
|
+
ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext);
|
|
166
|
+
// Any event that builds the entity restores a hold persisted before a
|
|
167
|
+
// restart.
|
|
168
|
+
keepAlive?.restore();
|
|
169
|
+
return {
|
|
170
|
+
handlers,
|
|
171
|
+
context,
|
|
172
|
+
scope,
|
|
173
|
+
server
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
const getHandlers = () => Effect.suspend(() => {
|
|
177
|
+
if (cached !== undefined) return Effect.succeed(cached);
|
|
178
|
+
if (building !== undefined) return Deferred.await(building);
|
|
179
|
+
const deferred = Deferred.makeUnsafe();
|
|
180
|
+
building = deferred;
|
|
181
|
+
return Effect.onExit(buildHandlers(), exit => Effect.sync(() => {
|
|
182
|
+
building = undefined;
|
|
183
|
+
if (Exit.isSuccess(exit)) cached = exit.value;
|
|
184
|
+
Deferred.doneUnsafe(deferred, exit);
|
|
185
|
+
}));
|
|
186
|
+
});
|
|
187
|
+
const runWithDefectRetry = effect => {
|
|
188
|
+
const policy = registration.options?.defectRetryPolicy;
|
|
189
|
+
if (policy === undefined) return Effect.exit(effect);
|
|
190
|
+
const retryable = Effect.flatMap(Effect.exit(effect), exit => Exit.isFailure(exit) && Cause.hasDies(exit.cause) ? Effect.fail(exit.cause) : Effect.succeed(exit));
|
|
191
|
+
return Effect.retryOrElse(retryable, policy, cause => Effect.succeed(Exit.failCause(cause)));
|
|
192
|
+
};
|
|
193
|
+
// Takes a lease on the current build's server for one request, including
|
|
194
|
+
// its defect retries. Only the build itself stays interruptible. A server
|
|
195
|
+
// that closed after `getHandlers` returned it is no longer cached, so the
|
|
196
|
+
// lookup runs again.
|
|
197
|
+
const leaseHandlers = restore => Effect.flatMap(restore(getHandlers()), entry => Effect.suspend(() => {
|
|
198
|
+
if (entry.server.closed) return leaseHandlers(restore);
|
|
199
|
+
entry.server.leases++;
|
|
200
|
+
return Effect.succeed(entry);
|
|
201
|
+
}));
|
|
202
|
+
const releaseLease = entry => Effect.suspend(() => {
|
|
203
|
+
entry.server.leases--;
|
|
204
|
+
return closeIfDrained(entry.server);
|
|
205
|
+
});
|
|
206
|
+
const rebuildAfterDefect = invalidate().pipe(Effect.andThen(Effect.catchCause(getHandlers(), () => Effect.void)));
|
|
207
|
+
// Writes one attempt to the server and waits for its exit and for the
|
|
208
|
+
// request scope to close. Interrupting the caller interrupts the server
|
|
209
|
+
// request and still waits, so an `Rpc.uninterruptible` handler finishes
|
|
210
|
+
// before the caller's interruption completes.
|
|
211
|
+
const runOnServer = (server, request, permit) => {
|
|
212
|
+
const id = nextServerRequestId++;
|
|
213
|
+
const settled = Effect.flatMap(Deferred.await(request.exit), exit => request.started ? Effect.as(Deferred.await(request.finished), exit) : Effect.succeed(exit));
|
|
214
|
+
// `finished` completes on every exit of the request effect, including an
|
|
215
|
+
// interrupt while it waits for the permit or before the handler starts.
|
|
216
|
+
// `started` is set inside the `ensuring`, so `settled` only waits for
|
|
217
|
+
// `finished` once the finalizer is registered.
|
|
218
|
+
const onRequest = effect => Effect.ensuring(Effect.suspend(() => {
|
|
219
|
+
request.started = true;
|
|
220
|
+
if (request.interrupted) return Effect.interrupt;
|
|
221
|
+
return request.fork ? effect : permit.acquire(effect);
|
|
222
|
+
}), Effect.sync(() => Deferred.doneUnsafe(request.finished, Effect.void)));
|
|
223
|
+
const write = Effect.suspend(() => {
|
|
224
|
+
serverRequests.set(id, request);
|
|
225
|
+
writing = request;
|
|
226
|
+
return server.write(0, {
|
|
227
|
+
_tag: "Request",
|
|
228
|
+
id: RpcMessage.RequestId(id),
|
|
229
|
+
tag: request.envelope.tag,
|
|
230
|
+
payload: new Request({
|
|
231
|
+
...request.envelope,
|
|
232
|
+
lastSentChunk: request.progress.lastSentChunk
|
|
233
|
+
}),
|
|
234
|
+
headers: request.envelope.headers
|
|
235
|
+
}, {
|
|
236
|
+
onRequest
|
|
237
|
+
}).pipe(Effect.ensuring(Effect.sync(() => {
|
|
238
|
+
writing = undefined;
|
|
239
|
+
})));
|
|
240
|
+
});
|
|
241
|
+
// The write and the interrupt hook are installed without an interruptible
|
|
242
|
+
// gap, so an interrupt that arrives just after the write still reaches
|
|
243
|
+
// the server request.
|
|
244
|
+
return Effect.uninterruptibleMask(restore => Effect.andThen(write, Effect.onInterrupt(restore(settled), () => Effect.suspend(() => {
|
|
245
|
+
request.interrupted = true;
|
|
246
|
+
const stop = request.started ? server.write(0, {
|
|
247
|
+
_tag: "Interrupt",
|
|
248
|
+
requestId: RpcMessage.RequestId(id),
|
|
249
|
+
interruptors: []
|
|
250
|
+
}) : Effect.void;
|
|
251
|
+
return Effect.andThen(stop, settled);
|
|
252
|
+
})))).pipe(Effect.flatten, Effect.ensuring(Effect.sync(() => serverRequests.delete(id))));
|
|
253
|
+
};
|
|
254
|
+
const run = (envelope, lastSentChunk, discard, respond, permit = makeHandlerPermit(handlerSemaphore)) => Effect.uninterruptibleMask(restore => Effect.flatMap(leaseHandlers(restore), entry => Effect.ensuring(restore(runLeased(entry, envelope, lastSentChunk, discard, respond, permit)), releaseLease(entry))));
|
|
255
|
+
const runLeased = Effect.fnUntraced(function* (entry, envelope, lastSentChunk, discard, respond, permit) {
|
|
256
|
+
const rpc = registration.entity.protocol.requests.get(envelope.tag);
|
|
257
|
+
const handler = entry.handlers[envelope.tag];
|
|
258
|
+
if (rpc === undefined || handler === undefined) {
|
|
259
|
+
const exit = Exit.die(`Unknown entity RPC tag: ${envelope.tag}`);
|
|
260
|
+
if (!discard) {
|
|
261
|
+
yield* respond(new Reply.WithExit({
|
|
262
|
+
requestId: envelope.requestId,
|
|
263
|
+
id: nextId(),
|
|
264
|
+
exit
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const streamSchema = RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema : undefined;
|
|
270
|
+
const progress = {
|
|
271
|
+
lastSentChunk,
|
|
272
|
+
sequence: Option.match(lastSentChunk, {
|
|
273
|
+
onNone: () => 0,
|
|
274
|
+
onSome: chunk => chunk.sequence + 1
|
|
275
|
+
})
|
|
276
|
+
};
|
|
277
|
+
const execute = Effect.suspend(() => runOnServer(entry.server.server, {
|
|
278
|
+
envelope,
|
|
279
|
+
discard,
|
|
280
|
+
respond,
|
|
281
|
+
progress,
|
|
282
|
+
exit: Deferred.makeUnsafe(),
|
|
283
|
+
finished: Deferred.makeUnsafe(),
|
|
284
|
+
started: false,
|
|
285
|
+
interrupted: false,
|
|
286
|
+
fork: false
|
|
287
|
+
}, permit)).pipe(Effect.withSpan("CloudflareCluster.handler", {
|
|
288
|
+
attributes: {
|
|
289
|
+
entityType: registration.entity.type,
|
|
290
|
+
entityId: String(address.entityId),
|
|
291
|
+
rpc: envelope.tag
|
|
292
|
+
}
|
|
293
|
+
}, {
|
|
294
|
+
captureStackTrace: false
|
|
295
|
+
}));
|
|
296
|
+
const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context);
|
|
297
|
+
if (!discard) {
|
|
298
|
+
yield* respond(new Reply.WithExit({
|
|
299
|
+
requestId: envelope.requestId,
|
|
300
|
+
id: nextId(),
|
|
301
|
+
exit: streamSchema !== undefined && Exit.isSuccess(exit) ? Exit.void : exit
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
if (Exit.isFailure(exit) && Cause.hasDies(exit.cause)) {
|
|
305
|
+
yield* rebuildAfterDefect;
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// Builds the handlers without running a request, for wakes that only need
|
|
309
|
+
// the entity alive. Building restores the hold; the explicit `restore`
|
|
310
|
+
// covers an entity that was already built when its hold ended.
|
|
311
|
+
const warm = Effect.andThen(getHandlers(), Effect.sync(() => keepAlive?.restore()));
|
|
312
|
+
return {
|
|
313
|
+
run,
|
|
314
|
+
invalidate,
|
|
315
|
+
warm,
|
|
316
|
+
handlerSemaphore
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
const success = (requestId, replies) => ({
|
|
320
|
+
_tag: "Success",
|
|
321
|
+
requestId,
|
|
322
|
+
replies
|
|
323
|
+
});
|
|
324
|
+
const unlimitedHandlerPermit = {
|
|
325
|
+
acquire: identity,
|
|
326
|
+
pause: identity
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* One permit per handler execution. `acquire` holds the permit for the whole
|
|
330
|
+
* run; `pause` gives it back while a chunk is parked on its client
|
|
331
|
+
* acknowledgement so an unacked stream cannot starve other handlers. The
|
|
332
|
+
* `held` flag keeps take/release balanced when interruption lands inside a
|
|
333
|
+
* paused section: a pause that never re-acquires leaves `held` false, and
|
|
334
|
+
* `acquire` then skips its release.
|
|
335
|
+
*/
|
|
336
|
+
const makeHandlerPermit = semaphore => {
|
|
337
|
+
if (semaphore === undefined) return unlimitedHandlerPermit;
|
|
338
|
+
let held = false;
|
|
339
|
+
return {
|
|
340
|
+
acquire: effect => Effect.uninterruptibleMask(restore => restore(Semaphore.take(semaphore, 1)).pipe(Effect.flatMap(() => {
|
|
341
|
+
held = true;
|
|
342
|
+
return restore(effect);
|
|
343
|
+
}), Effect.onExit(() => Effect.suspend(() => {
|
|
344
|
+
if (!held) return Effect.void;
|
|
345
|
+
held = false;
|
|
346
|
+
return Effect.asVoid(Semaphore.release(semaphore, 1));
|
|
347
|
+
})))),
|
|
348
|
+
pause: effect => Effect.uninterruptibleMask(restore => Effect.suspend(() => {
|
|
349
|
+
if (!held) return restore(effect);
|
|
350
|
+
held = false;
|
|
351
|
+
return Semaphore.release(semaphore, 1).pipe(Effect.flatMap(() => restore(effect)), Effect.flatMap(value => Effect.map(restore(Semaphore.take(semaphore, 1)), () => {
|
|
352
|
+
held = true;
|
|
353
|
+
return value;
|
|
354
|
+
})));
|
|
355
|
+
}))
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* The Effect-land state machine behind one `ClusterEntity` Durable Object.
|
|
360
|
+
* The class methods are one-line `Effect.runPromise` adapters over the
|
|
361
|
+
* effects returned here; all serialization, sessions, and waiters live in
|
|
362
|
+
* Effect primitives.
|
|
363
|
+
*
|
|
364
|
+
* @internal
|
|
365
|
+
*/
|
|
366
|
+
export const makeEntityManager = options => {
|
|
367
|
+
const storage = options.storage;
|
|
368
|
+
const sql = storage.sql;
|
|
369
|
+
// Serializes storage entry: envelope decode, persist-before-run, dedupe,
|
|
370
|
+
// duplicate resume, and alarm arming. Handler execution runs in forked
|
|
371
|
+
// fibers governed by the handler concurrency semaphore below.
|
|
372
|
+
const semaphore = Semaphore.makeUnsafe(1);
|
|
373
|
+
const sessions = new Map();
|
|
374
|
+
// Persisted discard/scheduled requests with an in-flight handler; guards
|
|
375
|
+
// against a second execution from replay or duplicate delivery in the same
|
|
376
|
+
// wake, the way `sessions` does for asks.
|
|
377
|
+
const pendingRuns = new Set();
|
|
378
|
+
// Sessions can retain queued replies after their handler has finished, and
|
|
379
|
+
// can also belong to non-persisted requests. Count persisted handler fibers
|
|
380
|
+
// separately so only transitions touch the recovery alarm.
|
|
381
|
+
let inFlightPersisted = 0;
|
|
382
|
+
const workerWaiters = new Map();
|
|
383
|
+
const replyRegistry = makeReplyRegistry();
|
|
384
|
+
const entitySql = makeEntitySql(storage);
|
|
385
|
+
// Every mailbox write runs in its own storage transaction and waits for any
|
|
386
|
+
// open user transaction, so a user rollback cannot take a reply with it.
|
|
387
|
+
const write = effect => entitySql.guard(withTransaction(storage, effect));
|
|
388
|
+
let runtime;
|
|
389
|
+
const getRuntime = registration => runtime !== undefined ? Effect.succeed(runtime) : Effect.map(makeEntityRuntime(registration, options.address, () => crypto.randomUUID(), options.entityName, options.keepAlive, replyRegistry, entitySql), built => runtime = built);
|
|
390
|
+
const armEarliestAlarm = Effect.suspend(() => {
|
|
391
|
+
const deliverAt = earliestDeliverAt(sql);
|
|
392
|
+
return deliverAt === undefined ? Effect.void : armAlarm(storage, deliverAt);
|
|
393
|
+
});
|
|
394
|
+
const heartbeatMillis = () => keepAliveHeartbeatMillis(options.address.entityType);
|
|
395
|
+
const isRunning = requestId => sessions.has(requestId) || pendingRuns.has(requestId);
|
|
396
|
+
// Hot paths only ever move the alarm earlier. A heartbeat while a persisted
|
|
397
|
+
// row runs leaves an alarm behind for the restart that kills its handler.
|
|
398
|
+
const armHeartbeat = Effect.suspend(() => armAlarm(storage, Date.now() + heartbeatMillis()));
|
|
399
|
+
// Recomputes the single alarm from the whole entity state. Callers hold the
|
|
400
|
+
// entry permit, so a concurrent keep-alive write cannot slip in between the
|
|
401
|
+
// read and a `deleteAlarm`.
|
|
402
|
+
const rearm = Effect.suspend(() => {
|
|
403
|
+
const next = nextAlarmAt(sql, {
|
|
404
|
+
now: Date.now(),
|
|
405
|
+
heartbeatMillis: heartbeatMillis(),
|
|
406
|
+
isRunning
|
|
407
|
+
});
|
|
408
|
+
return Effect.flatMap(Effect.promise(() => storage.getAlarm()), current => {
|
|
409
|
+
if (next !== undefined) {
|
|
410
|
+
return current === next ? Effect.void : Effect.promise(() => storage.setAlarm(next));
|
|
411
|
+
}
|
|
412
|
+
return current === null ? Effect.void : Effect.promise(() => storage.deleteAlarm());
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
// Called under the entry permit, before forking the handler. Overlapping
|
|
416
|
+
// requests share the first heartbeat instead of reading the alarm per run.
|
|
417
|
+
const startPersisted = Effect.suspend(() => ++inFlightPersisted === 1 ? armHeartbeat : Effect.void);
|
|
418
|
+
// Handler finalizers hold the entry permit and finish mailbox writes before
|
|
419
|
+
// dropping the count. The last completion may move the alarm later to a
|
|
420
|
+
// scheduled delivery, retain keep-alive, or remove it entirely.
|
|
421
|
+
const finishPersisted = Effect.suspend(() => --inFlightPersisted === 0 ? rearm : Effect.void);
|
|
422
|
+
const removeSession = (requestId, session) => {
|
|
423
|
+
if (!session.cancelling && sessions.get(requestId) === session) sessions.delete(requestId);
|
|
424
|
+
};
|
|
425
|
+
const signalSaved = session => {
|
|
426
|
+
const saved = session.saved;
|
|
427
|
+
if (saved === undefined) return;
|
|
428
|
+
session.saved = undefined;
|
|
429
|
+
Deferred.doneUnsafe(saved, Effect.void);
|
|
430
|
+
};
|
|
431
|
+
const storedNextReply = requestId => Effect.map(loadNextReply(sql, requestId), nextReply => nextReply === undefined ? [] : [nextReply.reply]);
|
|
432
|
+
// Reads the next stored reply of a running persisted stream, waiting for
|
|
433
|
+
// the handler to save one when there is none yet. The waiter is registered
|
|
434
|
+
// before reading storage, so a reply saved in between still wakes it.
|
|
435
|
+
const awaitStoredReply = (requestId, session) => Effect.suspend(() => {
|
|
436
|
+
const saved = session.saved ??= Deferred.makeUnsafe();
|
|
437
|
+
return Effect.map(loadNextReply(sql, requestId), nextReply => nextReply === undefined ? Effect.andThen(Deferred.await(saved), storedNextReply(requestId)) : Effect.succeed([nextReply.reply]));
|
|
438
|
+
});
|
|
439
|
+
const takeReply = (requestId, session) => Queue.take(session.queue).pipe(Effect.map(reply => {
|
|
440
|
+
if (reply.terminal) removeSession(requestId, session);
|
|
441
|
+
return [reply.text];
|
|
442
|
+
}), Pull.catchDone(() => Effect.succeed([])));
|
|
443
|
+
// Hands a scheduled reply to the pinned callers waiting in this wake and
|
|
444
|
+
// returns the reply targets stored now, which include any a duplicate added
|
|
445
|
+
// while the handler ran. Runs after the reply is saved: once the row is
|
|
446
|
+
// processed, a later duplicate reads the stored reply instead.
|
|
447
|
+
const settleScheduledReply = (requestId, reply, replyTos) => Effect.map(loadMessage(sql, requestId), message => {
|
|
448
|
+
const waiters = workerWaiters.get(requestId);
|
|
449
|
+
if (waiters !== undefined) {
|
|
450
|
+
workerWaiters.delete(requestId);
|
|
451
|
+
for (const waiter of waiters) {
|
|
452
|
+
Deferred.doneUnsafe(waiter.deferred, Effect.succeed(success(requestId, [reply])));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return message === undefined ? replyTos : message.replyTos;
|
|
456
|
+
});
|
|
457
|
+
const pushScheduledReply = (reply, replyTos) => Effect.suspend(() => {
|
|
458
|
+
if (replyTos === undefined) return Effect.void;
|
|
459
|
+
const namespace = options.getNamespace();
|
|
460
|
+
if (namespace === undefined) {
|
|
461
|
+
return Effect.logError("Scheduled entity reply delivery failed", new Error("CloudflareCluster: ClusterEntity export is unavailable for scheduled reply delivery"));
|
|
462
|
+
}
|
|
463
|
+
return Effect.forEach(replyTos, ({
|
|
464
|
+
replyTo,
|
|
465
|
+
requestId
|
|
466
|
+
}) => Effect.promise(() => namespace.getByName(replyTo).deliverReply(requestId, reply)).pipe(Effect.flatMap(delivered => delivered ? Effect.void : Effect.logError("Scheduled entity reply delivery failed", new Error(`Scheduled entity reply target is unavailable: ${replyTo}`))), Effect.catchCause(cause => Effect.logError("Scheduled entity reply delivery failed", cause))), {
|
|
467
|
+
concurrency: "unbounded",
|
|
468
|
+
discard: true
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
const deliverScheduledReply = (requestId, reply, replyTos) => Effect.flatMap(settleScheduledReply(requestId, reply, replyTos), targets => pushScheduledReply(reply, targets));
|
|
472
|
+
// Runs one request under the entry permit up to forking its handler fiber,
|
|
473
|
+
// then returns a continuation that awaits the handler output. Callers run
|
|
474
|
+
// the continuation after the entry permit is released so handlers governed
|
|
475
|
+
// by the concurrency semaphore can interleave with later storage entries.
|
|
476
|
+
const run = Effect.fnUntraced(function* (registration, entityRuntime, envelope, lastSentChunkText, discard, persisted, runOptions) {
|
|
477
|
+
const rpc = registration.entity.protocol.requests.get(envelope.tag);
|
|
478
|
+
let lastSentChunk = Option.none();
|
|
479
|
+
if (lastSentChunkText !== undefined) {
|
|
480
|
+
const reply = yield* decodeReplyFor(rpc, registration.context, lastSentChunkText);
|
|
481
|
+
if (reply._tag === "Chunk") lastSentChunk = Option.some(reply);
|
|
482
|
+
}
|
|
483
|
+
const requestId = String(envelope.requestId);
|
|
484
|
+
if (!discard) {
|
|
485
|
+
const active = sessions.get(requestId);
|
|
486
|
+
if (active !== undefined) {
|
|
487
|
+
// A stream duplicate resumes from storage, as on other runners.
|
|
488
|
+
if (persisted && RpcSchema.isStreamSchema(rpc.successSchema)) {
|
|
489
|
+
return yield* awaitStoredReply(requestId, active);
|
|
490
|
+
}
|
|
491
|
+
const nextReply = persisted ? yield* loadNextReply(sql, requestId) : undefined;
|
|
492
|
+
if (nextReply !== undefined) return Effect.succeed([nextReply.reply]);
|
|
493
|
+
const fiber = active.fiber;
|
|
494
|
+
if (!persisted || fiber === undefined) return Effect.succeed([]);
|
|
495
|
+
// A unary duplicate waits for the handler outside the entry permit and
|
|
496
|
+
// then reads its stored reply, which wins even when post-reply handler
|
|
497
|
+
// cleanup failed.
|
|
498
|
+
return Effect.gen(function* () {
|
|
499
|
+
const exit = yield* Fiber.await(fiber);
|
|
500
|
+
const reply = yield* loadNextReply(sql, requestId);
|
|
501
|
+
if (reply !== undefined) return [reply.reply];
|
|
502
|
+
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause);
|
|
503
|
+
return yield* Effect.die("Persisted unary handler finished without a reply");
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const scheduled = runOptions?.scheduled === true;
|
|
508
|
+
if (discard || scheduled) {
|
|
509
|
+
if (persisted) {
|
|
510
|
+
if (pendingRuns.has(requestId)) return Effect.succeed([]);
|
|
511
|
+
pendingRuns.add(requestId);
|
|
512
|
+
yield* startPersisted;
|
|
513
|
+
}
|
|
514
|
+
// No stream acks on this path, so the runtime's plain permit is enough.
|
|
515
|
+
const execute = entityRuntime.run(envelope, lastSentChunk, discard, reply => Effect.gen(function* () {
|
|
516
|
+
const encoded = yield* encodeReplyFor(registration, rpc, reply);
|
|
517
|
+
const save = persisted ? Effect.orDie(write(saveReply(sql, encoded))) : Effect.void;
|
|
518
|
+
if (!scheduled || reply._tag !== "WithExit") return yield* save;
|
|
519
|
+
// Saving the terminal reply, settling the waiters and reading the
|
|
520
|
+
// targets share the entry permit, so a duplicate entry either finds
|
|
521
|
+
// the stored reply or is registered in time to receive it.
|
|
522
|
+
const replyTos = yield* Semaphore.withPermit(semaphore, Effect.andThen(save, settleScheduledReply(requestId, encoded, runOptions?.replyTos)));
|
|
523
|
+
yield* pushScheduledReply(encoded, replyTos);
|
|
524
|
+
}));
|
|
525
|
+
const fiber = yield* Effect.forkDetach(execute.pipe(Effect.onExit(exit => !persisted ? Effect.void : Semaphore.withPermit(semaphore, Effect.suspend(() => {
|
|
526
|
+
pendingRuns.delete(requestId);
|
|
527
|
+
return discard && Exit.isSuccess(exit) ? write(completeTell(sql, requestId)) : Effect.void;
|
|
528
|
+
}).pipe(Effect.ensuring(finishPersisted))))));
|
|
529
|
+
options.waitUntil(Fiber.await(fiber));
|
|
530
|
+
return Effect.as(Fiber.join(fiber), []);
|
|
531
|
+
}
|
|
532
|
+
const queue = yield* Queue.make();
|
|
533
|
+
const completeInterrupt = persisted ? Effect.flatMap(encodeReplyFor(registration, rpc, new Reply.WithExit({
|
|
534
|
+
requestId: envelope.requestId,
|
|
535
|
+
id: crypto.randomUUID(),
|
|
536
|
+
exit: Exit.interrupt()
|
|
537
|
+
})), reply => Effect.orDie(write(Effect.andThen(clearReplies(sql, requestId), saveReply(sql, reply))))) : Effect.void;
|
|
538
|
+
const session = {
|
|
539
|
+
queue,
|
|
540
|
+
completeInterrupt,
|
|
541
|
+
uninterruptible: isUninterruptibleForServer(rpc.annotations),
|
|
542
|
+
cancelling: false,
|
|
543
|
+
ack: undefined,
|
|
544
|
+
fiber: undefined,
|
|
545
|
+
saved: undefined
|
|
546
|
+
};
|
|
547
|
+
sessions.set(requestId, session);
|
|
548
|
+
if (persisted) yield* startPersisted;
|
|
549
|
+
const permit = makeHandlerPermit(entityRuntime.handlerSemaphore);
|
|
550
|
+
const respond = Effect.fnUntraced(function* (reply) {
|
|
551
|
+
const encoded = yield* encodeReplyFor(registration, rpc, reply);
|
|
552
|
+
if (persisted) {
|
|
553
|
+
yield* Effect.orDie(write(saveReply(sql, encoded)));
|
|
554
|
+
signalSaved(session);
|
|
555
|
+
}
|
|
556
|
+
if (reply._tag === "Chunk") {
|
|
557
|
+
const acknowledged = Deferred.makeUnsafe();
|
|
558
|
+
session.ack = {
|
|
559
|
+
replyId: String(reply.id),
|
|
560
|
+
deferred: acknowledged
|
|
561
|
+
};
|
|
562
|
+
// A false offer means the session was interrupted and the queue
|
|
563
|
+
// ended; awaiting the acknowledgement would then never resume.
|
|
564
|
+
const offered = yield* Queue.offer(queue, {
|
|
565
|
+
text: encoded,
|
|
566
|
+
terminal: false
|
|
567
|
+
});
|
|
568
|
+
if (offered) yield* permit.pause(Deferred.await(acknowledged));else session.ack = undefined;
|
|
569
|
+
} else {
|
|
570
|
+
yield* Queue.offer(queue, {
|
|
571
|
+
text: encoded,
|
|
572
|
+
terminal: true
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
session.fiber = yield* Effect.forkDetach(entityRuntime.run(envelope, lastSentChunk, discard, respond, permit).pipe(Effect.onExit(exit => {
|
|
577
|
+
const cleanup = Effect.sync(() => {
|
|
578
|
+
if (Exit.isSuccess(exit)) Queue.endUnsafe(queue);else Queue.failCauseUnsafe(queue, exit.cause);
|
|
579
|
+
if (Queue.sizeUnsafe(queue) === 0 && session.ack === undefined) {
|
|
580
|
+
removeSession(requestId, session);
|
|
581
|
+
}
|
|
582
|
+
signalSaved(session);
|
|
583
|
+
});
|
|
584
|
+
return persisted ? Semaphore.withPermit(semaphore, Effect.andThen(cleanup, finishPersisted)) : cleanup;
|
|
585
|
+
})));
|
|
586
|
+
options.waitUntil(Fiber.await(session.fiber));
|
|
587
|
+
return takeReply(requestId, session);
|
|
588
|
+
});
|
|
589
|
+
const runStored = (registration, entityRuntime, envelopeText, lastSentChunk, discard, runOptions) => Effect.flatMap(decodeRequest(registration, envelopeText), envelope => run(registration, entityRuntime, envelope, lastSentChunk, discard, true, runOptions));
|
|
590
|
+
const completeReplayFailure = (registration, row, cause) => Effect.suspend(() => peekEnvelopeTag(row.envelope)).pipe(Effect.flatMap(tag => {
|
|
591
|
+
if (row.discard || tag === undefined) {
|
|
592
|
+
return write(completeTell(sql, row.requestId));
|
|
593
|
+
}
|
|
594
|
+
const rpc = registration.entity.protocol.requests.get(tag);
|
|
595
|
+
if (rpc === undefined) {
|
|
596
|
+
return write(completeTell(sql, row.requestId));
|
|
597
|
+
}
|
|
598
|
+
return Effect.flatMap(encodeReplyFor(registration, rpc, new Reply.WithExit({
|
|
599
|
+
requestId: row.requestId,
|
|
600
|
+
id: crypto.randomUUID(),
|
|
601
|
+
exit: Exit.failCause(cause)
|
|
602
|
+
})), reply => write(saveReply(sql, reply)).pipe(Effect.andThen(deliverScheduledReply(row.requestId, reply, row.replyTos)))).pipe(Effect.catchCause(() => write(completeTell(sql, row.requestId))));
|
|
603
|
+
}));
|
|
604
|
+
// Phase one runs each row up to forking its handler under the entry
|
|
605
|
+
// permit; the returned fibers observe the handler output and route
|
|
606
|
+
// failures through `completeReplayFailure`.
|
|
607
|
+
const replayRows = (registration, entityRuntime, rows) => rows.length === 0 ? Effect.succeed([]) : Effect.forEach(rows, row =>
|
|
608
|
+
// An active session or in-flight run already owns this request;
|
|
609
|
+
// replaying it would start a second execution in the same wake.
|
|
610
|
+
isRunning(row.requestId) ? Effect.succeed(undefined) : runStored(registration, entityRuntime, row.envelope, row.lastSentChunk, row.discard, row.deliverAt === undefined ? undefined : {
|
|
611
|
+
scheduled: true,
|
|
612
|
+
...(row.replyTos === undefined ? undefined : {
|
|
613
|
+
replyTos: row.replyTos
|
|
614
|
+
})
|
|
615
|
+
}).pipe(Effect.flatMap(continuation => Effect.forkDetach(Effect.catchCause(Effect.asVoid(continuation), cause => completeReplayFailure(registration, row, cause)))), Effect.catchCause(cause => Effect.as(completeReplayFailure(registration, row, cause), undefined)))).pipe(Effect.map(entries => entries.filter(entry => entry !== undefined)), Effect.tap(entries => Effect.sync(() => {
|
|
616
|
+
if (entries.length > 0) options.waitUntil(Fiber.awaitAll(entries));
|
|
617
|
+
})));
|
|
618
|
+
// Registers the waiter eagerly (under the entry permit) and returns the
|
|
619
|
+
// await for the caller to run once the permit is released.
|
|
620
|
+
const delayedOutcome = (requestId, discard, replyTo, clientRequestId = requestId) => {
|
|
621
|
+
if (discard || replyTo !== undefined) return Effect.succeed(success(requestId, []));
|
|
622
|
+
const deferred = Deferred.makeUnsafe();
|
|
623
|
+
const waiters = workerWaiters.get(requestId) ?? [];
|
|
624
|
+
waiters.push({
|
|
625
|
+
clientRequestId,
|
|
626
|
+
deferred
|
|
627
|
+
});
|
|
628
|
+
workerWaiters.set(requestId, waiters);
|
|
629
|
+
return Deferred.await(deferred);
|
|
630
|
+
};
|
|
631
|
+
// Runs the storage entry under the caller-held permit and returns the
|
|
632
|
+
// effect that produces the invoke result after the permit is released.
|
|
633
|
+
const invokeEntry = (envelopeText, discard, delivery) => {
|
|
634
|
+
const registration = getEntityRegistration(options.address.entityType);
|
|
635
|
+
if (registration === undefined) {
|
|
636
|
+
return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`);
|
|
637
|
+
}
|
|
638
|
+
return Effect.gen(function* () {
|
|
639
|
+
const entityRuntime = yield* getRuntime(registration);
|
|
640
|
+
// Replayed rows run on their own fibers. The handler semaphore already
|
|
641
|
+
// orders them ahead of this request, so the caller does not wait for them.
|
|
642
|
+
yield* Effect.flatMap(loadUnprocessed(sql), rows => replayRows(registration, entityRuntime, rows));
|
|
643
|
+
const finish = (requestId, continuation) => Effect.map(continuation, replies => success(requestId, replies));
|
|
644
|
+
// A persisted tell must not pin its caller: the row is durable, so the
|
|
645
|
+
// invoke result is complete once the handler is forked. Failures of the
|
|
646
|
+
// forked machinery leave the row unprocessed for replay on a later
|
|
647
|
+
// wake, so they are only logged here.
|
|
648
|
+
const finishTell = (requestId, continuation) => Effect.as(Effect.forkDetach(Effect.catchCause(Effect.asVoid(continuation), cause => Effect.logError("Entity tell execution failed", cause))), Effect.succeed(success(requestId, [])));
|
|
649
|
+
const envelope = yield* decodeRequest(registration, envelopeText);
|
|
650
|
+
const rpc = registration.entity.protocol.requests.get(envelope.tag);
|
|
651
|
+
const isPersisted = Context.get(rpc.annotations, Persisted);
|
|
652
|
+
if (!isPersisted) {
|
|
653
|
+
const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, false);
|
|
654
|
+
return finish(String(envelope.requestId), continuation);
|
|
655
|
+
}
|
|
656
|
+
const persistedResult = yield* Effect.result(write(persistRequest(sql, envelopeText, delivery?.primaryKey ?? Envelope.primaryKey(envelope), discard, delivery?.deliverAt, delivery?.replyTo)).pipe(Effect.withSpan("CloudflareCluster.persist", {
|
|
657
|
+
attributes: {
|
|
658
|
+
entityType: registration.entity.type,
|
|
659
|
+
entityId: String(options.address.entityId),
|
|
660
|
+
rpc: envelope.tag
|
|
661
|
+
}
|
|
662
|
+
}, {
|
|
663
|
+
captureStackTrace: false
|
|
664
|
+
}), Effect.provideContext(registration.context)));
|
|
665
|
+
if (Result.isFailure(persistedResult)) {
|
|
666
|
+
return Effect.succeed(persistedResult.failure._tag === "MailboxFull" ? {
|
|
667
|
+
_tag: "MailboxFull"
|
|
668
|
+
} : {
|
|
669
|
+
_tag: "EncodedMessageTooLarge"
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const persisted = persistedResult.success;
|
|
673
|
+
if (persisted._tag === "Duplicate") {
|
|
674
|
+
const original = yield* loadMessage(sql, persisted.originalId);
|
|
675
|
+
if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared");
|
|
676
|
+
if (original.discard && !discard) {
|
|
677
|
+
return Effect.succeed({
|
|
678
|
+
_tag: "AskDeduplicatedToTell"
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const nextReply = yield* loadNextReply(sql, persisted.originalId);
|
|
682
|
+
if (nextReply !== undefined) {
|
|
683
|
+
if (nextReply.kind === "WithExit") {
|
|
684
|
+
const session = sessions.get(persisted.originalId);
|
|
685
|
+
if (session !== undefined) removeSession(persisted.originalId, session);
|
|
686
|
+
}
|
|
687
|
+
return Effect.succeed(success(persisted.originalId, [nextReply.reply]));
|
|
688
|
+
}
|
|
689
|
+
if (persisted.processed) {
|
|
690
|
+
return Effect.succeed(success(persisted.originalId, []));
|
|
691
|
+
}
|
|
692
|
+
if (delivery?.deliverAt !== undefined || original.deliverAt !== undefined && original.deliverAt > Date.now()) {
|
|
693
|
+
yield* armEarliestAlarm;
|
|
694
|
+
return delayedOutcome(persisted.originalId, discard, delivery?.replyTo, String(envelope.requestId));
|
|
695
|
+
}
|
|
696
|
+
// A scheduled ask past its deadline replies through its waiters and
|
|
697
|
+
// reply targets, not a session. The duplicate joins that execution,
|
|
698
|
+
// starting it if it is not running yet; the waiter is registered
|
|
699
|
+
// first so a run that settles at once still reaches it.
|
|
700
|
+
if (original.deliverAt !== undefined && !original.discard) {
|
|
701
|
+
const outcome = delayedOutcome(persisted.originalId, discard, delivery?.replyTo, String(envelope.requestId));
|
|
702
|
+
if (!isRunning(persisted.originalId)) {
|
|
703
|
+
yield* replayRows(registration, entityRuntime, [original]);
|
|
704
|
+
}
|
|
705
|
+
return outcome;
|
|
706
|
+
}
|
|
707
|
+
const continuation = yield* runStored(registration, entityRuntime, original.envelope, original.lastSentChunk, original.discard);
|
|
708
|
+
return original.discard ? yield* finishTell(persisted.originalId, continuation) : finish(persisted.originalId, continuation);
|
|
709
|
+
}
|
|
710
|
+
if (delivery?.deliverAt !== undefined) {
|
|
711
|
+
yield* armEarliestAlarm;
|
|
712
|
+
return delayedOutcome(String(envelope.requestId), discard, delivery.replyTo);
|
|
713
|
+
}
|
|
714
|
+
const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, true);
|
|
715
|
+
return discard ? yield* finishTell(String(envelope.requestId), continuation) : finish(String(envelope.requestId), continuation);
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
const invoke = (envelopeText, discard, delivery) => Effect.flatten(Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery)));
|
|
719
|
+
// An acknowledgement with no session comes after a restart. The stream is
|
|
720
|
+
// replayed from its last acknowledged chunk so the caller gets its next
|
|
721
|
+
// reply instead of waiting on an alarm replay that waits for this ack.
|
|
722
|
+
const resumeAcknowledged = requestId => Effect.flatten(Semaphore.withPermit(semaphore, Effect.gen(function* () {
|
|
723
|
+
const nextReply = yield* loadNextReply(sql, requestId);
|
|
724
|
+
if (nextReply !== undefined) return Effect.succeed([nextReply.reply]);
|
|
725
|
+
const registration = getEntityRegistration(options.address.entityType);
|
|
726
|
+
if (registration === undefined) return Effect.succeed([]);
|
|
727
|
+
// A session started since the ack was written owns the request.
|
|
728
|
+
const session = sessions.get(requestId);
|
|
729
|
+
if (session !== undefined) return yield* awaitStoredReply(requestId, session);
|
|
730
|
+
if (pendingRuns.has(requestId)) return Effect.succeed([]);
|
|
731
|
+
const row = (yield* loadUnprocessed(sql)).find(row => row.requestId === requestId);
|
|
732
|
+
if (row === undefined || row.discard || row.deliverAt !== undefined) return Effect.succeed([]);
|
|
733
|
+
const entityRuntime = yield* getRuntime(registration);
|
|
734
|
+
return yield* runStored(registration, entityRuntime, row.envelope, row.lastSentChunk, false).pipe(Effect.catchCause(cause => Effect.as(completeReplayFailure(registration, row, cause), storedNextReply(requestId))));
|
|
735
|
+
})));
|
|
736
|
+
const acknowledge = (requestId, replyId) => write(ackChunk(sql, requestId, replyId)).pipe(Effect.flatMap(() => {
|
|
737
|
+
const session = sessions.get(requestId);
|
|
738
|
+
if (session?.ack?.replyId === replyId) {
|
|
739
|
+
const acknowledged = session.ack.deferred;
|
|
740
|
+
session.ack = undefined;
|
|
741
|
+
Deferred.doneUnsafe(acknowledged, Effect.void);
|
|
742
|
+
return takeReply(requestId, session);
|
|
743
|
+
}
|
|
744
|
+
if (session === undefined) return resumeAcknowledged(requestId);
|
|
745
|
+
// A session this ack does not match may be a replay that has not saved
|
|
746
|
+
// its next reply yet; the caller will not ask again, so wait for it.
|
|
747
|
+
return Effect.flatten(awaitStoredReply(requestId, session));
|
|
748
|
+
}));
|
|
749
|
+
const interrupt = (storageRequestId, clientRequestId = storageRequestId) => Effect.suspend(() => {
|
|
750
|
+
const waiters = workerWaiters.get(storageRequestId);
|
|
751
|
+
if (waiters !== undefined) {
|
|
752
|
+
const remaining = waiters.filter(waiter => {
|
|
753
|
+
if (waiter.clientRequestId !== clientRequestId) return true;
|
|
754
|
+
Deferred.doneUnsafe(waiter.deferred, Effect.die(new Error("Delayed entity request interrupted")));
|
|
755
|
+
return false;
|
|
756
|
+
});
|
|
757
|
+
if (remaining.length === 0) workerWaiters.delete(storageRequestId);else workerWaiters.set(storageRequestId, remaining);
|
|
758
|
+
}
|
|
759
|
+
const session = sessions.get(storageRequestId);
|
|
760
|
+
if (session === undefined || session.uninterruptible) return Effect.void;
|
|
761
|
+
// Keep ownership through handler finalization and the terminal write.
|
|
762
|
+
// A failed write must leave the row owned rather than replayable.
|
|
763
|
+
session.cancelling = true;
|
|
764
|
+
if (session.ack !== undefined) {
|
|
765
|
+
Deferred.doneUnsafe(session.ack.deferred, Effect.void);
|
|
766
|
+
session.ack = undefined;
|
|
767
|
+
}
|
|
768
|
+
Queue.endUnsafe(session.queue);
|
|
769
|
+
const stop = session.fiber === undefined ? Effect.void : Effect.asVoid(Fiber.interrupt(session.fiber));
|
|
770
|
+
// The handler finalizer takes this same permit. Interrupt outside it.
|
|
771
|
+
return Effect.andThen(stop, Semaphore.withPermit(semaphore, Effect.andThen(session.completeInterrupt, Effect.suspend(() => {
|
|
772
|
+
if (sessions.get(storageRequestId) === session) sessions.delete(storageRequestId);
|
|
773
|
+
return inFlightPersisted === 0 ? rearm : Effect.void;
|
|
774
|
+
}))));
|
|
775
|
+
});
|
|
776
|
+
const reset = requestId => write(clearReplies(sql, requestId));
|
|
777
|
+
const alarm = Effect.suspend(() => {
|
|
778
|
+
const registration = getEntityRegistration(options.address.entityType);
|
|
779
|
+
if (registration === undefined) {
|
|
780
|
+
// A deploy that removed this entity type leaves its last alarm behind:
|
|
781
|
+
// it fails here, Cloudflare retries it 6 times, and the chain stops. The
|
|
782
|
+
// `cluster_entity_state` row is never cleaned up.
|
|
783
|
+
return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`);
|
|
784
|
+
}
|
|
785
|
+
// The entry permit covers replay setup and alarm arming. The next alarm is
|
|
786
|
+
// armed before awaiting the replayed handlers: a handler that outlives the
|
|
787
|
+
// alarm wall-clock limit kills this invocation, and the chain must not
|
|
788
|
+
// depend on code after it.
|
|
789
|
+
return Effect.flatten(Semaphore.withPermit(semaphore, Effect.gen(function* () {
|
|
790
|
+
const rows = (yield* loadUnprocessed(sql)).filter(row => !isRunning(row.requestId));
|
|
791
|
+
const keepAlive = loadEntityState(sql).keepAlive;
|
|
792
|
+
// A heartbeat left over after keep-alive was released must not
|
|
793
|
+
// rebuild the entity; the `ensuring` below still settles the alarm.
|
|
794
|
+
if (rows.length === 0 && !keepAlive) return Effect.void;
|
|
795
|
+
const entityRuntime = yield* getRuntime(registration);
|
|
796
|
+
const fibers = yield* replayRows(registration, entityRuntime, rows);
|
|
797
|
+
yield* rearm;
|
|
798
|
+
const awaitReplays = Effect.asVoid(Fiber.awaitAll(fibers));
|
|
799
|
+
if (!keepAlive) return awaitReplays;
|
|
800
|
+
// The hold is never awaited: it does not resolve while held.
|
|
801
|
+
return Effect.andThen(Effect.catchCause(entityRuntime.warm, cause => Effect.logError("Entity keep-alive rebuild failed", cause)), awaitReplays);
|
|
802
|
+
}))).pipe(Effect.ensuring(Semaphore.withPermit(semaphore, rearm)), Effect.withSpan("CloudflareCluster.alarm", {
|
|
803
|
+
attributes: {
|
|
804
|
+
entityType: registration.entity.type,
|
|
805
|
+
entityId: String(options.address.entityId)
|
|
806
|
+
}
|
|
807
|
+
}, {
|
|
808
|
+
captureStackTrace: false
|
|
809
|
+
}), Effect.provideContext(registration.context));
|
|
810
|
+
});
|
|
811
|
+
const saveKeepAliveState = wanted => Semaphore.withPermit(semaphore, Effect.andThen(write(saveKeepAlive(sql, wanted)), wanted ? armHeartbeat : rearm));
|
|
812
|
+
const deliverReply = (requestId, reply) => Effect.sync(() => replyRegistry.deliver(requestId, reply));
|
|
813
|
+
return {
|
|
814
|
+
invoke,
|
|
815
|
+
acknowledge,
|
|
816
|
+
interrupt,
|
|
817
|
+
reset,
|
|
818
|
+
alarm,
|
|
819
|
+
deliverReply,
|
|
820
|
+
saveKeepAlive: saveKeepAliveState
|
|
821
|
+
};
|
|
822
|
+
};
|
|
823
|
+
//# sourceMappingURL=entityRuntime.js.map
|