@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.
@@ -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;
@@ -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
- return new SocketMigrationClient(url, token, handlers, opts);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {