@norskvideo/ctl-sdk 0.1.33 → 0.1.34
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/migration-client.d.ts +33 -0
- package/migration-client.js +112 -1
- package/package.json +1 -1
package/migration-client.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
|
+
/** A source declining the propose. Return it from `onGetState` (see
|
|
2
|
+
* {@link stateRefusal}); it goes to the worker as `state-refused{reason}`
|
|
3
|
+
* and the migration ends with no target ever placed. */
|
|
4
|
+
export interface StateRefusal {
|
|
5
|
+
readonly refused: true;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function stateRefusal(reason: string): StateRefusal;
|
|
9
|
+
export declare function isStateRefusal(value: unknown): value is StateRefusal;
|
|
1
10
|
export interface MigrationHandlers {
|
|
11
|
+
/** Source side, first on every handover: the propose. Return the state a
|
|
12
|
+
* target would need to take over (any JSON; `undefined`/`null` is a yes
|
|
13
|
+
* with nothing to hand over), or {@link stateRefusal} to decline. A throw
|
|
14
|
+
* is a refusal too. The answer goes back as `state` / `state-refused`;
|
|
15
|
+
* a product that answers from elsewhere calls `reportState` /
|
|
16
|
+
* `refuseState` inside the handler instead, and the return value is
|
|
17
|
+
* then ignored. Without this handler the client refuses every propose,
|
|
18
|
+
* naming the product — a product that cannot hand over is told so at
|
|
19
|
+
* once rather than timing out the migration. */
|
|
20
|
+
onGetState?(migrationId: string): unknown | Promise<unknown>;
|
|
21
|
+
/** Target side, first after the hello-ack when the source handed
|
|
22
|
+
* something over: adopt `payload` before reporting ready. Nothing is sent
|
|
23
|
+
* back for it. */
|
|
24
|
+
onInitialState?(payload: unknown, migrationId: string): void | Promise<void>;
|
|
2
25
|
/** Source side: the target is ready at `payload`. Stop output at or after
|
|
3
26
|
* it, then `reportStoppedOutput`. */
|
|
4
27
|
onCutoverReady?(payload: unknown, migrationId: string): void | Promise<void>;
|
|
@@ -19,6 +42,13 @@ export interface MigrationClient {
|
|
|
19
42
|
readonly migrationId?: string;
|
|
20
43
|
/** True while the socket is open and hello-acked. */
|
|
21
44
|
readonly connected: boolean;
|
|
45
|
+
/** Source: answer the propose with `payload` (any JSON; null is a yes with
|
|
46
|
+
* nothing to hand over). Only meaningful while a `get-state` is in
|
|
47
|
+
* flight — i.e. from inside `onGetState`, before it returns or resolves;
|
|
48
|
+
* otherwise dropped with a log line. */
|
|
49
|
+
reportState(payload: unknown): void;
|
|
50
|
+
/** Source: decline the propose. Same window as `reportState`. */
|
|
51
|
+
refuseState(reason: string): void;
|
|
22
52
|
/** Target: ready to take over at `payload` (a position). Once. */
|
|
23
53
|
reportReady(payload: unknown): void;
|
|
24
54
|
/** Source: output stopped at `payload` (a position). */
|
|
@@ -56,5 +86,8 @@ export interface ConnectMigrationOptions {
|
|
|
56
86
|
/** Where the client reports what it does; default: console.error, one
|
|
57
87
|
* line per event, prefixed. */
|
|
58
88
|
log?: (line: string) => void;
|
|
89
|
+
/** How the product is named in a refusal the client makes on its behalf;
|
|
90
|
+
* default: `NORSK_RUNTIME_ID` from the env, else "this product". */
|
|
91
|
+
name?: string;
|
|
59
92
|
}
|
|
60
93
|
export declare function connectMigration(handlers: MigrationHandlers, env?: NodeJS.ProcessEnv, opts?: ConnectMigrationOptions): MigrationClient;
|
package/migration-client.js
CHANGED
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
// handover-migratable product calls at startup.
|
|
4
4
|
//
|
|
5
5
|
// const migration = connectMigration({
|
|
6
|
+
// onGetState: async () => currentPosition(), // source: the propose (or stateRefusal("why"))
|
|
7
|
+
// onInitialState: async (payload) => { /* target: adopt the source's state before ready */ },
|
|
6
8
|
// onCutoverReady: async (payload) => { /* source: stop at >= payload */ migration.reportStoppedOutput({...}) },
|
|
7
9
|
// onCutoverApply: async (payload) => { /* target: take over from payload */ migration.reportApplied() },
|
|
8
10
|
// onAbort: (reason) => { /* source: resume output */ },
|
|
9
11
|
// });
|
|
10
12
|
// if (migration.role === "target") migration.reportReady({...});
|
|
11
13
|
//
|
|
14
|
+
// The worker asks `get-state` FIRST on every handover and waits for `state`
|
|
15
|
+
// or `state-refused`; nothing on the worker times that out, so a client that
|
|
16
|
+
// answered neither parked every migration in `proposing` until the
|
|
17
|
+
// AutoManager's deadline (review 2026-09-16). This client therefore ALWAYS
|
|
18
|
+
// answers: the handler's return value, its refusal, its exception, or — with
|
|
19
|
+
// no handler at all — a refusal that names the product. A refusal costs
|
|
20
|
+
// nothing; a park costs the deadline.
|
|
21
|
+
//
|
|
12
22
|
// Reads NORSK_WORKER_WS_URL / NORSK_WORKER_TOKEN. When they are absent — a
|
|
13
23
|
// worker with `--no-job-socket`, a local `norsk-ctl` launch, a test — this is
|
|
14
24
|
// a no-op client with role "none", so a product calls it unconditionally.
|
|
@@ -19,6 +29,15 @@
|
|
|
19
29
|
// the LATEST unsent report while disconnected — a newer position supersedes
|
|
20
30
|
// an older one, and the worker acts on the first it hears.
|
|
21
31
|
import { JOB_SOCKET_ENV, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
|
|
32
|
+
export function stateRefusal(reason) {
|
|
33
|
+
return { refused: true, reason };
|
|
34
|
+
}
|
|
35
|
+
export function isStateRefusal(value) {
|
|
36
|
+
return (typeof value === "object" &&
|
|
37
|
+
value !== null &&
|
|
38
|
+
value.refused === true &&
|
|
39
|
+
typeof value.reason === "string");
|
|
40
|
+
}
|
|
22
41
|
const DEFAULT_MIN_BACKOFF_MS = 1_000;
|
|
23
42
|
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
24
43
|
export function connectMigration(handlers, env = process.env, opts = {}) {
|
|
@@ -26,13 +45,16 @@ export function connectMigration(handlers, env = process.env, opts = {}) {
|
|
|
26
45
|
const token = env[JOB_SOCKET_ENV.token];
|
|
27
46
|
if (!url || !token)
|
|
28
47
|
return noopClient();
|
|
29
|
-
|
|
48
|
+
const name = opts.name ?? env.NORSK_RUNTIME_ID ?? "this product";
|
|
49
|
+
return new SocketMigrationClient(url, token, handlers, { ...opts, name });
|
|
30
50
|
}
|
|
31
51
|
function noopClient() {
|
|
32
52
|
return {
|
|
33
53
|
role: "none",
|
|
34
54
|
migrationId: undefined,
|
|
35
55
|
connected: false,
|
|
56
|
+
reportState: () => { },
|
|
57
|
+
refuseState: () => { },
|
|
36
58
|
reportReady: () => { },
|
|
37
59
|
reportStoppedOutput: () => { },
|
|
38
60
|
reportApplied: () => { },
|
|
@@ -53,6 +75,10 @@ class SocketMigrationClient {
|
|
|
53
75
|
attempt = 0;
|
|
54
76
|
reconnectTimer;
|
|
55
77
|
pending;
|
|
78
|
+
/** The `get-state` being answered, while one is. `answered` flips when
|
|
79
|
+
* the product calls reportState/refuseState from inside its handler, so
|
|
80
|
+
* the handler's return value is not sent as a second answer. */
|
|
81
|
+
stateRequest;
|
|
56
82
|
minBackoffMs;
|
|
57
83
|
maxBackoffMs;
|
|
58
84
|
log;
|
|
@@ -66,6 +92,12 @@ class SocketMigrationClient {
|
|
|
66
92
|
this.log = opts.log ?? ((line) => console.error(`[migration] ${line}`));
|
|
67
93
|
void this.connect();
|
|
68
94
|
}
|
|
95
|
+
reportState(payload) {
|
|
96
|
+
this.answerState({ type: "state", payload: asPayload(payload) });
|
|
97
|
+
}
|
|
98
|
+
refuseState(reason) {
|
|
99
|
+
this.answerState({ type: "state-refused", reason });
|
|
100
|
+
}
|
|
69
101
|
reportReady(payload) {
|
|
70
102
|
this.report({ type: "target-ready", payload: asPayload(payload) });
|
|
71
103
|
}
|
|
@@ -94,6 +126,67 @@ class SocketMigrationClient {
|
|
|
94
126
|
}
|
|
95
127
|
}
|
|
96
128
|
// ── private ─────────────────────────────────────────────────────────
|
|
129
|
+
/** The one answer to the in-flight `get-state`. The worker closes the
|
|
130
|
+
* socket 4400 on a `state` with no propose in flight, so an answer with
|
|
131
|
+
* none is dropped here rather than sent. Not held while disconnected: a
|
|
132
|
+
* socket that drops mid-propose is the source aborting, and the worker
|
|
133
|
+
* asks again on the next migration. */
|
|
134
|
+
answerState(msg) {
|
|
135
|
+
if (this.closed)
|
|
136
|
+
return;
|
|
137
|
+
const req = this.stateRequest;
|
|
138
|
+
if (!req) {
|
|
139
|
+
this.log(`'${msg.type}' with no get-state in flight; dropped`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (req.answered) {
|
|
143
|
+
this.log(`'${msg.type}' after get-state for ${req.migrationId} was already answered; dropped`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
req.answered = true;
|
|
147
|
+
if (msg.type === "state-refused") {
|
|
148
|
+
// A source that refused is not in the migration; it may be asked again.
|
|
149
|
+
if (this.migrationId === req.migrationId)
|
|
150
|
+
this.migrationId = undefined;
|
|
151
|
+
}
|
|
152
|
+
if (!this.connected || !this.socket) {
|
|
153
|
+
this.log(`'${msg.type}' for ${req.migrationId} while disconnected; dropped — the worker will ask again`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.send(msg);
|
|
157
|
+
}
|
|
158
|
+
/** The propose. Every path ends in exactly one `state` | `state-refused`. */
|
|
159
|
+
async onGetState(migrationId) {
|
|
160
|
+
if (this.stateRequest && !this.stateRequest.answered) {
|
|
161
|
+
// The worker never sends a second get-state while one is in flight;
|
|
162
|
+
// if it does, the newer one is the one it is waiting on.
|
|
163
|
+
this.log(`get-state for ${migrationId} while ${this.stateRequest.migrationId} is unanswered; superseded`);
|
|
164
|
+
}
|
|
165
|
+
this.stateRequest = { migrationId, answered: false };
|
|
166
|
+
this.migrationId = migrationId;
|
|
167
|
+
const handler = this.handlers.onGetState;
|
|
168
|
+
if (!handler) {
|
|
169
|
+
const reason = `${this.opts.name} registered no onGetState handler (connectMigration without onGetState); it cannot hand over`;
|
|
170
|
+
this.log(`get-state for ${migrationId}: ${reason}; refusing`);
|
|
171
|
+
this.refuseState(reason);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let answer;
|
|
175
|
+
try {
|
|
176
|
+
answer = await handler.call(this.handlers, migrationId);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
this.log(`onGetState for ${migrationId} threw: ${String(e)}; refusing`);
|
|
180
|
+
this.refuseState(`onGetState threw: ${String(e)}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (this.stateRequest?.migrationId !== migrationId || this.stateRequest.answered)
|
|
184
|
+
return;
|
|
185
|
+
if (isStateRefusal(answer))
|
|
186
|
+
this.refuseState(answer.reason);
|
|
187
|
+
else
|
|
188
|
+
this.reportState(answer);
|
|
189
|
+
}
|
|
97
190
|
report(msg) {
|
|
98
191
|
if (this.closed)
|
|
99
192
|
return;
|
|
@@ -169,6 +262,8 @@ class SocketMigrationClient {
|
|
|
169
262
|
return;
|
|
170
263
|
this.socket = undefined;
|
|
171
264
|
this.connected = false;
|
|
265
|
+
// A drop mid-propose is the source aborting; the worker asks afresh.
|
|
266
|
+
this.stateRequest = undefined;
|
|
172
267
|
if (this.closed)
|
|
173
268
|
return;
|
|
174
269
|
this.log(`socket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`);
|
|
@@ -186,6 +281,7 @@ class SocketMigrationClient {
|
|
|
186
281
|
return;
|
|
187
282
|
this.socket = undefined;
|
|
188
283
|
this.connected = false;
|
|
284
|
+
this.stateRequest = undefined;
|
|
189
285
|
if (this.closed)
|
|
190
286
|
return;
|
|
191
287
|
this.log("socket error; will reconnect");
|
|
@@ -219,6 +315,17 @@ class SocketMigrationClient {
|
|
|
219
315
|
switch (msg.type) {
|
|
220
316
|
case "hello-ack":
|
|
221
317
|
return;
|
|
318
|
+
case "get-state":
|
|
319
|
+
await this.onGetState(msg.migrationId);
|
|
320
|
+
return;
|
|
321
|
+
case "initial-state":
|
|
322
|
+
this.migrationId = msg.migrationId;
|
|
323
|
+
if (!this.handlers.onInitialState) {
|
|
324
|
+
this.log(`initial-state for ${msg.migrationId} arrived but no onInitialState handler is registered; the source's state is dropped`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await this.handlers.onInitialState(msg.payload, msg.migrationId);
|
|
328
|
+
return;
|
|
222
329
|
case "cutover-ready":
|
|
223
330
|
this.migrationId = msg.migrationId;
|
|
224
331
|
await this.handlers.onCutoverReady?.(msg.payload, msg.migrationId);
|
|
@@ -228,6 +335,10 @@ class SocketMigrationClient {
|
|
|
228
335
|
await this.handlers.onCutoverApply?.(msg.payload, msg.migrationId);
|
|
229
336
|
return;
|
|
230
337
|
case "abort":
|
|
338
|
+
// An abort ends an unanswered propose too: an answer after it
|
|
339
|
+
// would meet a worker with nothing in flight (4400).
|
|
340
|
+
if (this.stateRequest?.migrationId === msg.migrationId)
|
|
341
|
+
this.stateRequest = undefined;
|
|
231
342
|
await this.handlers.onAbort?.(msg.reason, msg.migrationId);
|
|
232
343
|
if (this.role === "source")
|
|
233
344
|
this.migrationId = undefined;
|