@swirls/looms 0.3.0 → 0.5.0
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/CHANGELOG.md +64 -0
- package/dist/actor/cell.d.ts +21 -2
- package/dist/actor/cell.js +15 -3
- package/dist/actor/host.d.ts +10 -2
- package/dist/actor/host.js +27 -4
- package/dist/actor/index.d.ts +2 -1
- package/dist/actor/index.js +1 -1
- package/dist/actor/routing.d.ts +3 -0
- package/dist/actor/routing.js +13 -2
- package/dist/agent/debugger/agent-chat.js +3 -30
- package/dist/agent/debugger/streaming-text.d.ts +6 -0
- package/dist/agent/debugger/streaming-text.js +33 -0
- package/dist/agent/definitions-store.d.ts +1 -1
- package/dist/agent/definitions-store.js +1 -1
- package/dist/agent/definitions.d.ts +48 -0
- package/dist/agent/definitions.js +44 -0
- package/dist/agent/effects.d.ts +184 -0
- package/dist/agent/effects.js +172 -40
- package/dist/agent/events.d.ts +89 -0
- package/dist/agent/events.js +25 -0
- package/dist/agent/module.d.ts +819 -407
- package/dist/agent/module.js +5 -1
- package/dist/agent/scope.d.ts +88 -0
- package/dist/agent/threads.js +39 -3
- package/dist/agent/types.d.ts +4 -0
- package/dist/approval/effects.d.ts +40 -0
- package/dist/approval/module.d.ts +80 -0
- package/dist/client/client.d.ts +468 -119
- package/dist/client/client.js +214 -21
- package/dist/client/index.d.ts +1 -1
- package/dist/client/sse.d.ts +1 -1
- package/dist/client/sse.js +2 -2
- package/dist/cloudflare/durable-object.d.ts +66 -2
- package/dist/cloudflare/durable-object.js +96 -1
- package/dist/cloudflare/event-store.d.ts +7 -3
- package/dist/cloudflare/event-store.js +16 -3
- package/dist/cloudflare/index.d.ts +7 -1
- package/dist/cloudflare/index.js +7 -1
- package/dist/cloudflare/memory-queue.d.ts +18 -0
- package/dist/cloudflare/memory-queue.js +53 -0
- package/dist/cloudflare/queue-consumer.d.ts +52 -0
- package/dist/cloudflare/queue-consumer.js +102 -0
- package/dist/cloudflare/queue-message.d.ts +31 -0
- package/dist/cloudflare/queue-message.js +33 -0
- package/dist/cloudflare/queue-pull.d.ts +62 -0
- package/dist/cloudflare/queue-pull.js +130 -0
- package/dist/cloudflare/queue-worker.d.ts +39 -0
- package/dist/cloudflare/queue-worker.js +62 -0
- package/dist/cloudflare/r2-blob-store.d.ts +33 -0
- package/dist/cloudflare/r2-blob-store.js +60 -0
- package/dist/cloudflare/router.d.ts +4 -1
- package/dist/cloudflare/router.js +8 -3
- package/dist/cloudflare/websocket.d.ts +27 -0
- package/dist/cloudflare/websocket.js +215 -0
- package/dist/core/attempts.d.ts +8 -0
- package/dist/core/attempts.js +27 -0
- package/dist/core/blob-offload.d.ts +21 -0
- package/dist/core/blob-offload.js +143 -0
- package/dist/core/blob-store.d.ts +45 -0
- package/dist/core/blob-store.js +164 -0
- package/dist/core/blobs.d.ts +37 -0
- package/dist/core/blobs.js +160 -0
- package/dist/core/definition-resolver.d.ts +40 -0
- package/dist/core/definition-resolver.js +129 -0
- package/dist/core/definition-store.d.ts +6 -0
- package/dist/core/definition-store.js +37 -1
- package/dist/core/effects.d.ts +86 -3
- package/dist/core/effects.js +99 -3
- package/dist/core/envelope.d.ts +9 -0
- package/dist/core/envelope.js +4 -0
- package/dist/core/event-codec.js +3 -0
- package/dist/core/fold.js +83 -1
- package/dist/core/ids.d.ts +2 -0
- package/dist/core/ids.js +4 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.js +9 -0
- package/dist/core/launcher.d.ts +97 -0
- package/dist/core/launcher.js +67 -0
- package/dist/core/module-scope.d.ts +7 -3
- package/dist/core/module-scope.js +3 -0
- package/dist/core/module.d.ts +10 -0
- package/dist/core/projection.d.ts +5 -4
- package/dist/core/projection.js +4 -0
- package/dist/core/protocol.d.ts +36 -1
- package/dist/core/protocol.js +35 -0
- package/dist/core/published.d.ts +2 -0
- package/dist/core/published.js +4 -1
- package/dist/{react → core}/register.d.ts +9 -8
- package/dist/core/replay.d.ts +96 -0
- package/dist/core/run-context.d.ts +33 -0
- package/dist/core/run-context.js +23 -0
- package/dist/core/sqlite-store.js +57 -5
- package/dist/core/state.d.ts +60 -0
- package/dist/core/state.js +19 -0
- package/dist/core/store.d.ts +6 -0
- package/dist/core/store.js +7 -0
- package/dist/core/subscription.d.ts +97 -0
- package/dist/core/subscription.js +224 -0
- package/dist/core/thread.d.ts +7 -0
- package/dist/core/thread.js +1 -0
- package/dist/core/trace.d.ts +21 -0
- package/dist/core/trace.js +45 -0
- package/dist/core/worker-callback.d.ts +25 -1
- package/dist/debugger/app/assets/{index-CgDzWJg7.css → index-B7GdT1UA.css} +1 -1
- package/dist/debugger/app/assets/index-C31SnCuN.js +22 -0
- package/dist/debugger/app/assets/routes-DPA3O5AF.js +47 -0
- package/dist/debugger/app/index.html +2 -2
- package/dist/debugger/components/event-inspector.d.ts +3 -1
- package/dist/debugger/components/event-inspector.js +25 -4
- package/dist/debugger/components/event-stream.d.ts +4 -1
- package/dist/debugger/components/event-stream.js +2 -2
- package/dist/debugger/components/run-tree.d.ts +5 -2
- package/dist/debugger/components/run-tree.js +10 -4
- package/dist/debugger/context.d.ts +8 -1
- package/dist/debugger/context.js +15 -3
- package/dist/debugger/events/protocol-summarize.js +18 -3
- package/dist/debugger/index.d.ts +1 -1
- package/dist/debugger/index.js +1 -1
- package/dist/debugger/shell/debugger-shell.js +1 -1
- package/dist/debugger/shell/events-pane.js +4 -3
- package/dist/debugger/styles.css +1 -0
- package/dist/evaluator/definitions-store.d.ts +3 -4
- package/dist/evaluator/definitions-store.js +3 -3
- package/dist/evaluator/definitions.d.ts +4 -1
- package/dist/evaluator/definitions.js +5 -2
- package/dist/evaluator/effects.d.ts +41 -0
- package/dist/evaluator/effects.js +6 -2
- package/dist/evaluator/module.d.ts +84 -0
- package/dist/evaluator/module.js +7 -3
- package/dist/evaluator/threads.d.ts +2 -0
- package/dist/evaluator/threads.js +4 -0
- package/dist/projectors/event-log.d.ts +87 -0
- package/dist/projectors/event-log.js +396 -0
- package/dist/projectors/index-model.d.ts +12 -0
- package/dist/projectors/index-model.js +12 -3
- package/dist/projectors/memory.js +7 -0
- package/dist/projectors/postgres.d.ts +1 -0
- package/dist/projectors/postgres.js +32 -15
- package/dist/projectors/schema.d.ts +5 -0
- package/dist/projectors/schema.js +26 -1
- package/dist/projectors/sqlite.js +47 -19
- package/dist/projectors/with-projectors.d.ts +7 -1
- package/dist/projectors/with-projectors.js +13 -3
- package/dist/react/derived.d.ts +26 -8
- package/dist/react/derived.js +23 -26
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +2 -2
- package/dist/react/materialize.js +5 -1
- package/dist/react/react.d.ts +16 -9
- package/dist/react/react.js +47 -23
- package/dist/react/store.d.ts +14 -6
- package/dist/react/store.js +104 -12
- package/dist/react/tables.d.ts +4 -1
- package/dist/runtime/cancellation.d.ts +2 -1
- package/dist/runtime/cancellation.js +35 -2
- package/dist/runtime/child-run.d.ts +10 -0
- package/dist/runtime/child-run.js +58 -0
- package/dist/runtime/deadline-transition-planner.js +11 -2
- package/dist/runtime/dispatch-effect.d.ts +2 -2
- package/dist/runtime/dispatch-effect.js +43 -19
- package/dist/runtime/errors.d.ts +14 -1
- package/dist/runtime/errors.js +10 -0
- package/dist/runtime/filesystem-blob-store.d.ts +6 -0
- package/dist/runtime/filesystem-blob-store.js +59 -0
- package/dist/runtime/helpers.d.ts +15 -6
- package/dist/runtime/helpers.js +46 -6
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +3 -1
- package/dist/runtime/local-effect-executor.d.ts +11 -2
- package/dist/runtime/local-effect-executor.js +80 -4
- package/dist/runtime/looms.d.ts +38 -4
- package/dist/runtime/looms.js +17 -3
- package/dist/runtime/repository.d.ts +5 -4
- package/dist/runtime/repository.js +5 -2
- package/dist/runtime/retry-policy.d.ts +5 -0
- package/dist/runtime/retry-policy.js +10 -2
- package/dist/runtime/run-start.d.ts +1 -1
- package/dist/runtime/run-start.js +10 -0
- package/dist/runtime/runtime.d.ts +2 -2
- package/dist/runtime/runtime.js +191 -68
- package/dist/runtime/server/auth.d.ts +3 -0
- package/dist/runtime/server/fetch-handler.d.ts +2 -2
- package/dist/runtime/server/fetch-handler.js +35 -4
- package/dist/runtime/server/grant.d.ts +49 -0
- package/dist/runtime/server/grant.js +177 -0
- package/dist/runtime/server/routes/effects.js +47 -5
- package/dist/runtime/server/routes/runs.js +6 -1
- package/dist/runtime/server/run-route.d.ts +18 -0
- package/dist/runtime/server/run-route.js +26 -0
- package/dist/runtime/server.d.ts +3 -1
- package/dist/runtime/server.js +3 -1
- package/dist/runtime/sse.js +26 -4
- package/dist/runtime/subscription.d.ts +8 -0
- package/dist/runtime/subscription.js +23 -0
- package/dist/runtime/types.d.ts +87 -8
- package/dist/runtime/wake-engine.d.ts +4 -2
- package/dist/runtime/wake-engine.js +57 -6
- package/dist/runtime/worker-callbacks.d.ts +12 -2
- package/dist/runtime/worker-callbacks.js +102 -72
- package/dist/runtime/worker-effect-dispatch.d.ts +14 -5
- package/dist/runtime/worker-effect-dispatch.js +84 -18
- package/dist/schedule/debugger/index.d.ts +4 -0
- package/dist/schedule/debugger/index.js +11 -0
- package/dist/schedule/debugger/schedule-view.d.ts +3 -0
- package/dist/schedule/debugger/schedule-view.js +68 -0
- package/dist/schedule/debugger/summarize.d.ts +2 -0
- package/dist/schedule/debugger/summarize.js +43 -0
- package/dist/schedule/definitions-store.d.ts +10 -0
- package/dist/schedule/definitions-store.js +8 -0
- package/dist/schedule/definitions.d.ts +57 -0
- package/dist/schedule/definitions.js +30 -0
- package/dist/schedule/effects.d.ts +756 -0
- package/dist/schedule/effects.js +244 -0
- package/dist/schedule/ensure.d.ts +55 -0
- package/dist/schedule/ensure.js +109 -0
- package/dist/schedule/events.d.ts +116 -0
- package/dist/schedule/events.js +49 -0
- package/dist/schedule/index.d.ts +11 -0
- package/dist/schedule/index.js +11 -0
- package/dist/schedule/module.d.ts +1474 -0
- package/dist/schedule/module.js +22 -0
- package/dist/schedule/projections.d.ts +55 -0
- package/dist/schedule/projections.js +88 -0
- package/dist/schedule/scope.d.ts +121 -0
- package/dist/schedule/scope.js +7 -0
- package/dist/schedule/signals.d.ts +25 -0
- package/dist/schedule/signals.js +33 -0
- package/dist/schedule/specs.d.ts +59 -0
- package/dist/schedule/specs.js +231 -0
- package/dist/schedule/threads.d.ts +25 -0
- package/dist/schedule/threads.js +259 -0
- package/dist/testing/corpus-module.d.ts +34 -0
- package/dist/testing/corpus-module.js +87 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +5 -6
- package/dist/testing/replay-corpus.d.ts +22 -0
- package/dist/testing/replay-corpus.js +50 -0
- package/dist/workflow/definitions-store.d.ts +1 -1
- package/dist/workflow/definitions-store.js +1 -1
- package/dist/workflow/definitions.d.ts +14 -0
- package/dist/workflow/effects.d.ts +122 -0
- package/dist/workflow/effects.js +16 -6
- package/dist/workflow/events.d.ts +42 -0
- package/dist/workflow/events.js +4 -0
- package/dist/workflow/module.d.ts +345 -32
- package/dist/workflow/module.js +7 -3
- package/dist/workflow/scope.d.ts +42 -0
- package/dist/workflow/threads.d.ts +72 -0
- package/dist/workflow/threads.js +106 -38
- package/package.json +11 -2
- package/dist/debugger/app/assets/index-BevQYa-U.js +0 -22
- package/dist/debugger/app/assets/routes-B17Om93i.js +0 -47
- /package/dist/{react → core}/register.js +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type { QueueBatchLike } from './queue-consumer';
|
|
3
|
+
import type { QueueEffectMessage } from './queue-message';
|
|
4
|
+
import type { QueuePullSource } from './queue-pull';
|
|
5
|
+
import type { QueueProducer } from './queue-worker';
|
|
6
|
+
export interface MemoryQueueOptions {
|
|
7
|
+
/** Redeliveries before a message moves to the dead-letter list. Default 3, as on Cloudflare. */
|
|
8
|
+
readonly maxRetries?: number;
|
|
9
|
+
}
|
|
10
|
+
/** A queue in process memory with Cloudflare's lease, retry, and dead-letter behavior. */
|
|
11
|
+
export interface MemoryQueue extends QueueProducer, QueuePullSource {
|
|
12
|
+
/** Messages still held, leased or not. */
|
|
13
|
+
size(): number;
|
|
14
|
+
deadLetters(): ReadonlyArray<QueueEffectMessage>;
|
|
15
|
+
/** Lease visible messages as a push batch, the way a Worker's `queue` handler receives them. */
|
|
16
|
+
batch(size?: number): Effect.Effect<QueueBatchLike>;
|
|
17
|
+
}
|
|
18
|
+
export declare function createMemoryQueue(options?: MemoryQueueOptions): MemoryQueue;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Clock, Effect } from 'effect';
|
|
2
|
+
export function createMemoryQueue(options = {}) {
|
|
3
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
4
|
+
let entries = [];
|
|
5
|
+
const dead = [];
|
|
6
|
+
let nextLease = 0;
|
|
7
|
+
const settleAt = (now, acks, retries) => {
|
|
8
|
+
entries = entries.filter((entry) => entry.leaseId === undefined || !acks.includes(entry.leaseId));
|
|
9
|
+
for (const retry of retries) {
|
|
10
|
+
const entry = entries.find((candidate) => candidate.leaseId === retry.leaseId);
|
|
11
|
+
if (entry) {
|
|
12
|
+
entry.leaseId = undefined;
|
|
13
|
+
entry.visibleAt = now + retry.delaySeconds * 1_000;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const pull = (batchSize, visibilityTimeoutMs) => Effect.map(Clock.currentTimeMillis, (now) => {
|
|
18
|
+
const visible = entries.filter((entry) => entry.visibleAt <= now);
|
|
19
|
+
const exhausted = visible.filter((entry) => entry.deliveries > maxRetries);
|
|
20
|
+
dead.push(...exhausted.map((entry) => entry.body));
|
|
21
|
+
entries = entries.filter((entry) => !exhausted.includes(entry));
|
|
22
|
+
const leased = visible
|
|
23
|
+
.filter((entry) => entry.deliveries <= maxRetries)
|
|
24
|
+
.slice(0, batchSize)
|
|
25
|
+
.map((entry) => {
|
|
26
|
+
nextLease += 1;
|
|
27
|
+
entry.deliveries += 1;
|
|
28
|
+
entry.visibleAt = now + visibilityTimeoutMs;
|
|
29
|
+
entry.leaseId = `lease_${nextLease}`;
|
|
30
|
+
return { leaseId: entry.leaseId, body: entry.body, attempts: entry.deliveries };
|
|
31
|
+
});
|
|
32
|
+
return { messages: leased, backlog: entries.length };
|
|
33
|
+
});
|
|
34
|
+
const settle = (acks, retries) => Effect.map(Clock.currentTimeMillis, (now) => settleAt(now, acks, retries));
|
|
35
|
+
return {
|
|
36
|
+
send: (message) => {
|
|
37
|
+
entries.push({ body: message, deliveries: 0, visibleAt: 0, leaseId: undefined });
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
},
|
|
40
|
+
pull,
|
|
41
|
+
settle,
|
|
42
|
+
size: () => entries.length,
|
|
43
|
+
deadLetters: () => [...dead],
|
|
44
|
+
batch: (size = 100) => pull(size, 30_000).pipe(Effect.map(({ messages }) => ({
|
|
45
|
+
messages: messages.map((message) => ({
|
|
46
|
+
body: message.body,
|
|
47
|
+
attempts: message.attempts,
|
|
48
|
+
ack: () => Effect.runSync(settle([message.leaseId], [])),
|
|
49
|
+
retry: (retry) => Effect.runSync(settle([], [{ leaseId: message.leaseId, delaySeconds: retry?.delaySeconds ?? 0 }])),
|
|
50
|
+
})),
|
|
51
|
+
}))),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { type BlobStore, type EventInput, type JsonValue, type SecretContext, type WorkerCallback, type WorkerCallbackInput, type WorkerCallbackResult, type WorkerSecretsResult } from '@swirls/looms/core';
|
|
3
|
+
import { type QueueEffectMessage, type QueueMessageLike } from './queue-message';
|
|
4
|
+
/** The worker half of the Looms HTTP API. `createLoomsClient({ baseUrl, workerCallbackToken })` is one. */
|
|
5
|
+
export interface WorkerCallbackClient {
|
|
6
|
+
workerCallback(runId: string, input: WorkerCallbackInput): Promise<WorkerCallbackResult>;
|
|
7
|
+
workerSecrets(runId: string, attempt: WorkerCallback): Promise<WorkerSecretsResult>;
|
|
8
|
+
}
|
|
9
|
+
/** The attempt a handler runs, with its input read back from the blob store when it was offloaded. */
|
|
10
|
+
export interface QueueTask extends Omit<QueueEffectMessage, 'v' | 'input' | 'inputRef'> {
|
|
11
|
+
readonly input: JsonValue;
|
|
12
|
+
}
|
|
13
|
+
export interface QueueTaskContext {
|
|
14
|
+
/** Aborts once Looms asks the attempt to stop or no longer counts it as running. */
|
|
15
|
+
readonly signal: AbortSignal;
|
|
16
|
+
/** Report progress. A later attempt receives the last `details` as `task.details`. */
|
|
17
|
+
heartbeat(details?: JsonValue): Promise<void>;
|
|
18
|
+
/** The run's secret context. Rejects once the attempt is no longer running. */
|
|
19
|
+
secrets(): Promise<SecretContext>;
|
|
20
|
+
}
|
|
21
|
+
export type QueueTaskHandler = (task: QueueTask, ctx: QueueTaskContext) => Promise<ReadonlyArray<EventInput>> | ReadonlyArray<EventInput>;
|
|
22
|
+
declare const QueueTaskError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
23
|
+
readonly _tag: "QueueTaskError";
|
|
24
|
+
} & Readonly<A>;
|
|
25
|
+
/** Throw from a handler to control how Looms treats the failed attempt. */
|
|
26
|
+
export declare class QueueTaskError extends QueueTaskError_base<{
|
|
27
|
+
readonly message: string;
|
|
28
|
+
/** Fail the effect even when the retry policy has attempts left. */
|
|
29
|
+
readonly nonRetryable?: boolean;
|
|
30
|
+
/** Wait at least this long before the next attempt. */
|
|
31
|
+
readonly retryAfterMs?: number;
|
|
32
|
+
}> {
|
|
33
|
+
}
|
|
34
|
+
export interface QueueConsumerOptions {
|
|
35
|
+
readonly handlers: Readonly<Record<string, QueueTaskHandler>>;
|
|
36
|
+
readonly callbacks: WorkerCallbackClient;
|
|
37
|
+
/** Reads inputs the producer offloaded. Use the store the producer writes to. */
|
|
38
|
+
readonly blobs?: BlobStore;
|
|
39
|
+
}
|
|
40
|
+
export interface QueueBatchLike {
|
|
41
|
+
readonly messages: ReadonlyArray<QueueMessageLike>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run one delivery. The message is acked as soon as Looms accepts `started`, and Looms owns every
|
|
45
|
+
* retry after that through heartbeat and start-to-close timeouts. A duplicate or stale delivery is
|
|
46
|
+
* acked without running. A message that cannot be started yet is retried by the queue, and one that
|
|
47
|
+
* never starts ends in the dead-letter queue, where the schedule-to-start timeout recovers it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function processQueueMessage(message: QueueMessageLike, options: QueueConsumerOptions): Effect.Effect<void>;
|
|
50
|
+
/** A push consumer: call it from the Worker's `queue(batch)` handler. */
|
|
51
|
+
export declare function queueConsumer(options: QueueConsumerOptions): (batch: QueueBatchLike) => Promise<void>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Data, Effect } from 'effect';
|
|
2
|
+
import { resolveJson, } from '@swirls/looms/core';
|
|
3
|
+
import { readQueueEffectMessage, } from './queue-message.js';
|
|
4
|
+
/** Throw from a handler to control how Looms treats the failed attempt. */
|
|
5
|
+
export class QueueTaskError extends Data.TaggedError('QueueTaskError') {
|
|
6
|
+
}
|
|
7
|
+
class CallbackError extends Data.TaggedError('CallbackError') {
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Run one delivery. The message is acked as soon as Looms accepts `started`, and Looms owns every
|
|
11
|
+
* retry after that through heartbeat and start-to-close timeouts. A duplicate or stale delivery is
|
|
12
|
+
* acked without running. A message that cannot be started yet is retried by the queue, and one that
|
|
13
|
+
* never starts ends in the dead-letter queue, where the schedule-to-start timeout recovers it.
|
|
14
|
+
*/
|
|
15
|
+
export function processQueueMessage(message, options) {
|
|
16
|
+
const task = readQueueEffectMessage(message);
|
|
17
|
+
const handler = task && options.handlers[task.type];
|
|
18
|
+
const redeliver = Effect.sync(() => message.retry({ delaySeconds: redeliveryDelay(message.attempts) }));
|
|
19
|
+
if (!task || !handler) {
|
|
20
|
+
return redeliver;
|
|
21
|
+
}
|
|
22
|
+
const attempt = { effectId: task.effectId, attempt: task.attempt };
|
|
23
|
+
return callback(() => options.callbacks.workerCallback(task.runId, { ...attempt, kind: 'started' })).pipe(Effect.matchEffect({
|
|
24
|
+
onFailure: () => redeliver,
|
|
25
|
+
onSuccess: (started) => Effect.sync(() => message.ack()).pipe(Effect.andThen(started.status === 'accepted'
|
|
26
|
+
? runAttempt(task, attempt, handler, started.cancelRequested, options)
|
|
27
|
+
: Effect.void)),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
/** A push consumer: call it from the Worker's `queue(batch)` handler. */
|
|
31
|
+
export function queueConsumer(options) {
|
|
32
|
+
return (batch) => Effect.runPromise(Effect.forEach(batch.messages, (message) => processQueueMessage(message, options), {
|
|
33
|
+
concurrency: 'unbounded',
|
|
34
|
+
discard: true,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
function runAttempt(task, attempt, handler, cancelRequested, options) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
if (cancelRequested) {
|
|
40
|
+
controller.abort();
|
|
41
|
+
}
|
|
42
|
+
const report = (input) => callback(() => options.callbacks.workerCallback(task.runId, input)).pipe(Effect.ignore);
|
|
43
|
+
const ctx = {
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
heartbeat: (details) => Effect.runPromise(callback(() => options.callbacks.workerCallback(task.runId, details === undefined
|
|
46
|
+
? { ...attempt, kind: 'heartbeat' }
|
|
47
|
+
: { ...attempt, kind: 'heartbeat', details })).pipe(Effect.map((result) => {
|
|
48
|
+
if (result.status !== 'accepted' || result.cancelRequested) {
|
|
49
|
+
controller.abort();
|
|
50
|
+
}
|
|
51
|
+
}))),
|
|
52
|
+
secrets: () => Effect.runPromise(callback(() => options.callbacks.workerSecrets(task.runId, attempt)).pipe(Effect.flatMap((result) => {
|
|
53
|
+
if (result.status === 'accepted') {
|
|
54
|
+
return Effect.succeed(result.secrets);
|
|
55
|
+
}
|
|
56
|
+
controller.abort();
|
|
57
|
+
return Effect.fail(new CallbackError({ message: `the attempt is ${result.status}` }));
|
|
58
|
+
}))),
|
|
59
|
+
};
|
|
60
|
+
return taskInput(task, options.blobs).pipe(Effect.flatMap((input) => Effect.tryPromise({
|
|
61
|
+
try: () => Promise.resolve(handler({ ...taskFields(task), input }, ctx)),
|
|
62
|
+
catch: (cause) => cause instanceof QueueTaskError
|
|
63
|
+
? cause
|
|
64
|
+
: new QueueTaskError({
|
|
65
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
66
|
+
}),
|
|
67
|
+
})), Effect.matchEffect({
|
|
68
|
+
onSuccess: (events) => report({ ...attempt, kind: 'complete', events }),
|
|
69
|
+
onFailure: (error) => report(controller.signal.aborted
|
|
70
|
+
? { ...attempt, kind: 'cancelled' }
|
|
71
|
+
: {
|
|
72
|
+
...attempt,
|
|
73
|
+
kind: 'fail',
|
|
74
|
+
error: error.message,
|
|
75
|
+
nonRetryable: error.nonRetryable,
|
|
76
|
+
retryAfterMs: error.retryAfterMs,
|
|
77
|
+
}),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
function taskFields(task) {
|
|
81
|
+
const { v: _v, input: _input, inputRef: _inputRef, ...fields } = task;
|
|
82
|
+
return fields;
|
|
83
|
+
}
|
|
84
|
+
function taskInput(task, blobs) {
|
|
85
|
+
if (!task.inputRef) {
|
|
86
|
+
return Effect.succeed(task.input ?? null);
|
|
87
|
+
}
|
|
88
|
+
if (!blobs) {
|
|
89
|
+
return Effect.fail(new QueueTaskError({ message: 'the input was offloaded and the consumer has no blob store' }));
|
|
90
|
+
}
|
|
91
|
+
return resolveJson(task.inputRef, blobs).pipe(Effect.mapError((error) => new QueueTaskError({ message: `could not read the input: ${error.message}` })));
|
|
92
|
+
}
|
|
93
|
+
function callback(call) {
|
|
94
|
+
return Effect.tryPromise({
|
|
95
|
+
try: call,
|
|
96
|
+
catch: (cause) => new CallbackError({ message: cause instanceof Error ? cause.message : String(cause) }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Seconds before the queue redelivers a message that could not start: 1, 2, 4, ... up to 60. */
|
|
100
|
+
function redeliveryDelay(attempts) {
|
|
101
|
+
return Math.min(60, 2 ** Math.max(0, attempts - 1));
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type BlobRef, type JsonValue, type TraceContext } from '@swirls/looms/core';
|
|
2
|
+
import type { EffectWorkerDeadlines } from '@swirls/looms/runtime';
|
|
3
|
+
/**
|
|
4
|
+
* One effect attempt on a Cloudflare Queue. It never carries the run's secrets; the consumer reads
|
|
5
|
+
* them through the worker callback API. Exactly one of `input` and `inputRef` is set.
|
|
6
|
+
*/
|
|
7
|
+
export interface QueueEffectMessage {
|
|
8
|
+
readonly v: 1;
|
|
9
|
+
readonly runId: string;
|
|
10
|
+
readonly effectId: string;
|
|
11
|
+
readonly attempt: number;
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly input?: JsonValue;
|
|
14
|
+
/** Set instead of `input` when the input would push the message over the queue's size limit. */
|
|
15
|
+
readonly inputRef?: BlobRef;
|
|
16
|
+
/** Progress from the previous attempt's last heartbeat. */
|
|
17
|
+
readonly details?: JsonValue;
|
|
18
|
+
readonly trace?: TraceContext;
|
|
19
|
+
readonly deadlines?: EffectWorkerDeadlines;
|
|
20
|
+
}
|
|
21
|
+
/** A delivered message, shaped like a Cloudflare `Message`. */
|
|
22
|
+
export interface QueueMessageLike {
|
|
23
|
+
readonly body: unknown;
|
|
24
|
+
readonly attempts: number;
|
|
25
|
+
ack(): void;
|
|
26
|
+
retry(options?: {
|
|
27
|
+
readonly delaySeconds?: number;
|
|
28
|
+
}): void;
|
|
29
|
+
}
|
|
30
|
+
/** `undefined` unless the body is a version 1 message with exactly one of `input` and `inputRef`. */
|
|
31
|
+
export declare function readQueueEffectMessage(message: QueueMessageLike): QueueEffectMessage | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Option, Schema } from 'effect';
|
|
2
|
+
import { isBlobRef, TraceContextSchema, } from '@swirls/looms/core';
|
|
3
|
+
const QueueEffectMessageSchema = Schema.Struct({
|
|
4
|
+
v: Schema.Literal(1),
|
|
5
|
+
runId: Schema.String,
|
|
6
|
+
effectId: Schema.String,
|
|
7
|
+
attempt: Schema.Finite,
|
|
8
|
+
type: Schema.String,
|
|
9
|
+
input: Schema.optional(Schema.Json),
|
|
10
|
+
inputRef: Schema.optional(Schema.Json),
|
|
11
|
+
details: Schema.optional(Schema.Json),
|
|
12
|
+
trace: Schema.optional(TraceContextSchema),
|
|
13
|
+
deadlines: Schema.optional(Schema.Struct({
|
|
14
|
+
scheduleToStartAt: Schema.optional(Schema.Finite),
|
|
15
|
+
startToCloseTimeoutMs: Schema.optional(Schema.Finite),
|
|
16
|
+
heartbeatTimeoutMs: Schema.optional(Schema.Finite),
|
|
17
|
+
})),
|
|
18
|
+
});
|
|
19
|
+
const decodeMessage = Schema.decodeUnknownOption(QueueEffectMessageSchema);
|
|
20
|
+
/** `undefined` unless the body is a version 1 message with exactly one of `input` and `inputRef`. */
|
|
21
|
+
export function readQueueEffectMessage(message) {
|
|
22
|
+
return Option.match(decodeMessage(message.body), {
|
|
23
|
+
onNone: () => undefined,
|
|
24
|
+
onSome: ({ inputRef, ...decoded }) => {
|
|
25
|
+
if (inputRef === undefined) {
|
|
26
|
+
return decoded.input === undefined ? undefined : decoded;
|
|
27
|
+
}
|
|
28
|
+
return decoded.input === undefined && isBlobRef(inputRef)
|
|
29
|
+
? { ...decoded, inputRef }
|
|
30
|
+
: undefined;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { type QueueConsumerOptions } from './queue-consumer';
|
|
3
|
+
export interface PulledQueueMessage {
|
|
4
|
+
readonly leaseId: string;
|
|
5
|
+
readonly body: unknown;
|
|
6
|
+
/** Deliveries so far, including this one. */
|
|
7
|
+
readonly attempts: number;
|
|
8
|
+
}
|
|
9
|
+
export interface QueuePullResult {
|
|
10
|
+
readonly messages: ReadonlyArray<PulledQueueMessage>;
|
|
11
|
+
/** Messages waiting in the queue, when the source reports it. */
|
|
12
|
+
readonly backlog?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface QueueLeaseRetry {
|
|
15
|
+
readonly leaseId: string;
|
|
16
|
+
readonly delaySeconds: number;
|
|
17
|
+
}
|
|
18
|
+
declare const QueuePullError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
19
|
+
readonly _tag: "QueuePullError";
|
|
20
|
+
} & Readonly<A>;
|
|
21
|
+
export declare class QueuePullError extends QueuePullError_base<{
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly status?: number;
|
|
24
|
+
}> {
|
|
25
|
+
}
|
|
26
|
+
/** Where a pull consumer leases messages from. `cloudflareQueuePullSource` reads a Cloudflare Queue. */
|
|
27
|
+
export interface QueuePullSource {
|
|
28
|
+
readonly pull: (batchSize: number, visibilityTimeoutMs: number) => Effect.Effect<QueuePullResult, QueuePullError>;
|
|
29
|
+
readonly settle: (acks: ReadonlyArray<string>, retries: ReadonlyArray<QueueLeaseRetry>) => Effect.Effect<void, QueuePullError>;
|
|
30
|
+
}
|
|
31
|
+
export interface QueuePullConsumerOptions extends QueueConsumerOptions {
|
|
32
|
+
readonly source: QueuePullSource;
|
|
33
|
+
/** Attempts run at once. Default 8. */
|
|
34
|
+
readonly concurrency?: number;
|
|
35
|
+
/** How long a leased message stays hidden. Acks land long before; default 30 s. */
|
|
36
|
+
readonly visibilityTimeoutMs?: number;
|
|
37
|
+
/** The longest wait between pulls that come back empty or fail. Default 5 s. */
|
|
38
|
+
readonly maxIdleMs?: number;
|
|
39
|
+
/** How often buffered acks and retries are sent. Default 250 ms. */
|
|
40
|
+
readonly ackIntervalMs?: number;
|
|
41
|
+
/** A pull or ack request failed. The consumer backs off and keeps going. */
|
|
42
|
+
readonly onError?: (error: QueuePullError) => void;
|
|
43
|
+
}
|
|
44
|
+
export interface QueuePullConsumer {
|
|
45
|
+
/** Messages waiting in the queue at the last pull, for autoscaling. */
|
|
46
|
+
backlog(): number | undefined;
|
|
47
|
+
inFlight(): number;
|
|
48
|
+
/** Stop pulling, let running attempts finish, and send the last acks. Call it on SIGTERM. */
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** Lease messages from `source` and run them with the same handlers as {@link queueConsumer}. */
|
|
52
|
+
export declare function createQueuePullConsumer(options: QueuePullConsumerOptions): QueuePullConsumer;
|
|
53
|
+
export interface CloudflareQueuePullSourceOptions {
|
|
54
|
+
readonly accountId: string;
|
|
55
|
+
readonly queueId: string;
|
|
56
|
+
/** An API token with Queues edit permission. */
|
|
57
|
+
readonly token: string;
|
|
58
|
+
readonly fetch?: typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
/** The Cloudflare Queues HTTP pull API. The producer must send `contentType: 'json'`. */
|
|
61
|
+
export declare function cloudflareQueuePullSource(options: CloudflareQueuePullSourceOptions): QueuePullSource;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Data, Deferred, Effect, Fiber, FiberSet, Option, Predicate, Schema, Semaphore, } from 'effect';
|
|
2
|
+
import { stringifyJson } from '@swirls/looms/core';
|
|
3
|
+
import { processQueueMessage } from './queue-consumer.js';
|
|
4
|
+
export class QueuePullError extends Data.TaggedError('QueuePullError') {
|
|
5
|
+
}
|
|
6
|
+
const MAX_PULL_BATCH = 100;
|
|
7
|
+
const MIN_IDLE_MS = 50;
|
|
8
|
+
/** Lease messages from `source` and run them with the same handlers as {@link queueConsumer}. */
|
|
9
|
+
export function createQueuePullConsumer(options) {
|
|
10
|
+
const concurrency = Math.max(1, options.concurrency ?? 8);
|
|
11
|
+
const visibilityTimeoutMs = options.visibilityTimeoutMs ?? 30_000;
|
|
12
|
+
const maxIdleMs = options.maxIdleMs ?? 5_000;
|
|
13
|
+
const slots = Semaphore.makeUnsafe(concurrency);
|
|
14
|
+
const stopping = Deferred.makeUnsafe();
|
|
15
|
+
let backlog;
|
|
16
|
+
let inFlight = 0;
|
|
17
|
+
let acks = [];
|
|
18
|
+
let retries = [];
|
|
19
|
+
const reportError = (error) => Effect.sync(() => options.onError?.(error));
|
|
20
|
+
const flush = Effect.suspend(() => {
|
|
21
|
+
if (acks.length === 0 && retries.length === 0) {
|
|
22
|
+
return Effect.void;
|
|
23
|
+
}
|
|
24
|
+
const settled = options.source.settle(acks, retries);
|
|
25
|
+
acks = [];
|
|
26
|
+
retries = [];
|
|
27
|
+
return settled.pipe(Effect.catchTag('QueuePullError', reportError));
|
|
28
|
+
});
|
|
29
|
+
const lease = (message) => ({
|
|
30
|
+
body: message.body,
|
|
31
|
+
attempts: message.attempts,
|
|
32
|
+
ack: () => {
|
|
33
|
+
acks.push(message.leaseId);
|
|
34
|
+
},
|
|
35
|
+
retry: (retry) => {
|
|
36
|
+
retries.push({ leaseId: message.leaseId, delaySeconds: retry?.delaySeconds ?? 0 });
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const untilStopped = (effect) => Effect.raceFirst(effect, Deferred.await(stopping));
|
|
40
|
+
const run = Effect.gen(function* () {
|
|
41
|
+
const running = yield* FiberSet.make();
|
|
42
|
+
const flusher = yield* Effect.forkScoped(Effect.forever(Effect.sleep(options.ackIntervalMs ?? 250).pipe(Effect.andThen(flush))));
|
|
43
|
+
let idleMs = 0;
|
|
44
|
+
while (!(yield* Deferred.isDone(stopping))) {
|
|
45
|
+
yield* untilStopped(Semaphore.withPermits(slots, 1)(Effect.void));
|
|
46
|
+
if (yield* Deferred.isDone(stopping)) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const pulled = yield* options.source
|
|
50
|
+
.pull(Math.min(concurrency - inFlight, MAX_PULL_BATCH), visibilityTimeoutMs)
|
|
51
|
+
.pipe(Effect.catchTag('QueuePullError', (error) => reportError(error).pipe(Effect.as({ messages: [] }))));
|
|
52
|
+
backlog = pulled.backlog ?? backlog;
|
|
53
|
+
for (const message of pulled.messages) {
|
|
54
|
+
inFlight += 1;
|
|
55
|
+
yield* FiberSet.run(running, Semaphore.withPermits(slots, 1)(processQueueMessage(lease(message), options)).pipe(Effect.ensuring(Effect.sync(() => {
|
|
56
|
+
inFlight -= 1;
|
|
57
|
+
}))));
|
|
58
|
+
}
|
|
59
|
+
idleMs =
|
|
60
|
+
pulled.messages.length > 0 ? 0 : Math.min(maxIdleMs, Math.max(MIN_IDLE_MS, idleMs * 2));
|
|
61
|
+
if (idleMs > 0) {
|
|
62
|
+
yield* untilStopped(Effect.sleep(idleMs));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
yield* FiberSet.awaitEmpty(running);
|
|
66
|
+
yield* Fiber.interrupt(flusher);
|
|
67
|
+
yield* flush;
|
|
68
|
+
});
|
|
69
|
+
const fiber = Effect.runFork(Effect.scoped(run));
|
|
70
|
+
return {
|
|
71
|
+
backlog: () => backlog,
|
|
72
|
+
inFlight: () => inFlight,
|
|
73
|
+
stop: () => Effect.runPromise(Deferred.succeed(stopping, undefined).pipe(Effect.andThen(Fiber.join(fiber)))),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const PullResponseSchema = Schema.Struct({
|
|
77
|
+
result: Schema.Struct({
|
|
78
|
+
message_backlog_count: Schema.optional(Schema.Finite),
|
|
79
|
+
messages: Schema.Array(Schema.Struct({
|
|
80
|
+
lease_id: Schema.String,
|
|
81
|
+
attempts: Schema.Finite,
|
|
82
|
+
body: Schema.Json,
|
|
83
|
+
})),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const decodePullResponse = Schema.decodeUnknownEffect(PullResponseSchema);
|
|
87
|
+
const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
|
|
88
|
+
/** The Cloudflare Queues HTTP pull API. The producer must send `contentType: 'json'`. */
|
|
89
|
+
export function cloudflareQueuePullSource(options) {
|
|
90
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
91
|
+
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(options.accountId)}/queues/${encodeURIComponent(options.queueId)}/messages`;
|
|
92
|
+
const post = (path, body) => Effect.tryPromise({
|
|
93
|
+
try: () => fetchImpl(`${endpoint}/${path}`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
authorization: `Bearer ${options.token}`,
|
|
97
|
+
'content-type': 'application/json',
|
|
98
|
+
},
|
|
99
|
+
body: stringifyJson(body),
|
|
100
|
+
}),
|
|
101
|
+
catch: (cause) => new QueuePullError({ message: cause instanceof Error ? cause.message : String(cause) }),
|
|
102
|
+
}).pipe(Effect.filterOrFail((response) => response.ok, (response) => new QueuePullError({
|
|
103
|
+
message: `Cloudflare Queues ${path} returned ${response.status}`,
|
|
104
|
+
status: response.status,
|
|
105
|
+
})));
|
|
106
|
+
return {
|
|
107
|
+
pull: (batchSize, visibilityTimeoutMs) => post('pull', { batch_size: batchSize, visibility_timeout_ms: visibilityTimeoutMs }).pipe(Effect.flatMap((response) => Effect.tryPromise({
|
|
108
|
+
try: () => response.json(),
|
|
109
|
+
catch: () => new QueuePullError({ message: 'the pull response is not JSON' }),
|
|
110
|
+
})), Effect.flatMap((json) => decodePullResponse(json).pipe(Effect.mapError(() => new QueuePullError({ message: 'unexpected pull response' })))), Effect.map(({ result }) => ({
|
|
111
|
+
backlog: result.message_backlog_count,
|
|
112
|
+
messages: result.messages.map((message) => ({
|
|
113
|
+
leaseId: message.lease_id,
|
|
114
|
+
attempts: message.attempts,
|
|
115
|
+
body: jsonBody(message.body),
|
|
116
|
+
})),
|
|
117
|
+
}))),
|
|
118
|
+
settle: (acks, retries) => post('ack', {
|
|
119
|
+
acks: acks.map((leaseId) => ({ lease_id: leaseId })),
|
|
120
|
+
retries: retries.map((retry) => ({
|
|
121
|
+
lease_id: retry.leaseId,
|
|
122
|
+
delay_seconds: retry.delaySeconds,
|
|
123
|
+
})),
|
|
124
|
+
}).pipe(Effect.asVoid),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The pull API returns a JSON message's body as its JSON text. */
|
|
128
|
+
function jsonBody(body) {
|
|
129
|
+
return Predicate.isString(body) ? Option.getOrElse(decodeJsonText(body), () => body) : body;
|
|
130
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Types } from 'effect';
|
|
2
|
+
import { type BlobStore } from '@swirls/looms/core';
|
|
3
|
+
import type { EffectWorker } from '@swirls/looms/runtime';
|
|
4
|
+
import type { QueueEffectMessage } from './queue-message';
|
|
5
|
+
/** Leaves room under Cloudflare's 128 KB message limit. */
|
|
6
|
+
export declare const DEFAULT_MAX_QUEUE_MESSAGE_BYTES = 100000;
|
|
7
|
+
/** A Cloudflare Queue producer binding, or anything that sends the same way. */
|
|
8
|
+
export interface QueueProducer {
|
|
9
|
+
send(message: QueueEffectMessage, options?: {
|
|
10
|
+
readonly contentType?: 'json';
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface QueueRoute {
|
|
14
|
+
readonly handles: ReadonlyArray<string> | ((type: string) => boolean);
|
|
15
|
+
readonly queue: QueueProducer;
|
|
16
|
+
}
|
|
17
|
+
export interface CloudflareQueueWorkerOptions {
|
|
18
|
+
/** The first route that handles an effect type gets its attempts. */
|
|
19
|
+
readonly routes: ReadonlyArray<QueueRoute>;
|
|
20
|
+
/** Holds an input that would push a message past `maxMessageBytes`. Without it, that dispatch fails. */
|
|
21
|
+
readonly blobs?: BlobStore;
|
|
22
|
+
readonly maxMessageBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
declare const QueueDispatchError_base: new <A extends Record<string, any> = {}>(args: Types.VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
25
|
+
readonly _tag: "QueueDispatchError";
|
|
26
|
+
} & Readonly<A>;
|
|
27
|
+
export declare class QueueDispatchError extends QueueDispatchError_base<{
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}> {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An {@link EffectWorker} that sends each attempt to a Cloudflare Queue. A send the queue rejects
|
|
33
|
+
* (a 429, for one) fails the attempt under the effect's retry policy. A message the queue accepts
|
|
34
|
+
* and later dead-letters is recovered by the effect's schedule-to-start timeout, which every routed
|
|
35
|
+
* effect must set. Cancellation reaches a running attempt through its callback responses, since a
|
|
36
|
+
* queued message cannot be recalled.
|
|
37
|
+
*/
|
|
38
|
+
export declare function cloudflareQueueWorker(options: CloudflareQueueWorkerOptions): EffectWorker;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Data, Effect, Predicate } from 'effect';
|
|
2
|
+
import { claimJson, utf8JsonBytes } from '@swirls/looms/core';
|
|
3
|
+
/** Leaves room under Cloudflare's 128 KB message limit. */
|
|
4
|
+
export const DEFAULT_MAX_QUEUE_MESSAGE_BYTES = 100_000;
|
|
5
|
+
export class QueueDispatchError extends Data.TaggedError('QueueDispatchError') {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* An {@link EffectWorker} that sends each attempt to a Cloudflare Queue. A send the queue rejects
|
|
9
|
+
* (a 429, for one) fails the attempt under the effect's retry policy. A message the queue accepts
|
|
10
|
+
* and later dead-letters is recovered by the effect's schedule-to-start timeout, which every routed
|
|
11
|
+
* effect must set. Cancellation reaches a running attempt through its callback responses, since a
|
|
12
|
+
* queued message cannot be recalled.
|
|
13
|
+
*/
|
|
14
|
+
export function cloudflareQueueWorker(options) {
|
|
15
|
+
const routeFor = (type) => options.routes.find((route) => Predicate.isFunction(route.handles) ? route.handles(type) : route.handles.includes(type));
|
|
16
|
+
return {
|
|
17
|
+
dispatchSemantics: 'acknowledged',
|
|
18
|
+
handles: (type) => routeFor(type) !== undefined,
|
|
19
|
+
dispatch: (task) => Effect.runPromise(sendTask(task, routeFor(task.type), options)),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function sendTask(task, route, options) {
|
|
23
|
+
if (!route) {
|
|
24
|
+
return Effect.fail(new QueueDispatchError({ message: `no queue route handles "${task.type}"` }));
|
|
25
|
+
}
|
|
26
|
+
return queueMessage(task, options).pipe(Effect.flatMap((message) => Effect.tryPromise({
|
|
27
|
+
try: () => route.queue.send(message, { contentType: 'json' }),
|
|
28
|
+
catch: (cause) => new QueueDispatchError({
|
|
29
|
+
message: `queue send failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
30
|
+
}),
|
|
31
|
+
})));
|
|
32
|
+
}
|
|
33
|
+
function queueMessage(task, options) {
|
|
34
|
+
const message = {
|
|
35
|
+
v: 1,
|
|
36
|
+
runId: task.runId,
|
|
37
|
+
effectId: task.effectId,
|
|
38
|
+
attempt: task.attempt,
|
|
39
|
+
type: task.type,
|
|
40
|
+
};
|
|
41
|
+
if (task.details !== undefined) {
|
|
42
|
+
message.details = task.details;
|
|
43
|
+
}
|
|
44
|
+
if (task.trace) {
|
|
45
|
+
message.trace = task.trace;
|
|
46
|
+
}
|
|
47
|
+
if (task.deadlines) {
|
|
48
|
+
message.deadlines = task.deadlines;
|
|
49
|
+
}
|
|
50
|
+
const inline = { ...message, input: task.input };
|
|
51
|
+
const limit = options.maxMessageBytes ?? DEFAULT_MAX_QUEUE_MESSAGE_BYTES;
|
|
52
|
+
if (utf8JsonBytes(inline) <= limit) {
|
|
53
|
+
return Effect.succeed(inline);
|
|
54
|
+
}
|
|
55
|
+
const { blobs } = options;
|
|
56
|
+
if (!blobs) {
|
|
57
|
+
return Effect.fail(new QueueDispatchError({
|
|
58
|
+
message: `the "${task.type}" message is over ${limit} bytes and no blob store is configured`,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
return claimJson(task.input, blobs).pipe(Effect.tap((ref) => blobs.retain(task.runId, [ref.sha256])), Effect.map((inputRef) => ({ ...message, inputRef })), Effect.mapError((error) => new QueueDispatchError({ message: `could not store the input: ${error.message}` })));
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type BlobStore } from '@swirls/looms/core';
|
|
2
|
+
export interface R2ObjectBodyLike {
|
|
3
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
4
|
+
}
|
|
5
|
+
export interface R2ListResult {
|
|
6
|
+
readonly objects: ReadonlyArray<{
|
|
7
|
+
readonly key: string;
|
|
8
|
+
}>;
|
|
9
|
+
readonly truncated: boolean;
|
|
10
|
+
readonly cursor?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Structural subset of a Cloudflare `R2Bucket` binding. An `R2Bucket` satisfies it.
|
|
14
|
+
*/
|
|
15
|
+
export interface R2StoredObject {
|
|
16
|
+
readonly key: string;
|
|
17
|
+
}
|
|
18
|
+
export interface R2BucketLike {
|
|
19
|
+
put(key: string, value: Uint8Array): Promise<R2StoredObject>;
|
|
20
|
+
get(key: string): Promise<R2ObjectBodyLike | null>;
|
|
21
|
+
head(key: string): Promise<R2StoredObject | null>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
list(options?: {
|
|
24
|
+
prefix?: string;
|
|
25
|
+
cursor?: string;
|
|
26
|
+
}): Promise<R2ListResult>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Content-addressed blob store in R2 under `looms/blobs/`. Every run's Durable Object can
|
|
30
|
+
* share one bucket: each run writes its own reference markers, and a body is deleted only
|
|
31
|
+
* after no marker for it remains.
|
|
32
|
+
*/
|
|
33
|
+
export declare function r2BlobStore(bucket: R2BucketLike): BlobStore;
|