@loomwork/dispatcher 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +83 -2
- package/dist/LocalDispatcher.d.ts +56 -0
- package/dist/LocalDispatcher.d.ts.map +1 -0
- package/dist/LocalDispatcher.js +104 -0
- package/dist/LocalDispatcher.js.map +1 -0
- package/dist/PlacementDispatcher.d.ts +45 -0
- package/dist/PlacementDispatcher.d.ts.map +1 -0
- package/dist/PlacementDispatcher.js +95 -0
- package/dist/PlacementDispatcher.js.map +1 -0
- package/dist/SupervisingDispatcher.d.ts +57 -0
- package/dist/SupervisingDispatcher.d.ts.map +1 -0
- package/dist/SupervisingDispatcher.js +119 -0
- package/dist/SupervisingDispatcher.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/placement.d.ts +5 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +28 -0
- package/dist/placement.js.map +1 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -3
- package/src/LocalDispatcher.ts +141 -0
- package/src/PlacementDispatcher.ts +124 -0
- package/src/SupervisingDispatcher.ts +160 -0
- package/src/index.ts +27 -0
- package/src/placement.ts +26 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ExecutionPackage, ExecutionResult } from "@loomwork/worker-runtime";
|
|
2
|
+
import { defaultPlacementPolicy } from "./placement";
|
|
3
|
+
import type {
|
|
4
|
+
DispatchMetadata,
|
|
5
|
+
DispatcherHealth,
|
|
6
|
+
ExecutionDispatcher,
|
|
7
|
+
PlacementPolicy,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export interface PlacementDispatcherOptions {
|
|
11
|
+
/** Named compute targets. Each is a full ExecutionDispatcher (e.g. a LocalDispatcher
|
|
12
|
+
* wrapping a local / remote-HTTP / Cloud Run WorkerRuntime), so retry/timeout/lease
|
|
13
|
+
* handling is reused per target. */
|
|
14
|
+
targets: Record<string, ExecutionDispatcher>;
|
|
15
|
+
/** Target used when the policy returns an unregistered name. Must be a key of `targets`. */
|
|
16
|
+
default: string;
|
|
17
|
+
/** How to choose a target. Defaults to "honour meta.target, else default". */
|
|
18
|
+
policy?: PlacementPolicy;
|
|
19
|
+
/**
|
|
20
|
+
* Where to report a dispatch that named an unregistered target (#392). Defaults to
|
|
21
|
+
* `console.warn`. Falling back is correct; doing it silently is how an execution placed
|
|
22
|
+
* on sized compute ends up running somewhere else without anyone noticing.
|
|
23
|
+
*/
|
|
24
|
+
onWarn?: (message: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Policy-driven placement (Loom 2.0 M4). A composite {@link ExecutionDispatcher} that
|
|
29
|
+
* routes each dispatch to one of several named targets according to a
|
|
30
|
+
* {@link PlacementPolicy} — the WHERE decision, driven by `ExecutionPolicy`
|
|
31
|
+
* (`dispatchMetadataFromPolicy`). It owns no cloud knowledge: every target is just
|
|
32
|
+
* another ExecutionDispatcher, so local / remote-HTTP / Cloud Run workers compose
|
|
33
|
+
* behind one seam. With a single target it behaves exactly like that target, so
|
|
34
|
+
* existing single-placement dispatch is unchanged.
|
|
35
|
+
*/
|
|
36
|
+
export class PlacementDispatcher implements ExecutionDispatcher {
|
|
37
|
+
private readonly targets: Record<string, ExecutionDispatcher>;
|
|
38
|
+
private readonly names: string[];
|
|
39
|
+
private readonly defaultTarget: string;
|
|
40
|
+
private readonly policy: PlacementPolicy;
|
|
41
|
+
// Remember where each execution was placed so cancel() reaches the right target.
|
|
42
|
+
// Bounded (LRU-by-insertion) so it can't grow without limit in a long-lived dispatcher.
|
|
43
|
+
private static readonly ROUTED_CAP = 10_000;
|
|
44
|
+
private readonly routed = new Map<string, string>();
|
|
45
|
+
private inFlight = 0;
|
|
46
|
+
private readonly onWarn: (message: string) => void;
|
|
47
|
+
private readonly warnedTargets = new Set<string>();
|
|
48
|
+
|
|
49
|
+
constructor(options: PlacementDispatcherOptions) {
|
|
50
|
+
this.names = Object.keys(options.targets);
|
|
51
|
+
if (this.names.length === 0) {
|
|
52
|
+
throw new Error("PlacementDispatcher requires at least one target");
|
|
53
|
+
}
|
|
54
|
+
if (!(options.default in options.targets)) {
|
|
55
|
+
throw new Error(`PlacementDispatcher default target "${options.default}" is not registered`);
|
|
56
|
+
}
|
|
57
|
+
this.targets = options.targets;
|
|
58
|
+
this.defaultTarget = options.default;
|
|
59
|
+
this.policy = options.policy ?? defaultPlacementPolicy(options.default);
|
|
60
|
+
this.onWarn = options.onWarn ?? ((m: string) => console.warn(`[loom] ${m}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The target names currently registered. */
|
|
64
|
+
get targetNames(): readonly string[] {
|
|
65
|
+
return this.names;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async dispatch(pkg: ExecutionPackage, meta?: DispatchMetadata): Promise<ExecutionResult> {
|
|
69
|
+
// #392 — a placement hint can outlive the lane it names: an execution suspended for days
|
|
70
|
+
// wakes into a deployment where its target was renamed or retired. Falling back is
|
|
71
|
+
// right (a retired lane must not strand what named it) but doing it SILENTLY means an
|
|
72
|
+
// execution deliberately placed on sized compute quietly runs somewhere else forever.
|
|
73
|
+
// Warned once per unknown name — this is a deployment fact, not a per-dispatch event.
|
|
74
|
+
if (meta?.target !== undefined && !this.names.includes(meta.target)) {
|
|
75
|
+
if (!this.warnedTargets.has(meta.target)) {
|
|
76
|
+
this.warnedTargets.add(meta.target);
|
|
77
|
+
this.onWarn(
|
|
78
|
+
`placement target "${meta.target}" is not registered [${this.names.join(", ")}] — falling back to "${this.defaultTarget}". An execution that named it is now running somewhere else.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const chosen = this.policy(pkg, meta, this.names);
|
|
83
|
+
const name = chosen in this.targets ? chosen : this.defaultTarget;
|
|
84
|
+
const target = this.targets[name] as ExecutionDispatcher;
|
|
85
|
+
this.routed.set(pkg.executionId, name);
|
|
86
|
+
// Bound `routed` (it would otherwise grow one entry per execution forever). Recent
|
|
87
|
+
// executions — the ones a cancel can still act on — keep their precise target; only a
|
|
88
|
+
// very old execution (evicted here) falls back to the all-targets cancel path, which is
|
|
89
|
+
// harmless (a finished execution ignores a stray cancel). Insertion-ordered eviction.
|
|
90
|
+
if (this.routed.size > PlacementDispatcher.ROUTED_CAP) {
|
|
91
|
+
const oldest = this.routed.keys().next().value;
|
|
92
|
+
if (oldest !== undefined) this.routed.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
this.inFlight++;
|
|
95
|
+
try {
|
|
96
|
+
return await target.dispatch(pkg, meta);
|
|
97
|
+
} finally {
|
|
98
|
+
this.inFlight--;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async cancel(executionId: string): Promise<void> {
|
|
103
|
+
const name = this.routed.get(executionId);
|
|
104
|
+
if (name !== undefined) {
|
|
105
|
+
await (this.targets[name] as ExecutionDispatcher).cancel(executionId);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Placement unknown (never dispatched here, or restarted): ask every target.
|
|
109
|
+
await Promise.all(
|
|
110
|
+
this.names.map((n) => (this.targets[n] as ExecutionDispatcher).cancel(executionId))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async health(): Promise<DispatcherHealth> {
|
|
115
|
+
const healths = await Promise.all(
|
|
116
|
+
this.names.map((n) => (this.targets[n] as ExecutionDispatcher).health())
|
|
117
|
+
);
|
|
118
|
+
return {
|
|
119
|
+
healthy: healths.every((h) => h.healthy),
|
|
120
|
+
inFlight: this.inFlight,
|
|
121
|
+
detail: this.names.map((n, i) => `${n}:${healths[i]?.inFlight ?? 0}`).join(","),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExecutionPackage,
|
|
3
|
+
type ExecutionResult,
|
|
4
|
+
isSupersededRefusal,
|
|
5
|
+
} from "@loomwork/worker-runtime";
|
|
6
|
+
import type { DispatchMetadata, DispatcherHealth, ExecutionDispatcher } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface CircuitPolicy {
|
|
9
|
+
/** Consecutive dispatch failures before the circuit opens. */
|
|
10
|
+
threshold: number;
|
|
11
|
+
/** How long the circuit stays open before a half-open trial, in ms. */
|
|
12
|
+
cooldownMs: number;
|
|
13
|
+
/** Circuit key for a package. Default: `pkg.flowId` (per-flow breaker). */
|
|
14
|
+
key?: (pkg: ExecutionPackage) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SupervisionPolicy {
|
|
18
|
+
/** Restart a *throwing* dispatch up to N extra times (default 0). A returned
|
|
19
|
+
* "failed" result is a normal outcome, not a restartable error — and neither is a
|
|
20
|
+
* refusal because another holder owns the execution ({@link isSupersededRefusal},
|
|
21
|
+
* L20): it is rethrown at once, without a restart and without counting against the
|
|
22
|
+
* circuit, because restarting it would fence out the holder that won. */
|
|
23
|
+
maxRestarts?: number;
|
|
24
|
+
/** Base backoff between restarts, in ms (default 0). */
|
|
25
|
+
backoffMs?: number;
|
|
26
|
+
/** Exponential growth factor for the backoff (default 2). */
|
|
27
|
+
backoffFactor?: number;
|
|
28
|
+
/** Optional circuit breaker — fail fast when a target is consistently down. */
|
|
29
|
+
circuit?: CircuitPolicy;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class CircuitOpenError extends Error {
|
|
33
|
+
readonly key: string;
|
|
34
|
+
constructor(key: string) {
|
|
35
|
+
super(`Circuit open for "${key}" — failing fast`);
|
|
36
|
+
this.name = "CircuitOpenError";
|
|
37
|
+
this.key = key;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Circuit {
|
|
42
|
+
failures: number;
|
|
43
|
+
openedAt: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SupervisingDispatcherOptions {
|
|
47
|
+
policy?: SupervisionPolicy;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fault supervision (Loom 2.0 K4). A composite {@link ExecutionDispatcher} that wraps
|
|
54
|
+
* a child with an explicit restart/backoff policy and a per-key **circuit breaker**:
|
|
55
|
+
* after `threshold` consecutive dispatch failures the circuit opens and further
|
|
56
|
+
* dispatches fail fast (CircuitOpenError) until a `cooldownMs` half-open trial — so a
|
|
57
|
+
* flaky model/tool/worker is not hammered. Promotes the runtime's ad-hoc
|
|
58
|
+
* retry/redispatch into a named supervision seam; composes over LocalDispatcher /
|
|
59
|
+
* PlacementDispatcher unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export class SupervisingDispatcher implements ExecutionDispatcher {
|
|
62
|
+
private readonly circuits = new Map<string, Circuit>();
|
|
63
|
+
private readonly policy: SupervisionPolicy;
|
|
64
|
+
private readonly now: () => number;
|
|
65
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly child: ExecutionDispatcher,
|
|
69
|
+
options: SupervisingDispatcherOptions = {}
|
|
70
|
+
) {
|
|
71
|
+
this.policy = options.policy ?? {};
|
|
72
|
+
this.now = options.now ?? (() => Date.now());
|
|
73
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async dispatch(pkg: ExecutionPackage, meta?: DispatchMetadata): Promise<ExecutionResult> {
|
|
77
|
+
const key = this.policy.circuit?.key?.(pkg) ?? pkg.flowId;
|
|
78
|
+
this.guardCircuit(key); // throws CircuitOpenError if open within cooldown
|
|
79
|
+
|
|
80
|
+
const maxRestarts = Math.max(0, this.policy.maxRestarts ?? 0);
|
|
81
|
+
const base = this.policy.backoffMs ?? 0;
|
|
82
|
+
const factor = this.policy.backoffFactor ?? 2;
|
|
83
|
+
|
|
84
|
+
let lastErr: unknown;
|
|
85
|
+
for (let attempt = 0; attempt <= maxRestarts; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
const result = await this.child.dispatch(pkg, meta);
|
|
88
|
+
this.recordSuccess(key);
|
|
89
|
+
return result;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// L20 — superseded, not failed: not restartable, and not the target's fault. But an
|
|
92
|
+
// EARLIER restart of this dispatch that failed genuinely still counts against the
|
|
93
|
+
// circuit — the refusal ends the dispatch, it does not erase what preceded it.
|
|
94
|
+
if (isSupersededRefusal(err, pkg.executionId)) {
|
|
95
|
+
if (attempt > 0) this.recordFailure(key);
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
lastErr = err;
|
|
99
|
+
if (attempt < maxRestarts) await this.sleep(base * factor ** attempt);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.recordFailure(key);
|
|
103
|
+
throw lastErr;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async cancel(executionId: string): Promise<void> {
|
|
107
|
+
return this.child.cancel(executionId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async health(): Promise<DispatcherHealth> {
|
|
111
|
+
const h = await this.child.health();
|
|
112
|
+
const open = [...this.circuits.entries()]
|
|
113
|
+
.filter(([, c]) => c.openedAt !== null)
|
|
114
|
+
.map(([k]) => k);
|
|
115
|
+
return {
|
|
116
|
+
healthy: h.healthy && open.length === 0,
|
|
117
|
+
inFlight: h.inFlight,
|
|
118
|
+
...(open.length > 0
|
|
119
|
+
? { detail: `circuits-open: ${open.join(",")}` }
|
|
120
|
+
: h.detail !== undefined && { detail: h.detail }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private guardCircuit(key: string): void {
|
|
125
|
+
const c = this.circuits.get(key);
|
|
126
|
+
if (c === undefined || c.openedAt === null) return;
|
|
127
|
+
const cooldown = this.policy.circuit?.cooldownMs ?? 0;
|
|
128
|
+
if (this.now() - c.openedAt < cooldown) {
|
|
129
|
+
throw new CircuitOpenError(key);
|
|
130
|
+
}
|
|
131
|
+
// Cooldown elapsed → half-open: allow one trial. Keep the failure count so a
|
|
132
|
+
// fresh failure re-opens immediately; a success resets it.
|
|
133
|
+
c.openedAt = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private recordSuccess(key: string): void {
|
|
137
|
+
// DELETE rather than reset. A circuit with `failures: 0, openedAt: null` is
|
|
138
|
+
// indistinguishable from an absent one everywhere it is read — `guardCircuit`
|
|
139
|
+
// returns on either, `recordFailure` defaults to exactly that shape, and
|
|
140
|
+
// `health()` counts only open circuits — so dropping it is observationally
|
|
141
|
+
// identical and bounds the map to circuits actually accumulating failures.
|
|
142
|
+
//
|
|
143
|
+
// Without this the map only grows. The default key is `flowId` (bounded by
|
|
144
|
+
// deployed flows), but `policy.circuit.key` receives the whole ExecutionPackage,
|
|
145
|
+
// so keying per tenant — or per execution, which reads as natural — turned a
|
|
146
|
+
// supervisor into a leak for the lifetime of the process.
|
|
147
|
+
this.circuits.delete(key);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private recordFailure(key: string): void {
|
|
151
|
+
const circuit = this.policy.circuit;
|
|
152
|
+
if (circuit === undefined) return;
|
|
153
|
+
const c = this.circuits.get(key) ?? { failures: 0, openedAt: null };
|
|
154
|
+
c.failures += 1;
|
|
155
|
+
if (c.failures >= circuit.threshold) {
|
|
156
|
+
c.openedAt = this.now();
|
|
157
|
+
}
|
|
158
|
+
this.circuits.set(key, c);
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
DispatchMetadata,
|
|
3
|
+
DispatcherHealth,
|
|
4
|
+
ExecutionDispatcher,
|
|
5
|
+
PlacementPolicy,
|
|
6
|
+
} from "./types";
|
|
7
|
+
export { DISPATCHER_SPI_VERSION } from "./types";
|
|
8
|
+
export { LocalDispatcher, type LocalDispatcherOptions } from "./LocalDispatcher";
|
|
9
|
+
export {
|
|
10
|
+
PlacementDispatcher,
|
|
11
|
+
type PlacementDispatcherOptions,
|
|
12
|
+
} from "./PlacementDispatcher";
|
|
13
|
+
export { dispatchMetadataFromPolicy, defaultPlacementPolicy } from "./placement";
|
|
14
|
+
export {
|
|
15
|
+
SupervisingDispatcher,
|
|
16
|
+
CircuitOpenError,
|
|
17
|
+
type SupervisionPolicy,
|
|
18
|
+
type CircuitPolicy,
|
|
19
|
+
type SupervisingDispatcherOptions,
|
|
20
|
+
} from "./SupervisingDispatcher";
|
|
21
|
+
// Re-export the transport contract so consumers can import package + dispatcher
|
|
22
|
+
// types from one place.
|
|
23
|
+
export type {
|
|
24
|
+
ExecutionOutcome,
|
|
25
|
+
ExecutionPackage,
|
|
26
|
+
ExecutionResult,
|
|
27
|
+
} from "@loomwork/worker-runtime";
|
package/src/placement.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExecutionPolicy } from "@loomwork/core";
|
|
2
|
+
import type { DispatchMetadata, PlacementPolicy } from "./types";
|
|
3
|
+
|
|
4
|
+
// Map the out-of-core ExecutionPolicy (the Loom 2.0 PCB sidecar) onto the advisory
|
|
5
|
+
// DispatchMetadata the compute layer already understands. This is the seam by which
|
|
6
|
+
// a flow's declared placement/priority/resources drive WHERE it runs — without the
|
|
7
|
+
// kernel ever depending on the compute layer (P10).
|
|
8
|
+
export function dispatchMetadataFromPolicy(policy: ExecutionPolicy): DispatchMetadata {
|
|
9
|
+
return {
|
|
10
|
+
...(policy.computeTarget !== undefined && { target: policy.computeTarget }),
|
|
11
|
+
...(policy.priority !== undefined && { priority: policy.priority }),
|
|
12
|
+
...(policy.affinity !== undefined && { lane: policy.affinity }),
|
|
13
|
+
...(policy.resources?.cpu !== undefined && { estimatedCpu: policy.resources.cpu }),
|
|
14
|
+
...(policy.resources?.memoryMb !== undefined && { estimatedMemory: policy.resources.memoryMb }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Default placement: honour an explicit `meta.target` when it names a registered
|
|
19
|
+
// target, otherwise fall through to the dispatcher's default. Deterministic and
|
|
20
|
+
// dependency-free — the advisory plane never affects correctness (P10), only WHERE.
|
|
21
|
+
export function defaultPlacementPolicy(defaultTarget: string): PlacementPolicy {
|
|
22
|
+
return (_pkg, meta, targets) => {
|
|
23
|
+
if (meta?.target !== undefined && targets.includes(meta.target)) return meta.target;
|
|
24
|
+
return defaultTarget;
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExecutionPackage, ExecutionResult } from "@loomwork/worker-runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SPI version for the {@link ExecutionDispatcher} contract (ADR 0033). Pinned by
|
|
5
|
+
* `runExecutionDispatcherConformance` in `@loomwork/testing`.
|
|
6
|
+
*
|
|
7
|
+
* 1.1.0 (L20, ADR 0004 amendment) — a dispatcher MUST NOT retry or redispatch when the worker
|
|
8
|
+
* was refused because another holder owns the execution (`isSupersededRefusal` from
|
|
9
|
+
* `@loomwork/worker-runtime`); it settles the dispatch with that refusal. MINOR: no method changed,
|
|
10
|
+
* a non-retrying dispatcher already conforms, and the kit case runs only when the caller opts in
|
|
11
|
+
* with `overWorker`, so no 1.0 dispatcher newly fails the kit.
|
|
12
|
+
*
|
|
13
|
+
* 1.2.0 (R3, ADR 0111) — `ExecutionPackage.principal` and `WorkerRequest.principal` (both
|
|
14
|
+
* optional JSON) carry the authenticated caller across the dispatch seam, and `WorkerRuntime`
|
|
15
|
+
* gains the optional `runsAsSystem` marker. MINOR: every field is optional, a dispatcher that
|
|
16
|
+
* forwards the package whole (all four first-party ones do) already conforms, and a worker that
|
|
17
|
+
* ignores `principal` behaves as before.
|
|
18
|
+
*/
|
|
19
|
+
export const DISPATCHER_SPI_VERSION = "1.2.0";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Placement hints the scheduler/orchestrator attaches to a dispatch. The
|
|
23
|
+
* Dispatcher maps these to a concrete placement decision (e.g. Cloud Run CPU/
|
|
24
|
+
* memory in Step 3). Everything is optional and advisory — correctness never
|
|
25
|
+
* depends on it.
|
|
26
|
+
*/
|
|
27
|
+
export interface DispatchMetadata {
|
|
28
|
+
lane?: string;
|
|
29
|
+
priority?: number;
|
|
30
|
+
estimatedCpu?: number;
|
|
31
|
+
estimatedMemory?: number;
|
|
32
|
+
estimatedDuration?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Named compute placement target (e.g. "local" | "cloudrun"). Advisory: a
|
|
35
|
+
* {@link PlacementDispatcher} routes to the matching target, falling back to its
|
|
36
|
+
* default when absent/unknown. Derived from `ExecutionPolicy.computeTarget` (M4).
|
|
37
|
+
*/
|
|
38
|
+
target?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Chooses which named target handles a dispatch. Pure and synchronous so placement
|
|
43
|
+
* is deterministic and cheap. Given the package, its advisory metadata, and the set
|
|
44
|
+
* of registered target names, return the chosen name (a PlacementDispatcher falls
|
|
45
|
+
* back to its default if the returned name is not registered).
|
|
46
|
+
*/
|
|
47
|
+
export type PlacementPolicy = (
|
|
48
|
+
pkg: ExecutionPackage,
|
|
49
|
+
meta: DispatchMetadata | undefined,
|
|
50
|
+
targets: readonly string[]
|
|
51
|
+
) => string;
|
|
52
|
+
|
|
53
|
+
/** A snapshot of dispatcher liveness for health checks / observability. */
|
|
54
|
+
export interface DispatcherHealth {
|
|
55
|
+
healthy: boolean;
|
|
56
|
+
inFlight: number;
|
|
57
|
+
detail?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Separates execution *placement* from execution *scheduling*. The scheduler
|
|
62
|
+
* decides WHAT runs; the Dispatcher decides WHERE, and owns worker allocation,
|
|
63
|
+
* result collection, retries and (from Step 4) timeout + failure redispatch. It
|
|
64
|
+
* talks only to a {@link WorkerRuntime} — never to a specific cloud.
|
|
65
|
+
*/
|
|
66
|
+
export interface ExecutionDispatcher {
|
|
67
|
+
/**
|
|
68
|
+
* Run `pkg` to suspend-or-complete. MAY retry a failed attempt; MUST NOT retry or redispatch a
|
|
69
|
+
* superseded refusal (`isSupersededRefusal`, 1.1.0) — it rejects with that refusal instead.
|
|
70
|
+
* Such a rejection is not the execution's failure: another holder owns it, possibly this
|
|
71
|
+
* caller's own earlier timed-out attempt. A caller that needs the outcome awaits it by id
|
|
72
|
+
* (`loadState` / `subscribe`); a holder that has died is re-driven by `recoverAbandoned`.
|
|
73
|
+
*/
|
|
74
|
+
dispatch(pkg: ExecutionPackage, meta?: DispatchMetadata): Promise<ExecutionResult>;
|
|
75
|
+
cancel(executionId: string): Promise<void>;
|
|
76
|
+
health(): Promise<DispatcherHealth>;
|
|
77
|
+
}
|