@norskvideo/ctl-sdk 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -79,6 +79,21 @@ export interface ProductServiceOptions {
79
79
  containerOps?: ProductContainerOps;
80
80
  }
81
81
  export declare class ProductService {
82
+ /**
83
+ * Serialises every mutation. `add` decides on a store read — which host port
84
+ * is free, whether the name is taken — and only commits after starting a
85
+ * container, waiting for it, fetching its manifest and probing it. That gap
86
+ * cannot move inside the store's `update` callback: the callback is sync,
87
+ * and a registration cannot be written under its name before `fetchManifest`
88
+ * supplies the name. Two concurrent adds therefore allocated the same host
89
+ * port and both stored under one name.
90
+ *
91
+ * This is a lock in a different module from the data it protects, which is
92
+ * ordinarily the shape to avoid — accepted here because the invariant spans
93
+ * side effects (a running container, an allocated port) the store cannot
94
+ * see. See ADR-0008.
95
+ */
96
+ private readonly mutations;
82
97
  private readonly store;
83
98
  private readonly allocatePort;
84
99
  private readonly importProductTemplateBytes?;
@@ -92,8 +107,11 @@ export declare class ProductService {
92
107
  * the dev URL — registered no longer implies running. */
93
108
  isRunning(reg: ProductRegistration): Promise<boolean>;
94
109
  add(spec: ProductSpec, opts?: AddProductOpts): Promise<AddProductResult>;
110
+ private addSerialised;
95
111
  remove(name: string): Promise<void>;
112
+ private removeSerialised;
96
113
  reload(name: string): Promise<ProductRegistration>;
114
+ private reloadSerialised;
97
115
  /** Stop every running container-kind product and clear its tracked
98
116
  * containerId (model B: the daemon owns container lifecycle, so it reaps
99
117
  * the control planes it started). Dev-kind products are externally owned —
@@ -102,6 +120,7 @@ export declare class ProductService {
102
120
  * Best-effort per product — a failing `docker rm` is logged, never thrown,
103
121
  * so one stubborn container can't block a clean shutdown. */
104
122
  stopAll(): Promise<void>;
123
+ private stopAllSerialised;
105
124
  /** Relaunch every container-kind product recorded in the store, refreshing
106
125
  * its containerId (model B: called once at daemon boot to re-create the
107
126
  * control planes stopped on the previous shutdown). Dev-kind products are
@@ -109,6 +128,7 @@ export declare class ProductService {
109
128
  * come up is logged and left with its containerId cleared, so isRunning()
110
129
  * reports it down rather than pointing at a container that never started. */
111
130
  restoreAll(): Promise<void>;
131
+ private restoreAllSerialised;
112
132
  /** Stop (if still present) and relaunch a single container-kind product,
113
133
  * recording the fresh containerId. Used by the health monitor to recover a
114
134
  * product that has failed its liveness probe. Unlike restoreAll this is not
@@ -117,4 +137,5 @@ export declare class ProductService {
117
137
  * persisted before the readiness wait, so even a timed-out restart leaves a
118
138
  * tracked container the next restart can reap rather than orphan. */
119
139
  restart(name: string): Promise<void>;
140
+ private restartSerialised;
120
141
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { logger } from "@norskvideo/ctl-foundation";
2
+ import { logger, Mutex } from "@norskvideo/ctl-foundation";
3
3
  import { validateDevUrl } from "./dev-url.js";
4
4
  import { dockerRename, dockerRm, dockerRun, productContainerName } from "./docker-runner.js";
5
5
  import { resolveLicenseFile } from "./license-registration.js";
@@ -31,6 +31,21 @@ const defaultContainerOps = {
31
31
  waitForReady,
32
32
  };
33
33
  export class ProductService {
34
+ /**
35
+ * Serialises every mutation. `add` decides on a store read — which host port
36
+ * is free, whether the name is taken — and only commits after starting a
37
+ * container, waiting for it, fetching its manifest and probing it. That gap
38
+ * cannot move inside the store's `update` callback: the callback is sync,
39
+ * and a registration cannot be written under its name before `fetchManifest`
40
+ * supplies the name. Two concurrent adds therefore allocated the same host
41
+ * port and both stored under one name.
42
+ *
43
+ * This is a lock in a different module from the data it protects, which is
44
+ * ordinarily the shape to avoid — accepted here because the invariant spans
45
+ * side effects (a running container, an allocated port) the store cannot
46
+ * see. See ADR-0008.
47
+ */
48
+ mutations = new Mutex();
34
49
  store;
35
50
  allocatePort;
36
51
  importProductTemplateBytes;
@@ -57,6 +72,9 @@ export class ProductService {
57
72
  return this.isDevUrlAlive(specBaseUrl(reg.spec));
58
73
  }
59
74
  async add(spec, opts = {}) {
75
+ return this.mutations.run(() => this.addSerialised(spec, opts));
76
+ }
77
+ async addSerialised(spec, opts) {
60
78
  const existing = this.store.read();
61
79
  let baseUrl;
62
80
  let port;
@@ -222,6 +240,9 @@ export class ProductService {
222
240
  return { registration, warnings };
223
241
  }
224
242
  async remove(name) {
243
+ return this.mutations.run(() => this.removeSerialised(name));
244
+ }
245
+ async removeSerialised(name) {
225
246
  const existing = this.store.read();
226
247
  const target = existing.find((p) => p.name === name);
227
248
  if (!target)
@@ -232,6 +253,9 @@ export class ProductService {
232
253
  logger.info(`Product '${name}' removed`);
233
254
  }
234
255
  async reload(name) {
256
+ return this.mutations.run(() => this.reloadSerialised(name));
257
+ }
258
+ async reloadSerialised(name) {
235
259
  const existing = this.store.read();
236
260
  const target = existing.find((p) => p.name === name);
237
261
  if (!target)
@@ -254,6 +278,9 @@ export class ProductService {
254
278
  * Best-effort per product — a failing `docker rm` is logged, never thrown,
255
279
  * so one stubborn container can't block a clean shutdown. */
256
280
  async stopAll() {
281
+ return this.mutations.run(() => this.stopAllSerialised());
282
+ }
283
+ async stopAllSerialised() {
257
284
  const toStop = this.store.read().filter((p) => p.spec.kind === "container" && p.containerId !== undefined);
258
285
  if (toStop.length === 0)
259
286
  return;
@@ -276,6 +303,9 @@ export class ProductService {
276
303
  * come up is logged and left with its containerId cleared, so isRunning()
277
304
  * reports it down rather than pointing at a container that never started. */
278
305
  async restoreAll() {
306
+ return this.mutations.run(() => this.restoreAllSerialised());
307
+ }
308
+ async restoreAllSerialised() {
279
309
  const toRestore = this.store.read().filter((p) => p.spec.kind === "container");
280
310
  if (toRestore.length === 0)
281
311
  return;
@@ -308,6 +338,9 @@ export class ProductService {
308
338
  * persisted before the readiness wait, so even a timed-out restart leaves a
309
339
  * tracked container the next restart can reap rather than orphan. */
310
340
  async restart(name) {
341
+ return this.mutations.run(() => this.restartSerialised(name));
342
+ }
343
+ async restartSerialised(name) {
311
344
  const target = this.store.read().find((p) => p.name === name);
312
345
  if (!target)
313
346
  throw new ProductError("NOT_FOUND", `product '${name}' not registered`);