@lostgradient/weft 0.2.0 → 0.3.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/README.md +47 -22
- package/dist/cli/generated/operation-client.generated.d.ts +28 -1
- package/dist/cli/generated/operation-client.generated.js +2 -0
- package/dist/cli-main.js +79 -79
- package/dist/client/handle-delegation.d.ts +4 -0
- package/dist/client/handle-delegation.js +6 -0
- package/dist/client/http-client-requests.d.ts +2 -0
- package/dist/client/http-client-requests.js +3 -0
- package/dist/client/http-client.d.ts +4 -1
- package/dist/client/http-client.js +9 -1
- package/dist/client/interface.d.ts +57 -2
- package/dist/client/local.d.ts +4 -1
- package/dist/client/local.js +7 -0
- package/dist/client/start-body.d.ts +7 -1
- package/dist/client/start-body.js +13 -4
- package/dist/core/codec/extension-codec.js +4 -2
- package/dist/core/codec/index.d.ts +1 -0
- package/dist/core/codec/index.js +1 -0
- package/dist/core/codec/serializer-registry.d.ts +122 -0
- package/dist/core/codec/serializer-registry.js +51 -0
- package/dist/core/context/index.d.ts +10 -0
- package/dist/core/context/index.js +3 -0
- package/dist/core/context/internals.d.ts +10 -0
- package/dist/core/context/internals.js +5 -1
- package/dist/core/context/run-operation.d.ts +16 -3
- package/dist/core/context/run-operation.js +16 -7
- package/dist/core/context/speculative-child.js +2 -0
- package/dist/core/context/types.d.ts +6 -0
- package/dist/core/engine/bulk-operations-purge.js +1 -0
- package/dist/core/engine/bulk-operations.js +1 -1
- package/dist/core/engine/callback-creators-bundles.js +2 -1
- package/dist/core/engine/callback-creators-core.js +2 -1
- package/dist/core/engine/construction.d.ts +1 -1
- package/dist/core/engine/construction.js +15 -3
- package/dist/core/engine/disposal.js +12 -0
- package/dist/core/engine/engine-create-types.d.ts +0 -14
- package/dist/core/engine/engine-internal-types.d.ts +17 -0
- package/dist/core/engine/engine-leak-warnings.d.ts +6 -0
- package/dist/core/engine/engine-leak-warnings.js +4 -0
- package/dist/core/engine/engine-runtime-helpers.d.ts +17 -0
- package/dist/core/engine/engine-runtime-helpers.js +26 -5
- package/dist/core/engine/errors.d.ts +74 -0
- package/dist/core/engine/errors.js +25 -1
- package/dist/core/engine/handle-result.js +1 -1
- package/dist/core/engine/handles.d.ts +89 -40
- package/dist/core/engine/handles.js +25 -27
- package/dist/core/engine/index.d.ts +122 -4
- package/dist/core/engine/index.js +82 -5
- package/dist/core/engine/inline-launch-queue.d.ts +14 -0
- package/dist/core/engine/inline-launch-queue.js +32 -7
- package/dist/core/engine/internals.d.ts +26 -10
- package/dist/core/engine/lifecycle/fork-helpers.js +1 -7
- package/dist/core/engine/lifecycle/persist.js +5 -20
- package/dist/core/engine/lifecycle/recovered-services.d.ts +45 -0
- package/dist/core/engine/lifecycle/recovered-services.js +34 -0
- package/dist/core/engine/lifecycle/resume.js +33 -5
- package/dist/core/engine/lifecycle/shared.d.ts +8 -0
- package/dist/core/engine/lifecycle/start-batch.js +23 -12
- package/dist/core/engine/lifecycle/start-commit.d.ts +47 -0
- package/dist/core/engine/lifecycle/start-commit.js +27 -0
- package/dist/core/engine/lifecycle/start-exec.d.ts +30 -2
- package/dist/core/engine/lifecycle/start-exec.js +38 -0
- package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +79 -0
- package/dist/core/engine/lifecycle/start-or-signal-resolution.js +60 -0
- package/dist/core/engine/lifecycle/start-or-signal.d.ts +45 -0
- package/dist/core/engine/lifecycle/start-or-signal.js +141 -0
- package/dist/core/engine/lifecycle/start.d.ts +3 -3
- package/dist/core/engine/lifecycle/start.js +42 -37
- package/dist/core/engine/lifecycle.d.ts +3 -2
- package/dist/core/engine/lifecycle.js +9 -2
- package/dist/core/engine/listing.js +1 -1
- package/dist/core/engine/operations-data.d.ts +16 -0
- package/dist/core/engine/operations-data.js +6 -0
- package/dist/core/engine/operations-time.d.ts +3 -2
- package/dist/core/engine/operations-time.js +6 -1
- package/dist/core/engine/persisted-data-version.d.ts +5 -9
- package/dist/core/engine/persisted-data-version.js +4 -5
- package/dist/core/engine/schedule-handle.d.ts +45 -0
- package/dist/core/engine/schedule-handle.js +26 -0
- package/dist/core/engine/schedules.d.ts +1 -1
- package/dist/core/engine/schedules.js +7 -3
- package/dist/core/engine/second-instance-detector.d.ts +96 -0
- package/dist/core/engine/second-instance-detector.js +108 -0
- package/dist/core/engine/signals.d.ts +22 -0
- package/dist/core/engine/signals.js +15 -0
- package/dist/core/engine/termination/cleanup.d.ts +25 -0
- package/dist/core/engine/termination/cleanup.js +21 -1
- package/dist/core/engine/termination/complete.js +4 -3
- package/dist/core/engine/termination/suspend.d.ts +68 -0
- package/dist/core/engine/termination/suspend.js +41 -0
- package/dist/core/engine/termination.d.ts +4 -2
- package/dist/core/engine/termination.js +2 -0
- package/dist/core/engine/validation.js +25 -1
- package/dist/core/engine/workflow-feed.d.ts +5 -3
- package/dist/core/events/event-map.d.ts +2 -1
- package/dist/core/events/workflow-events.d.ts +23 -0
- package/dist/core/events/workflow-events.js +9 -0
- package/dist/core/inline-execution-strategy.d.ts +5 -0
- package/dist/core/inline-execution-strategy.js +2 -1
- package/dist/core/list-filter-validation.js +2 -1
- package/dist/core/start-workflow-validation.d.ts +22 -0
- package/dist/core/start-workflow-validation.js +11 -1
- package/dist/core/step-context.d.ts +10 -6
- package/dist/core/step-context.js +7 -15
- package/dist/core/types/activity.d.ts +6 -3
- package/dist/core/types/identity.d.ts +8 -1
- package/dist/core/types/launch-metadata.d.ts +33 -0
- package/dist/core/types/launch-metadata.js +0 -0
- package/dist/core/types/message-handles.d.ts +25 -0
- package/dist/core/types/options.d.ts +90 -7
- package/dist/core/types/reviews.d.ts +2 -1
- package/dist/core/types/services-resolution.d.ts +47 -0
- package/dist/core/types/services-resolution.js +0 -0
- package/dist/core/types/state.d.ts +11 -11
- package/dist/core/types/workflow-builder.d.ts +5 -4
- package/dist/core/types/workflow-context.d.ts +25 -0
- package/dist/core/types/workflow-function.d.ts +17 -0
- package/dist/core/types/workflow-snapshot.d.ts +29 -0
- package/dist/core/types/workflow-snapshot.js +0 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.js +3 -0
- package/dist/core/weft-error.d.ts +46 -14
- package/dist/core/weft-error.js +12 -1
- package/dist/diagnostics/doctor.js +6 -3
- package/dist/diagnostics/format.js +2 -2
- package/dist/diagnostics/types.d.ts +1 -0
- package/dist/diagnostics/version-check.js +6 -4
- package/dist/index.d.ts +10 -5
- package/dist/index.js +11 -2
- package/dist/json-schema.js +3 -3
- package/dist/mcp/cli.js +35 -35
- package/dist/mcp/list-filter.js +2 -1
- package/dist/mcp/session.js +1 -0
- package/dist/observability/index.js +2 -2
- package/dist/server/handler.js +30 -30
- package/dist/server/index.js +33 -33
- package/dist/server/interactive-operations.js +1 -0
- package/dist/server/operations/resume-workflow.js +2 -2
- package/dist/server/operations/start-or-signal-workflow.d.ts +39 -0
- package/dist/server/operations/start-or-signal-workflow.js +140 -0
- package/dist/server/operations/start-workflow-options.d.ts +32 -0
- package/dist/server/operations/start-workflow-options.js +63 -0
- package/dist/server/operations/start-workflow.js +7 -69
- package/dist/server/operations/suspend-workflow.d.ts +13 -0
- package/dist/server/operations/suspend-workflow.js +36 -0
- package/dist/server/rest-binding.d.ts +18 -7
- package/dist/server/rest-bindings.js +12 -0
- package/dist/server/runtime/task-dispatch.js +5 -3
- package/dist/server/runtime/task-polling.d.ts +16 -2
- package/dist/server/runtime/task-polling.js +20 -5
- package/dist/server/runtime/websocket-worker.js +8 -0
- package/dist/server/serve-internals.d.ts +8 -0
- package/dist/server/serve-internals.js +4 -2
- package/dist/server/task-state.d.ts +8 -0
- package/dist/service-worker/index.js +28 -28
- package/dist/storage/capabilities.d.ts +10 -2
- package/dist/storage/capabilities.js +2 -2
- package/dist/storage/http.js +2 -2
- package/dist/storage/index.d.ts +7 -1
- package/dist/storage/indexeddb.js +1 -1
- package/dist/storage/interface.d.ts +40 -0
- package/dist/storage/interface.js +1 -1
- package/dist/storage/key-prefixes.d.ts +1 -1
- package/dist/storage/key-prefixes.js +3 -0
- package/dist/storage/lmdb.js +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/neon-value-mapping.d.ts +47 -0
- package/dist/storage/neon-value-mapping.js +11 -0
- package/dist/storage/neon.d.ts +108 -0
- package/dist/storage/neon.js +10 -0
- package/dist/storage/node-sqlite-loader.d.ts +71 -0
- package/dist/storage/node-sqlite-loader.js +41 -0
- package/dist/storage/node-sqlite.d.ts +1 -19
- package/dist/storage/node-sqlite.js +38 -32
- package/dist/storage/postgres-key-value-queries.d.ts +79 -0
- package/dist/storage/postgres-key-value-queries.js +63 -0
- package/dist/storage/resolve.d.ts +2 -165
- package/dist/storage/resolve.js +1 -1
- package/dist/storage/scoped-storage.js +1 -1
- package/dist/storage/storage-configuration.d.ts +209 -0
- package/dist/storage/storage-configuration.js +0 -0
- package/dist/storage/text-value-store.d.ts +13 -10
- package/dist/storage/turso.js +2 -2
- package/dist/storage/typed-storage.js +1 -1
- package/dist/storage/web-extension.js +1 -1
- package/dist/testing/event-loop.d.ts +36 -2
- package/dist/testing/index.d.ts +31 -1
- package/dist/testing/index.js +33 -33
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/index.js +9 -5
- package/dist/worker/long-poll.js +4 -0
- package/dist/worker/protocol-messages.d.ts +20 -0
- package/dist/worker/protocol-schemas.d.ts +32 -0
- package/dist/worker/protocol-schemas.js +8 -4
- package/dist/worker/protocol-task-result.d.ts +28 -0
- package/dist/worker/protocol-task-result.js +76 -0
- package/dist/worker/protocol.d.ts +4 -15
- package/dist/worker/protocol.js +1 -1
- package/dist/worker/registry/fair-share.d.ts +29 -0
- package/dist/worker/registry/fair-share.js +30 -0
- package/dist/worker/registry/routing.d.ts +18 -0
- package/dist/worker/registry/routing.js +14 -0
- package/dist/worker/registry/types.d.ts +7 -0
- package/dist/worker/registry.d.ts +16 -1
- package/dist/worker/registry.js +24 -36
- package/package.json +17 -4
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
PersistedDataIncompatibleError
|
|
5
5
|
} from "../persisted-data-incompatible-error.js";
|
|
6
6
|
const SCHEMA_VERSION_PATTERN = /^(?:0|[1-9]\d*)$/, USER_DATA_PREFIXES = ["wf:", "op:", "schedule:", "ev:", "sig:", "upd:", "idx:"];
|
|
7
|
-
export async function assertCompatiblePersistedDataVersion(storage
|
|
7
|
+
export async function assertCompatiblePersistedDataVersion(storage) {
|
|
8
8
|
const raw = await storage.get(PERSISTED_DATA_SCHEMA_VERSION_KEY);
|
|
9
9
|
if (raw !== null) {
|
|
10
10
|
const text = new TextDecoder().decode(raw);
|
|
@@ -17,9 +17,8 @@ export async function assertCompatiblePersistedDataVersion(storage, options = {}
|
|
|
17
17
|
throw new PersistedDataIncompatibleError(parsed, CURRENT_PERSISTED_DATA_SCHEMA_VERSION);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
for (const
|
|
22
|
-
|
|
23
|
-
throw new PersistedDataIncompatibleError(null, CURRENT_PERSISTED_DATA_SCHEMA_VERSION);
|
|
20
|
+
for (const prefix of USER_DATA_PREFIXES)
|
|
21
|
+
for await (const _entry of storage.scan(prefix, { limit: 1 }))
|
|
22
|
+
throw new PersistedDataIncompatibleError(null, CURRENT_PERSISTED_DATA_SCHEMA_VERSION);
|
|
24
23
|
await storage.put(PERSISTED_DATA_SCHEMA_VERSION_KEY, new TextEncoder().encode(String(CURRENT_PERSISTED_DATA_SCHEMA_VERSION)));
|
|
25
24
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ScheduleSpec, ScheduleSummary } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Narrow engine view a {@link ScheduleHandle} delegates to. The full
|
|
4
|
+
* {@link Engine} implements it; the handle only depends on these schedule
|
|
5
|
+
* lifecycle operations.
|
|
6
|
+
*/
|
|
7
|
+
export interface ScheduleHandleEngine {
|
|
8
|
+
pauseSchedule(scheduleId: string): Promise<void>;
|
|
9
|
+
resumeSchedule(scheduleId: string): Promise<void>;
|
|
10
|
+
cancelSchedule(scheduleId: string): Promise<void>;
|
|
11
|
+
updateSchedule(scheduleId: string, newSpec: string | ScheduleSpec): Promise<void>;
|
|
12
|
+
getSchedule(scheduleId: string): Promise<ScheduleSummary | null>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handle to a recurring schedule created by {@link Engine.schedule}. Use
|
|
16
|
+
* `handle.pause()`, `handle.resume()`, `handle.cancel()`, or
|
|
17
|
+
* `handle.update(cronExpression)` to manage the schedule lifecycle.
|
|
18
|
+
* `handle.describe()` returns the current {@link ScheduleSummary}.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { workflow, Engine, ScheduleHandle } from '@lostgradient/weft';
|
|
23
|
+
*
|
|
24
|
+
* const engine = new Engine();
|
|
25
|
+
* engine.register(workflow({ name: 'daily-report' }).execute(async function* () { return 'ok'; }));
|
|
26
|
+
*
|
|
27
|
+
* const handle = await engine.schedule('daily-report', null, '0 9 * * *');
|
|
28
|
+
* const typedHandle: ScheduleHandle = handle;
|
|
29
|
+
* await handle.pause();
|
|
30
|
+
* const summary = await handle.describe();
|
|
31
|
+
* void typedHandle;
|
|
32
|
+
* console.log(summary.status); // 'paused'
|
|
33
|
+
* await handle.cancel();
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare class ScheduleHandle {
|
|
37
|
+
#private;
|
|
38
|
+
readonly id: string;
|
|
39
|
+
constructor(id: string, engine: ScheduleHandleEngine);
|
|
40
|
+
pause(): Promise<void>;
|
|
41
|
+
resume(): Promise<void>;
|
|
42
|
+
cancel(): Promise<void>;
|
|
43
|
+
update(newSpec: string | ScheduleSpec): Promise<void>;
|
|
44
|
+
describe(): Promise<ScheduleSummary>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class ScheduleHandle {
|
|
2
|
+
id;
|
|
3
|
+
#engine;
|
|
4
|
+
constructor(id, engine) {
|
|
5
|
+
this.id = id;
|
|
6
|
+
this.#engine = engine;
|
|
7
|
+
}
|
|
8
|
+
async pause() {
|
|
9
|
+
await this.#engine.pauseSchedule(this.id);
|
|
10
|
+
}
|
|
11
|
+
async resume() {
|
|
12
|
+
await this.#engine.resumeSchedule(this.id);
|
|
13
|
+
}
|
|
14
|
+
async cancel() {
|
|
15
|
+
await this.#engine.cancelSchedule(this.id);
|
|
16
|
+
}
|
|
17
|
+
async update(newSpec) {
|
|
18
|
+
await this.#engine.updateSchedule(this.id, newSpec);
|
|
19
|
+
}
|
|
20
|
+
async describe() {
|
|
21
|
+
const schedule = await this.#engine.getSchedule(this.id);
|
|
22
|
+
if (!schedule)
|
|
23
|
+
throw Error(`Schedule "${this.id}" not found`);
|
|
24
|
+
return schedule;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { BatchOperation } from '../../storage/interface.ts';
|
|
2
2
|
import type { PaginatedResult, ScheduleFilter, ScheduleOptions, ScheduleSpec, ScheduleState, ScheduleSummary, WorkflowState } from '../types.ts';
|
|
3
|
-
import { ScheduleHandle } from './handles.ts';
|
|
4
3
|
import type { EngineInternals } from './internals.ts';
|
|
4
|
+
import { ScheduleHandle } from './schedule-handle.ts';
|
|
5
5
|
export { handleScheduleTimer } from './schedule-timer.ts';
|
|
6
6
|
export type RefreshedScheduleState = {
|
|
7
7
|
state: ScheduleState;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { KEYS } from "../../storage/interface.js";
|
|
2
2
|
import { decode, encode } from "../codec.js";
|
|
3
3
|
import { WorkflowNotRegisteredError } from "./errors.js";
|
|
4
|
-
import { ScheduleHandle } from "./
|
|
4
|
+
import { ScheduleHandle } from "./schedule-handle.js";
|
|
5
5
|
import { getNextScheduleOccurrence } from "./schedule-occurrence.js";
|
|
6
6
|
import {
|
|
7
7
|
clearScheduleCurrentWorkflow,
|
|
@@ -123,11 +123,15 @@ export async function updateSchedule(internals, scheduleId, newSpec) {
|
|
|
123
123
|
};
|
|
124
124
|
await writeScheduleState(internals, updatedState, { includeTimer: state.status === "active" });
|
|
125
125
|
}
|
|
126
|
+
function scheduledRunOccupiesSlot(currentWorkflowState) {
|
|
127
|
+
const status = currentWorkflowState?.status;
|
|
128
|
+
return status === "running" || status === "pending" || status === "suspended";
|
|
129
|
+
}
|
|
126
130
|
export async function refreshScheduledWorkflowState(internals, state, callbacks) {
|
|
127
131
|
if (!state.currentWorkflowId)
|
|
128
132
|
return { state, currentWorkflowState: null };
|
|
129
133
|
const currentWorkflowState = await callbacks.loadWorkflowState(state.currentWorkflowId);
|
|
130
|
-
if (currentWorkflowState
|
|
134
|
+
if (scheduledRunOccupiesSlot(currentWorkflowState))
|
|
131
135
|
return { state, currentWorkflowState };
|
|
132
136
|
await internals.storage.delete(KEYS.scheduleRun(state.currentWorkflowId));
|
|
133
137
|
return {
|
|
@@ -141,7 +145,7 @@ export async function startScheduledRun(_internals, state, callbacks) {
|
|
|
141
145
|
return workflowId;
|
|
142
146
|
}
|
|
143
147
|
function hasActiveScheduledWorkflow(currentWorkflowState) {
|
|
144
|
-
return currentWorkflowState
|
|
148
|
+
return scheduledRunOccupiesSlot(currentWorkflowState);
|
|
145
149
|
}
|
|
146
150
|
async function applyBlockedScheduleOccurrence(state, hasActiveWorkflow, callbacks) {
|
|
147
151
|
if (!hasActiveWorkflow)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort, warn-only detection of a SECOND engine instance writing to the
|
|
3
|
+
* same durable store — a smoke alarm for singleton-deployment misconfiguration
|
|
4
|
+
* (an autoscaler accidentally set above one replica, or overlapping rolling
|
|
5
|
+
* deploys), NOT a correctness mechanism.
|
|
6
|
+
*
|
|
7
|
+
* **This is liveness, not fencing.** Weft's supported model is one engine process
|
|
8
|
+
* per durable store (see the recovery-and-deploys guide); fenced ownership is a
|
|
9
|
+
* future `MultiEngine` capability that does not exist yet. This detector never
|
|
10
|
+
* blocks boot, gates recovery, refuses a write, or claims ownership. It only
|
|
11
|
+
* observes whether another instance's heartbeat is *advancing while this instance
|
|
12
|
+
* is also running* and emits a warning if so. It does not prevent duplicate
|
|
13
|
+
* execution — infrastructure-level enforcement (`replicas: 1` + a `Recreate`
|
|
14
|
+
* deploy strategy, or a single systemd unit) is the real control.
|
|
15
|
+
*
|
|
16
|
+
* **Why liveness, not a boot check.** A boot-time check cannot distinguish a
|
|
17
|
+
* rolling-deploy handoff from a genuine second instance: both leave a recent
|
|
18
|
+
* heartbeat record in the store. Only observing a *foreign* heartbeat advance
|
|
19
|
+
* across several of our own ticks separates a live peer (autoscaling=2 → both
|
|
20
|
+
* heartbeats advance forever → both warn) from a dead-but-recent predecessor (a
|
|
21
|
+
* clean `Recreate` deploy → old heartbeat never advances after handoff → quiet).
|
|
22
|
+
*
|
|
23
|
+
* **Advance is measured by sequence, not wall clock.** Each heartbeat carries a
|
|
24
|
+
* per-instance monotonic `sequence`; a peer counts as advancing only when its
|
|
25
|
+
* `sequence` grows between two of *our* ticks. A peer's sequence cannot increase
|
|
26
|
+
* unless it is alive and ticking in our own time frame, so detection never
|
|
27
|
+
* compares clocks across hosts — it stays correct even if a peer's clock is
|
|
28
|
+
* frozen, skewed, or stepped backward. `heartbeatAt` exists only for the boot
|
|
29
|
+
* staleness sweep that garbage-collects long-dead instances' keys.
|
|
30
|
+
*
|
|
31
|
+
* Each engine writes its own heartbeat under `liveness:<instanceId>` and scans
|
|
32
|
+
* the `liveness:` prefix to observe peers. Per-instance keys (not one shared,
|
|
33
|
+
* clobbered key) keep every heartbeat independently observable and the sequence
|
|
34
|
+
* monotonic per writer.
|
|
35
|
+
*
|
|
36
|
+
* @module core/engine/second-instance-detector
|
|
37
|
+
*/
|
|
38
|
+
import { type Storage } from '../../storage/interface.ts';
|
|
39
|
+
import type { EngineCleanupIntervalDisposalTracker } from './engine-leak-warnings.ts';
|
|
40
|
+
/** Options for {@link createSecondInstanceDetector}. */
|
|
41
|
+
export type SecondInstanceDetectorOptions = {
|
|
42
|
+
storage: Storage;
|
|
43
|
+
/** This engine's unique instance id. */
|
|
44
|
+
instanceId: string;
|
|
45
|
+
/** Wall-clock source (ms), injected so tests can advance time deterministically. */
|
|
46
|
+
getNow: () => number;
|
|
47
|
+
/**
|
|
48
|
+
* Heartbeat interval in ms. The staleness window is derived from this, so the
|
|
49
|
+
* interval also sets how long a deploy overlap must last before it warns.
|
|
50
|
+
*/
|
|
51
|
+
intervalMs: number;
|
|
52
|
+
/**
|
|
53
|
+
* Emit a warning. Defaults to `process.emitWarning(message, WARNING_NAME)`,
|
|
54
|
+
* so the emitted `Warning.name` is `WeftSecondInstanceWarning` and consumers
|
|
55
|
+
* can filter on `warning.name` rather than scraping the message. Injected for
|
|
56
|
+
* tests; the seam is message-only because the name is a fixed constant.
|
|
57
|
+
*/
|
|
58
|
+
warn?: (message: string) => void;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The `name` of the emitted warning. A stable, filterable identifier on the
|
|
62
|
+
* `Warning` object — consumers subscribe to the process `warning` event and
|
|
63
|
+
* match `warning.name === WARNING_NAME` (see the singleton-deployment guide).
|
|
64
|
+
*/
|
|
65
|
+
export declare const SECOND_INSTANCE_WARNING_NAME = "WeftSecondInstanceWarning";
|
|
66
|
+
/**
|
|
67
|
+
* A running detector. `tick()` runs one heartbeat round (exposed for tests and
|
|
68
|
+
* driven by an interval in production); `stop()` clears the interval and
|
|
69
|
+
* best-effort removes this instance's heartbeat so the next boot starts quiet.
|
|
70
|
+
*/
|
|
71
|
+
export type SecondInstanceDetector = {
|
|
72
|
+
/** Run one heartbeat round: observe peers, then write our own heartbeat. */
|
|
73
|
+
tick(): Promise<void>;
|
|
74
|
+
/** Stop the interval and best-effort delete this instance's heartbeat key. */
|
|
75
|
+
stop(): Promise<void>;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Create a best-effort second-instance detector. Does not start an interval
|
|
79
|
+
* itself — the engine owns timer lifecycle so disposal can clear it through the
|
|
80
|
+
* same path as its other intervals. Call {@link SecondInstanceDetector.tick} on
|
|
81
|
+
* an interval and {@link SecondInstanceDetector.stop} on dispose.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createSecondInstanceDetector(options: SecondInstanceDetectorOptions): SecondInstanceDetector;
|
|
84
|
+
/**
|
|
85
|
+
* Build the `setInterval` callback that drives a detector tick. `resolveDetector`
|
|
86
|
+
* returns the live detector, or `null` when the engine has been garbage-collected
|
|
87
|
+
* or disposed. When it returns `null` the tick SELF-CLEARS its own interval (via
|
|
88
|
+
* `tracker.secondInstanceDetectionInterval`) and returns — mirroring
|
|
89
|
+
* {@link createCleanupIntervalTick}. This is the prompt cleanup path: a leaked
|
|
90
|
+
* engine's first post-GC tick clears the timer immediately, rather than relying
|
|
91
|
+
* on the `FinalizationRegistry` backstop, whose callbacks are not guaranteed to
|
|
92
|
+
* run promptly (or at all). Extracted so the skip/clear guard is directly
|
|
93
|
+
* testable without a timer. A tick failure is swallowed: the detector is a smoke
|
|
94
|
+
* alarm, never a correctness path, so it must not surface as an unhandled rejection.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createSecondInstanceDetectionTick(resolveDetector: () => SecondInstanceDetector | null, tracker: EngineCleanupIntervalDisposalTracker): () => void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { KEYS } from "../../storage/interface.js";
|
|
2
|
+
const textEncoder = new TextEncoder, textDecoder = new TextDecoder;
|
|
3
|
+
function encodeHeartbeat(heartbeat) {
|
|
4
|
+
return textEncoder.encode(JSON.stringify(heartbeat));
|
|
5
|
+
}
|
|
6
|
+
async function bestEffort(operation) {
|
|
7
|
+
try {
|
|
8
|
+
await operation();
|
|
9
|
+
} catch {}
|
|
10
|
+
}
|
|
11
|
+
function isUsableHeartbeat(candidate) {
|
|
12
|
+
const { instanceId, heartbeatAt, sequence } = candidate;
|
|
13
|
+
return typeof instanceId === "string" && instanceId.length > 0 && typeof heartbeatAt === "number" && Number.isFinite(heartbeatAt) && typeof sequence === "number" && Number.isInteger(sequence) && sequence >= 0;
|
|
14
|
+
}
|
|
15
|
+
function decodeHeartbeat(raw) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(textDecoder.decode(raw));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
23
|
+
return null;
|
|
24
|
+
const candidate = parsed;
|
|
25
|
+
return isUsableHeartbeat(candidate) ? candidate : null;
|
|
26
|
+
}
|
|
27
|
+
export const SECOND_INSTANCE_WARNING_NAME = "WeftSecondInstanceWarning";
|
|
28
|
+
const STALE_SWEEP_WINDOW_MULTIPLE = 10, ADVANCE_TICKS_BEFORE_WARN = 2;
|
|
29
|
+
export function createSecondInstanceDetector(options) {
|
|
30
|
+
const { storage, instanceId, getNow, intervalMs } = options, warn = options.warn ?? ((message) => process.emitWarning(message, SECOND_INSTANCE_WARNING_NAME)), stalenessWindowMs = intervalMs * (ADVANCE_TICKS_BEFORE_WARN + 1);
|
|
31
|
+
let sequence = 0, swept = !1, stopped = !1;
|
|
32
|
+
const observed = new Map, warnedInstanceIds = new Set;
|
|
33
|
+
async function readPeers() {
|
|
34
|
+
const peers = [];
|
|
35
|
+
for await (const [key, value] of storage.scan(KEYS.livenessPrefix())) {
|
|
36
|
+
const heartbeat = decodeHeartbeat(value);
|
|
37
|
+
if (heartbeat !== null && heartbeat.instanceId !== instanceId)
|
|
38
|
+
peers.push({ key, heartbeat });
|
|
39
|
+
}
|
|
40
|
+
return peers;
|
|
41
|
+
}
|
|
42
|
+
async function sweepStaleHeartbeats(peers, now) {
|
|
43
|
+
const staleBefore = now - stalenessWindowMs * STALE_SWEEP_WINDOW_MULTIPLE;
|
|
44
|
+
for (const peer of peers)
|
|
45
|
+
if (peer.heartbeat.heartbeatAt < staleBefore)
|
|
46
|
+
await bestEffort(() => storage.delete(peer.key));
|
|
47
|
+
}
|
|
48
|
+
function evaluatePeers(peers) {
|
|
49
|
+
const seenThisTick = new Set;
|
|
50
|
+
for (const { key, heartbeat } of peers) {
|
|
51
|
+
if (KEYS.liveness(heartbeat.instanceId) !== key)
|
|
52
|
+
continue;
|
|
53
|
+
seenThisTick.add(heartbeat.instanceId);
|
|
54
|
+
const previous = observed.get(heartbeat.instanceId), advances = previous !== void 0 && heartbeat.sequence > previous.sequence ? previous.advances + 1 : 0;
|
|
55
|
+
observed.set(heartbeat.instanceId, { sequence: heartbeat.sequence, advances });
|
|
56
|
+
if (advances >= ADVANCE_TICKS_BEFORE_WARN && !warnedInstanceIds.has(heartbeat.instanceId)) {
|
|
57
|
+
warnedInstanceIds.add(heartbeat.instanceId);
|
|
58
|
+
warn(`another engine instance (${heartbeat.instanceId}) is writing to this durable store. Weft supports one engine process per store; running two can cause duplicate workflow execution. Enforce a single instance at the infrastructure layer (for example, one replica with a Recreate deploy strategy).`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const knownInstanceId of Array.from(observed.keys()))
|
|
62
|
+
if (!seenThisTick.has(knownInstanceId))
|
|
63
|
+
observed.delete(knownInstanceId);
|
|
64
|
+
}
|
|
65
|
+
let tickInFlight = !1;
|
|
66
|
+
async function tick() {
|
|
67
|
+
if (stopped || tickInFlight)
|
|
68
|
+
return;
|
|
69
|
+
tickInFlight = !0;
|
|
70
|
+
let wroteHeartbeat = !1;
|
|
71
|
+
try {
|
|
72
|
+
const now = getNow(), peers = await readPeers();
|
|
73
|
+
if (!swept) {
|
|
74
|
+
swept = !0;
|
|
75
|
+
await sweepStaleHeartbeats(peers, now);
|
|
76
|
+
}
|
|
77
|
+
evaluatePeers(peers);
|
|
78
|
+
if (stopped)
|
|
79
|
+
return;
|
|
80
|
+
sequence += 1;
|
|
81
|
+
const heartbeat = { instanceId, heartbeatAt: now, sequence };
|
|
82
|
+
wroteHeartbeat = !0;
|
|
83
|
+
await bestEffort(() => storage.put(KEYS.liveness(instanceId), encodeHeartbeat(heartbeat)));
|
|
84
|
+
} finally {
|
|
85
|
+
tickInFlight = !1;
|
|
86
|
+
if (wroteHeartbeat && stopped)
|
|
87
|
+
await bestEffort(() => storage.delete(KEYS.liveness(instanceId)));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function stop() {
|
|
91
|
+
stopped = !0;
|
|
92
|
+
await bestEffort(() => storage.delete(KEYS.liveness(instanceId)));
|
|
93
|
+
}
|
|
94
|
+
return { tick, stop };
|
|
95
|
+
}
|
|
96
|
+
export function createSecondInstanceDetectionTick(resolveDetector, tracker) {
|
|
97
|
+
return function secondInstanceDetectionTick() {
|
|
98
|
+
const detector = resolveDetector();
|
|
99
|
+
if (detector === null) {
|
|
100
|
+
if (tracker.secondInstanceDetectionInterval !== null) {
|
|
101
|
+
clearInterval(tracker.secondInstanceDetectionInterval);
|
|
102
|
+
tracker.secondInstanceDetectionInterval = null;
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
detector.tick().catch(() => {});
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BatchOperation } from '../../storage/interface.ts';
|
|
1
2
|
import type { ComposedWorkflowInterceptor } from '../interceptor.ts';
|
|
2
3
|
import type { SignalDeliveryOptions, WorkflowState } from '../types.ts';
|
|
3
4
|
import type { EngineInternals } from './internals.ts';
|
|
@@ -31,6 +32,27 @@ export type ConsumedSignalResult = {
|
|
|
31
32
|
export declare function signal(internals: EngineInternals, workflowId: string, name: string, payload: unknown, callbacks: SignalCallbacks, options?: SignalDeliveryOptions): Promise<void>;
|
|
32
33
|
export declare function releaseSignalWaiter(internals: EngineInternals, workflowId: string, waiterKey: string, expectedResolve?: () => void): void;
|
|
33
34
|
export declare function bufferSignalPayloads(internals: EngineInternals, workflowId: string, deliveries: BufferedSignalDelivery[], callbacks: SignalCallbacks, defaultOptions?: SignalDeliveryOptions): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Build the durable operations for a single keyed signal so it can be folded
|
|
37
|
+
* into a workflow's create batch by {@link startOrSignal}. Writes the same pair
|
|
38
|
+
* the live signal path writes — the `sig:` payload (consumed on first drive by
|
|
39
|
+
* `processWaitSignalOperation`) and the `sigres:` accepted-response marker — so a
|
|
40
|
+
* concurrent caller that falls back to the standard signal path dedups against
|
|
41
|
+
* the SAME `signalId`. The accepted-response marker is consumption-independent:
|
|
42
|
+
* even after the winning run consumes the `sig:` payload, a late loser finds the
|
|
43
|
+
* `sigres:` key and short-circuits instead of re-delivering, which is what
|
|
44
|
+
* guarantees "one signal per signalId" across the create and signal paths.
|
|
45
|
+
*
|
|
46
|
+
* Returns both the put operations and the CAS condition (the `sig:` key must be
|
|
47
|
+
* absent) so the caller can gate the create batch on it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildCreateBatchSignalOperations(internals: EngineInternals, workflowId: string, signalName: string, payload: unknown, signalId: string): {
|
|
50
|
+
operations: BatchOperation[];
|
|
51
|
+
condition: {
|
|
52
|
+
key: string;
|
|
53
|
+
expectedValue: null;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
34
56
|
export declare function hasBufferedSignal(internals: EngineInternals, workflowId: string, signalName: string): Promise<boolean>;
|
|
35
57
|
export declare function consumeSignal(internals: EngineInternals, workflowId: string, signalName: string): Promise<ConsumedSignalResult>;
|
|
36
58
|
/** Register a waiter key in a workflow-keyed reverse index. */
|
|
@@ -83,6 +83,21 @@ function createExplicitSignalOperations(internals, workflowId, deliveries, signa
|
|
|
83
83
|
value: encodePayloadWithinLimit(payload, internals.options.payloadSizePolicy.maxBytes, "signal payload")
|
|
84
84
|
}));
|
|
85
85
|
}
|
|
86
|
+
export function buildCreateBatchSignalOperations(internals, workflowId, signalName, payload, signalId) {
|
|
87
|
+
validateSignalId(signalId);
|
|
88
|
+
const signalKey = KEYS.signal(workflowId, signalName, signalId), acceptedResponseKey = KEYS.signalAcceptedResponse(workflowId, signalName, signalId);
|
|
89
|
+
return {
|
|
90
|
+
operations: [
|
|
91
|
+
{
|
|
92
|
+
type: "put",
|
|
93
|
+
key: signalKey,
|
|
94
|
+
value: encodePayloadWithinLimit(payload, internals.options.payloadSizePolicy.maxBytes, "signal payload")
|
|
95
|
+
},
|
|
96
|
+
{ type: "put", key: acceptedResponseKey, value: encode(SIGNAL_ACCEPTED_RESPONSE) }
|
|
97
|
+
],
|
|
98
|
+
condition: { key: signalKey, expectedValue: null }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
86
101
|
function appendTerminalCleanupOperation(internals, workflowId, operations) {
|
|
87
102
|
if (internals.workflowsNeedingTerminalCleanup.has(workflowId))
|
|
88
103
|
return;
|
|
@@ -17,12 +17,37 @@ export type TerminationCallbacks = {
|
|
|
17
17
|
cleanupReviews: (workflowId: string) => Promise<void>;
|
|
18
18
|
};
|
|
19
19
|
export declare const TERMINAL_WORKFLOW_STATUSES: ReadonlySet<WorkflowStatus>;
|
|
20
|
+
/**
|
|
21
|
+
* Non-terminal statuses a workflow can be *forcibly* terminated from — cancel/
|
|
22
|
+
* timeout (`terminateWorkflow`) or system fail (`failWorkflow`). Both read this
|
|
23
|
+
* single constant so they cannot drift. `'suspended'` is included (a paused run
|
|
24
|
+
* must still be reachable by cancel/fail, else the CAS no-ops and the run is
|
|
25
|
+
* stranded); `completeWorkflow` deliberately excludes it (a suspended run's
|
|
26
|
+
* generator is evicted, so it can never reach normal completion).
|
|
27
|
+
*/
|
|
28
|
+
export declare const FORCIBLY_TERMINABLE_STATUSES: readonly ["running", "pending", "suspended"];
|
|
20
29
|
/**
|
|
21
30
|
* Remove any pending signal, update, and sleep waiters for a workflow. This
|
|
22
31
|
* prevents memory leaks and ensures that cancelled/completed/failed workflows
|
|
23
32
|
* cannot accept new signals, updates, or resolve orphaned sleep timers.
|
|
24
33
|
*/
|
|
25
34
|
export declare function cleanupWaiters(internals: EngineInternals, workflowId: string, callbacks: Pick<TerminationCallbacks, 'swallowPromiseRejection'>): void;
|
|
35
|
+
/**
|
|
36
|
+
* Evict only the in-flight OPERATION waiters for a workflow that is being
|
|
37
|
+
* suspended (signal/update/review waiters, review escalations, and sleep
|
|
38
|
+
* resolvers), WITHOUT resolving them and WITHOUT touching the non-serialized
|
|
39
|
+
* `services`, headers, type, or nesting-depth bookkeeping.
|
|
40
|
+
*
|
|
41
|
+
* Suspend parks the inline run (evicting its context/generator), so a signal or
|
|
42
|
+
* update arriving afterwards must NOT wake the dormant operation loop and drive
|
|
43
|
+
* the gone generator — it should buffer durably and be replayed when the
|
|
44
|
+
* workflow resumes. Deleting (not resolving) each waiter leaves the loop dormant
|
|
45
|
+
* and severs that wake path; the durable signal/sleep state in storage is
|
|
46
|
+
* untouched, so resume replays it. This is the suspend-specific complement to
|
|
47
|
+
* {@link cleanupWaiters}, which is for terminal transitions and additionally
|
|
48
|
+
* drops `services` and resolves sleep resolvers — both wrong for a pause.
|
|
49
|
+
*/
|
|
50
|
+
export declare function evictSuspendedWorkflowWaiters(internals: EngineInternals, workflowId: string, callbacks: Pick<TerminationCallbacks, 'swallowPromiseRejection'>): void;
|
|
26
51
|
/**
|
|
27
52
|
* Remove durable records keyed by `workflowId` that otherwise leak after a
|
|
28
53
|
* workflow reaches a terminal state.
|
|
@@ -8,7 +8,11 @@ export const TERMINAL_WORKFLOW_STATUSES = new Set([
|
|
|
8
8
|
"failed",
|
|
9
9
|
"cancelled",
|
|
10
10
|
"timed-out"
|
|
11
|
-
])
|
|
11
|
+
]), FORCIBLY_TERMINABLE_STATUSES = [
|
|
12
|
+
"running",
|
|
13
|
+
"pending",
|
|
14
|
+
"suspended"
|
|
15
|
+
];
|
|
12
16
|
function selectTrackedWaiterMaps(internals, kind) {
|
|
13
17
|
return {
|
|
14
18
|
signal: () => ({
|
|
@@ -48,6 +52,14 @@ function cleanupSleepResolvers(internals, workflowId) {
|
|
|
48
52
|
}
|
|
49
53
|
internals.sleepResolversByWorkflow.delete(workflowId);
|
|
50
54
|
}
|
|
55
|
+
function evictSleepResolversWithoutResolving(internals, workflowId) {
|
|
56
|
+
const sleepOps = internals.sleepResolversByWorkflow.get(workflowId);
|
|
57
|
+
if (!sleepOps)
|
|
58
|
+
return;
|
|
59
|
+
for (const operationId of sleepOps)
|
|
60
|
+
internals.sleepResolvers.delete(`${workflowId}:${operationId}`);
|
|
61
|
+
internals.sleepResolversByWorkflow.delete(workflowId);
|
|
62
|
+
}
|
|
51
63
|
function cleanupReviewEscalations(internals, workflowId, callbacks) {
|
|
52
64
|
const reviewIds = internals.workflowReviewIds.get(workflowId);
|
|
53
65
|
if (!reviewIds)
|
|
@@ -71,8 +83,15 @@ export function cleanupWaiters(internals, workflowId, callbacks) {
|
|
|
71
83
|
cleanupReviewEscalations(internals, workflowId, callbacks);
|
|
72
84
|
internals.workflowNestingDepths.delete(workflowId);
|
|
73
85
|
internals.workflowHeaders.delete(workflowId);
|
|
86
|
+
internals.workflowServices.delete(workflowId);
|
|
74
87
|
internals.workflowTypeByWorkflowId.delete(workflowId);
|
|
75
88
|
}
|
|
89
|
+
export function evictSuspendedWorkflowWaiters(internals, workflowId, callbacks) {
|
|
90
|
+
for (const kind of TRACKED_WAITER_KINDS)
|
|
91
|
+
cleanupTrackedWaiter(internals, workflowId, kind);
|
|
92
|
+
evictSleepResolversWithoutResolving(internals, workflowId);
|
|
93
|
+
cleanupReviewEscalations(internals, workflowId, callbacks);
|
|
94
|
+
}
|
|
76
95
|
export async function cleanupWorkflowStorage(internals, workflowId, includeOutputArtifacts) {
|
|
77
96
|
const encodedWorkflowId = encodeStorageKeyComponent(workflowId), prefixes = [
|
|
78
97
|
KEYS.activityReconciliationPrefix(workflowId),
|
|
@@ -86,6 +105,7 @@ export async function cleanupWorkflowStorage(internals, workflowId, includeOutpu
|
|
|
86
105
|
prefixes.push(`offload:${encodedWorkflowId}:`, `blob:${encodedWorkflowId}:`);
|
|
87
106
|
await internals.storage.delete(KEYS.workflowHeaders(workflowId));
|
|
88
107
|
await internals.storage.delete(KEYS.signalSequence(workflowId));
|
|
108
|
+
await internals.storage.delete(KEYS.workflowHasServices(workflowId));
|
|
89
109
|
if (internals.storage.deletePrefix) {
|
|
90
110
|
for (const prefix of prefixes)
|
|
91
111
|
await internals.storage.deletePrefix(prefix);
|
|
@@ -26,7 +26,8 @@ import { buildWorkflowVisibilityIndexTransition } from "../workflow-indexes.js";
|
|
|
26
26
|
import {
|
|
27
27
|
cleanupTerminalWorkflowImmediately,
|
|
28
28
|
cleanupTerminalWorkflowSynchronously,
|
|
29
|
-
finalizeScheduledWorkflowTerminal
|
|
29
|
+
finalizeScheduledWorkflowTerminal,
|
|
30
|
+
FORCIBLY_TERMINABLE_STATUSES
|
|
30
31
|
} from "./cleanup.js";
|
|
31
32
|
async function runCancelHandlers(handlers, callbacks, workflowId) {
|
|
32
33
|
for (const handler of handlers)
|
|
@@ -54,7 +55,7 @@ export async function terminateWorkflow(internals, workflowId, status, callbacks
|
|
|
54
55
|
internals.strategy.cancelWorkflow(workflowId);
|
|
55
56
|
try {
|
|
56
57
|
const attributeBytes = await internals.storage.get(KEYS.attribute(workflowId)), attributes = attributeBytes ? decode(attributeBytes) : {}, retainedAttributes = buildRetainedTerminalSearchAttributes(attributes), terminationMessage = status === "timed-out" ? "Workflow timed out" : "Workflow cancelled", terminationResult = await updateWorkflowState(internals, workflowId, { status, ...reason !== void 0 ? { terminationReason: reason } : {} }, {
|
|
57
|
-
allowedStatuses:
|
|
58
|
+
allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
|
|
58
59
|
buildAdditionalOperations: (_previousState, updatedAt) => {
|
|
59
60
|
finalizePendingTimelineEntry(internals, workflowId, status, terminationMessage, updatedAt);
|
|
60
61
|
const pendingTimelineOperation = buildPendingTimelineOperation(internals, workflowId);
|
|
@@ -182,7 +183,7 @@ export async function failWorkflow(internals, workflowId, error, callbacks, fail
|
|
|
182
183
|
if (error.stack !== void 0)
|
|
183
184
|
stateUpdate.errorStack = error.stack;
|
|
184
185
|
if (!await updateWorkflowState(internals, workflowId, stateUpdate, {
|
|
185
|
-
allowedStatuses:
|
|
186
|
+
allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
|
|
186
187
|
buildAdditionalOperations: (_previousState, updatedAt) => {
|
|
187
188
|
finalizePendingTimelineEntry(internals, workflowId, "failed", error.message, updatedAt);
|
|
188
189
|
const pendingTimelineOperation = buildPendingTimelineOperation(internals, workflowId);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EngineInternals } from '../internals.ts';
|
|
2
|
+
import { type TerminationCallbacks } from './cleanup.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Suspend a running workflow without terminating it: a non-terminal cousin of
|
|
5
|
+
* {@link terminateWorkflow}. The workflow's status transitions `running →
|
|
6
|
+
* suspended`, its durable checkpoint is preserved, and it becomes resumable via
|
|
7
|
+
* `engine.resume(id)` / `handle.resume()`. Suspension is client-driven
|
|
8
|
+
* preemption, so — unlike a fault — a suspended workflow is NOT auto-recovered
|
|
9
|
+
* by `engine.recoverAll()`.
|
|
10
|
+
*
|
|
11
|
+
* Contrast with cancel/timeout, which this deliberately does NOT do:
|
|
12
|
+
* - does NOT abort the workflow's `AbortController` — suspend is a pause, not a
|
|
13
|
+
* cancellation, so user code observing `ctx.signal.aborted` or registered
|
|
14
|
+
* abort listeners must not fire. The live inline run is *parked*
|
|
15
|
+
* (`parkWorkflow`: evict execution state without aborting), the same primitive
|
|
16
|
+
* the engine uses for signal-parking,
|
|
17
|
+
* - does NOT run cancel handlers,
|
|
18
|
+
* - does NOT settle the result promise (`handle.result()` stays pending until a
|
|
19
|
+
* later `resume()` drives the run to completion, or a `cancel()` terminates it),
|
|
20
|
+
* - does NOT clean up durable output artifacts or in-memory services (the
|
|
21
|
+
* `services` value is preserved so an in-process `resume()` can reuse it),
|
|
22
|
+
* - does NOT schedule terminal cleanup.
|
|
23
|
+
*
|
|
24
|
+
* The CAS status flip and the in-memory teardown both run inside one serialized
|
|
25
|
+
* per-workflow write section with `allowedStatuses: ['running']`. If the
|
|
26
|
+
* workflow already left `running` (it completed, failed, or a concurrent cancel
|
|
27
|
+
* won the race), the flip is skipped and suspend is a no-op — and because the
|
|
28
|
+
* teardown is gated on the flip succeeding, a workflow that lost the race keeps
|
|
29
|
+
* its execution state intact.
|
|
30
|
+
*
|
|
31
|
+
* The teardown evicts every piece of in-memory execution state that could let a
|
|
32
|
+
* post-suspend operation drive the parked run: the inline context/generator (via
|
|
33
|
+
* `parkWorkflow`), the in-memory checkpoint, the parked-inline marker, and the
|
|
34
|
+
* in-flight operation waiters (signal/update/sleep/review — deleted, NOT
|
|
35
|
+
* resolved, so a signal arriving after suspend buffers durably and is replayed
|
|
36
|
+
* on resume instead of waking a dormant operation loop against the gone
|
|
37
|
+
* generator). The durable checkpoint, durable buffered signals, durable sleep
|
|
38
|
+
* timers, and `workflowServices` are all left intact for resume.
|
|
39
|
+
*
|
|
40
|
+
* A signal that races the in-lock teardown is benign: `continueWorkflow` no-ops
|
|
41
|
+
* for an evicted generator, and `persistCheckpoint` no-ops when the context and
|
|
42
|
+
* in-memory checkpoint are gone — so no step can commit past the suspend point.
|
|
43
|
+
*
|
|
44
|
+
* `'suspended'` is neither `'running'` nor `'pending'`, and both local-ownership
|
|
45
|
+
* predicates (`isInlineWorkflowLocallyOwned`, `hasLocalCheckpointOwnership`) are
|
|
46
|
+
* gated on those two statuses. So once the status flips, the workflow stops
|
|
47
|
+
* registering as locally owned — which is exactly what makes `recoverAll()` skip
|
|
48
|
+
* it AND what lets `engine.resume()` re-drive it from storage instead of taking
|
|
49
|
+
* its local-ownership early return.
|
|
50
|
+
*
|
|
51
|
+
* The execution deadline is absolute wall-clock time: suspension does NOT extend
|
|
52
|
+
* it. The pending `deadline:` timer is deleted durably IN THE SAME COMMIT BATCH
|
|
53
|
+
* as the status flip, and re-armed at the same absolute fire time on resume (or
|
|
54
|
+
* fires immediately if already past). It is a durable delete rather than a
|
|
55
|
+
* `scheduler.cancel()` call because the scheduler is durable-scan-based and
|
|
56
|
+
* resume's re-arm is likewise durable-only (`buildTimerBatchOperations`); folding
|
|
57
|
+
* the delete into the commit makes it atomic with the flip and ordered before any
|
|
58
|
+
* concurrent resume, so an immediate resume cannot have its freshly re-armed
|
|
59
|
+
* deadline deleted by a late fire-and-forget cancel.
|
|
60
|
+
*
|
|
61
|
+
* Worker execution mode is not supported: a worker run cannot be parked without
|
|
62
|
+
* sending it a cancellation. To keep the contract state-dependent (suspend on a
|
|
63
|
+
* non-running workflow is always a no-op), the mode check runs only AFTER the
|
|
64
|
+
* status load confirms the workflow is `running`; a `running` worker workflow
|
|
65
|
+
* throws {@link WorkflowSuspendNotSupportedError}, while a completed or unknown
|
|
66
|
+
* one is a no-op regardless of execution mode.
|
|
67
|
+
*/
|
|
68
|
+
export declare function suspendWorkflow(internals: EngineInternals, workflowId: string, callbacks: TerminationCallbacks): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { KEYS } from "../../../storage/interface.js";
|
|
2
|
+
import { encode } from "../../codec.js";
|
|
3
|
+
import { WorkflowSuspendedEvent } from "../../events.js";
|
|
4
|
+
import { WorkflowSuspendNotSupportedError } from "../errors.js";
|
|
5
|
+
import { dropQueuedInlineWorkflowStart } from "../inline-launch-queue.js";
|
|
6
|
+
import { buildWorkflowVisibilityIndexTransition } from "../workflow-indexes.js";
|
|
7
|
+
import { evictSuspendedWorkflowWaiters } from "./cleanup.js";
|
|
8
|
+
export async function suspendWorkflow(internals, workflowId, callbacks) {
|
|
9
|
+
if (!await callbacks.runSerializedWorkflowStateWrite(workflowId, async () => {
|
|
10
|
+
const state = await callbacks.loadWorkflowState(workflowId);
|
|
11
|
+
if (!state || state.status !== "running")
|
|
12
|
+
return !1;
|
|
13
|
+
if (internals.inlineStrategy === null)
|
|
14
|
+
throw new WorkflowSuspendNotSupportedError("suspend is only supported in inline execution mode; a worker run cannot be paused without cancelling it.");
|
|
15
|
+
internals.inlineStrategy.parkWorkflow(workflowId);
|
|
16
|
+
dropQueuedInlineWorkflowStart(internals, workflowId);
|
|
17
|
+
internals.checkpoints.delete(workflowId);
|
|
18
|
+
internals.parkedInlineWorkflows.delete(workflowId);
|
|
19
|
+
evictSuspendedWorkflowWaiters(internals, workflowId, callbacks);
|
|
20
|
+
const updatedAt = internals.options.getNow(), updatedState = { ...state, status: "suspended", updatedAt };
|
|
21
|
+
await callbacks.commitWorkflowStateOperations(state, [
|
|
22
|
+
{ type: "put", key: KEYS.workflow(workflowId), value: encode(updatedState) },
|
|
23
|
+
...buildWorkflowVisibilityIndexTransition(workflowId, state, updatedState).batchOps,
|
|
24
|
+
...buildDeadlineTimerDeleteOperations(workflowId, state.executionDeadline)
|
|
25
|
+
]);
|
|
26
|
+
return !0;
|
|
27
|
+
}))
|
|
28
|
+
return;
|
|
29
|
+
const event = new WorkflowSuspendedEvent(workflowId);
|
|
30
|
+
callbacks.dispatchEvent(event);
|
|
31
|
+
callbacks.forwardEventToHandle(workflowId, event);
|
|
32
|
+
}
|
|
33
|
+
function buildDeadlineTimerDeleteOperations(workflowId, executionDeadline) {
|
|
34
|
+
if (executionDeadline === void 0)
|
|
35
|
+
return [];
|
|
36
|
+
const timerId = `deadline:${workflowId}`;
|
|
37
|
+
return [
|
|
38
|
+
{ type: "delete", key: KEYS.deadline(executionDeadline, timerId) },
|
|
39
|
+
{ type: "delete", key: `timer-idx:${timerId}` }
|
|
40
|
+
];
|
|
41
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thin barrel over the split termination modules. Keeps existing import paths
|
|
3
3
|
* (`./termination.ts`) working while the implementation lives in
|
|
4
|
-
* `./termination/cleanup.ts
|
|
4
|
+
* `./termination/cleanup.ts`, `./termination/complete.ts`, and
|
|
5
|
+
* `./termination/suspend.ts`.
|
|
5
6
|
*/
|
|
6
|
-
export { TERMINAL_WORKFLOW_STATUSES, cleanupTerminalWorkflowDurableState, cleanupTerminalWorkflowImmediately, cleanupTerminalWorkflowMemory, cleanupTerminalWorkflowSynchronously, cleanupWaiters, cleanupWorkflowStorage, finalizeScheduledWorkflowTerminal, handleCleanupError, runDeferredTerminalCleanup, type TerminationCallbacks, } from './termination/cleanup.ts';
|
|
7
|
+
export { TERMINAL_WORKFLOW_STATUSES, cleanupTerminalWorkflowDurableState, cleanupTerminalWorkflowImmediately, cleanupTerminalWorkflowMemory, cleanupTerminalWorkflowSynchronously, cleanupWaiters, cleanupWorkflowStorage, evictSuspendedWorkflowWaiters, finalizeScheduledWorkflowTerminal, handleCleanupError, runDeferredTerminalCleanup, type TerminationCallbacks, } from './termination/cleanup.ts';
|
|
7
8
|
export { buildPendingTimelineOperation, buildTerminalCleanupTimerOperations, cancelWorkflow, completeWorkflow, ensureTerminalCleanupTracked, failWorkflow, finalizePendingTimelineEntry, terminateWorkflow, timeoutWorkflow, } from './termination/complete.ts';
|
|
9
|
+
export { suspendWorkflow } from './termination/suspend.ts';
|