@faable/deploy-sdk 2.18.0 → 2.18.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.
@@ -16,6 +16,10 @@ export declare class Indexer<T extends {
16
16
  applied: boolean;
17
17
  old?: T;
18
18
  };
19
+ /** Set unconditionally (a relist is authoritative). Returns the previous object. */
20
+ set(obj: T, rv: string): T | undefined;
21
+ /** Remove unconditionally (a relist did not list it). */
22
+ evict(id: string): T | undefined;
19
23
  /** Remove unless what is held is newer than the delete. */
20
24
  delete(id: string, rv: string): T | undefined;
21
25
  private indexObj;
@@ -1 +1 @@
1
- {"version":3,"file":"Indexer.d.ts","sourceRoot":"","sources":["../../src/informers/Indexer.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,EAAE,CAAA;AAE7C,qBAAa,OAAO,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAC3C,OAAO,CAAC,OAAO,CAA4C;IAC3D,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,OAAO,CAA8C;IAE7D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI;IAO5C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAI9B,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIpC,IAAI,IAAI,CAAC,EAAE;IAIX,GAAG,IAAI,MAAM,EAAE;IAIf,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE;IAMvC,wEAAwE;IACxE,MAAM,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,CAAC,CAAA;KAAE;IASzD,2DAA2D;IAC3D,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAQ7C,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,OAAO;CAShB"}
1
+ {"version":3,"file":"Indexer.d.ts","sourceRoot":"","sources":["../../src/informers/Indexer.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,EAAE,CAAA;AAE7C,qBAAa,OAAO,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAC3C,OAAO,CAAC,OAAO,CAA4C;IAC3D,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,OAAO,CAA8C;IAE7D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI;IAO5C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAI9B,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIpC,IAAI,IAAI,CAAC,EAAE;IAIX,GAAG,IAAI,MAAM,EAAE;IAIf,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE;IAMvC,wEAAwE;IACxE,MAAM,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,CAAC,CAAA;KAAE;IASzD,oFAAoF;IACpF,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAQtC,yDAAyD;IACzD,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAQhC,2DAA2D;IAC3D,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAQ7C,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,OAAO;CAShB"}
@@ -40,6 +40,25 @@ export class Indexer {
40
40
  this.indexObj(name, fn, obj);
41
41
  return { applied: true, old: cur?.obj };
42
42
  }
43
+ /** Set unconditionally (a relist is authoritative). Returns the previous object. */
44
+ set(obj, rv) {
45
+ const cur = this.objects.get(obj.id);
46
+ if (cur)
47
+ this.unindex(cur.obj);
48
+ this.objects.set(obj.id, { obj, rv });
49
+ for (const [name, fn] of this.indexFns)
50
+ this.indexObj(name, fn, obj);
51
+ return cur?.obj;
52
+ }
53
+ /** Remove unconditionally (a relist did not list it). */
54
+ evict(id) {
55
+ const cur = this.objects.get(id);
56
+ if (!cur)
57
+ return undefined;
58
+ this.unindex(cur.obj);
59
+ this.objects.delete(id);
60
+ return cur.obj;
61
+ }
43
62
  /** Remove unless what is held is newer than the delete. */
44
63
  delete(id, rv) {
45
64
  const cur = this.objects.get(id);
@@ -1,14 +1,23 @@
1
1
  import { Indexer, type IndexFn } from "./Indexer.js";
2
- import { type WatchClient, type WatchResource } from "./WatchClient.js";
2
+ import { type WatchClient, type WatchEvent, type WatchResource } from "./WatchClient.js";
3
3
  export type InformerEvent = "add" | "update" | "delete";
4
4
  type Handler<T> = (obj: T, old?: T) => void;
5
+ type Logger = {
6
+ warn: (o: object, m?: string) => void;
7
+ info?: (o: object, m?: string) => void;
8
+ };
9
+ /** Thrown into the loop when a watch sends nothing for `idleTimeoutMs`. */
10
+ export declare class WatchIdleError extends Error {
11
+ constructor(idleMs: number);
12
+ }
5
13
  export declare class SharedInformer<T extends {
6
14
  id: string;
7
15
  }> {
8
16
  readonly store: Indexer<T>;
9
17
  private handlers;
10
18
  readonly resource: WatchResource;
11
- constructor(resource: WatchResource);
19
+ private logger?;
20
+ constructor(resource: WatchResource, logger?: Logger);
12
21
  on(event: InformerEvent, handler: Handler<T>): this;
13
22
  addIndex(name: string, fn: IndexFn<T>): this;
14
23
  get(id: string): T | undefined;
@@ -16,24 +25,36 @@ export declare class SharedInformer<T extends {
16
25
  byIndex(name: string, key: string): T[];
17
26
  /** @internal */
18
27
  emit(event: InformerEvent, obj: T, old?: T): void;
19
- /** @internal Apply an upsert; emits add/update. */
20
- apply(obj: T, rv: string): void;
21
- /** @internal Apply a delete; emits delete. */
22
- remove(id: string, rv: string): void;
23
- /** @internal A relist: apply every item, then delete what was not listed. */
28
+ /** @internal Apply an upsert; emits add/update. false if dropped as stale. */
29
+ apply(obj: T, rv: string): boolean;
30
+ /** @internal Apply a delete; emits delete. false if dropped as stale. */
31
+ remove(id: string, rv: string): boolean;
32
+ /**
33
+ * @internal A relist, authoritative like client-go's Replace: the listed
34
+ * objects become the store regardless of rvs (rvs of another generation
35
+ * are not comparable). add for new, update for changed, delete for gone.
36
+ */
24
37
  replace(items: {
25
38
  rv: string;
26
39
  object: T;
27
- }[], listRv: string): void;
40
+ }[]): void;
28
41
  }
29
42
  export type InformerFactoryOptions = {
30
43
  resources: WatchResource[];
31
- logger?: {
32
- warn: (o: object, m?: string) => void;
33
- info?: (o: object, m?: string) => void;
34
- };
35
- /** Backoff after a failed list or watch: first and max delay. */
44
+ logger?: Logger;
45
+ /** Backoff after an attempt without progress: first and max delay (±20 % jitter). */
36
46
  backoffMs?: [number, number];
47
+ /**
48
+ * A watch that sends no line for this long is aborted and reconnected (the
49
+ * server sends a BOOKMARK every ≤ 15 s). Default 45 s.
50
+ */
51
+ idleTimeoutMs?: number;
52
+ /**
53
+ * Every event off the watch, before it is applied. `lagMs` is how long a
54
+ * MODIFIED/DELETED took from the cache's stream to here (null for
55
+ * BOOKMARK/ERROR, whose rv is not a change). For metrics; must not throw.
56
+ */
57
+ onEvent?: (ev: WatchEvent, lagMs: number | null) => void;
37
58
  };
38
59
  export declare class InformerFactory {
39
60
  private informers;
@@ -42,12 +63,24 @@ export declare class InformerFactory {
42
63
  private synced;
43
64
  private syncWaiters;
44
65
  private rv?;
66
+ /** The generation of the current lists; events of another one → relist. */
67
+ private gen;
45
68
  private stopped;
46
69
  readonly stats: {
47
70
  relists: number;
48
71
  reconnects: number;
49
72
  events: number;
50
73
  lastEventAt: number;
74
+ /** Failed lists/watches, idle watches included (each one retried with backoff). */
75
+ errors: number;
76
+ /** When the stores last held a complete list (initial sync or relist). */
77
+ lastSyncAt: number;
78
+ /** Events older than what the store held (same generation), not applied. */
79
+ dropped: number;
80
+ /** ERROR events other than watch_expired. */
81
+ watchErrors: number;
82
+ /** From a 410 / generation change until a relist succeeds. */
83
+ relistPending: boolean;
51
84
  };
52
85
  private client;
53
86
  private options;
@@ -56,11 +89,24 @@ export declare class InformerFactory {
56
89
  id: string;
57
90
  } = any>(resource: WatchResource): SharedInformer<T>;
58
91
  hasSynced(): boolean;
92
+ /**
93
+ * Synced, no relist pending, and something (an event — BOOKMARKs included —
94
+ * or a list) arrived within `maxQuietMs`.
95
+ */
96
+ isFresh(maxQuietMs: number): boolean;
59
97
  waitForSync(): Promise<void>;
60
98
  start(): void;
61
99
  stop(): Promise<void>;
100
+ /** Fetch every list first; replace only if all of them arrived (K4). */
62
101
  private relist;
63
102
  private handle;
103
+ /**
104
+ * One watch connection, read with an idle watchdog: no line for
105
+ * `idleTimeoutMs` aborts the request (its signal) and throws. The race
106
+ * does not trust the client to honour the signal.
107
+ */
108
+ private watchOnce;
109
+ private sleep;
64
110
  private run;
65
111
  }
66
112
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"InformerFactory.d.ts","sourceRoot":"","sources":["../../src/informers/InformerFactory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,cAAc,CAAA;AAEpD,OAAO,EAEL,KAAK,WAAW,EAEhB,KAAK,aAAa,EACnB,MAAM,kBAAkB,CAAA;AAkBzB,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;AACvD,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,KAAK,IAAI,CAAA;AAE3C,qBAAa,cAAc,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAClD,QAAQ,CAAC,KAAK,aAAmB;IACjC,OAAO,CAAC,QAAQ,CAIf;IAED,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;gBAEpB,QAAQ,EAAE,aAAa;IAInC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI;IAKnD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI;IAK5C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAI9B,IAAI,IAAI,CAAC,EAAE;IAIX,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE;IAIvC,gBAAgB;IAChB,IAAI,CAAC,KAAK,EAAE,aAAa,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IAW1C,mDAAmD;IACnD,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM;IAKxB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM;IAK7B,6EAA6E;IAC7E,OAAO,CAAC,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,CAAC,CAAA;KAAE,EAAE,EAAE,MAAM,EAAE,MAAM;CAQ3D;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,SAAS,EAAE,aAAa,EAAE,CAAA;IAC1B,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;QAAC,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAA;IAC1F,iEAAiE;IACjE,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC7B,CAAA;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,SAAS,CAAgD;IACjE,OAAO,CAAC,KAAK,CAAC,CAAiB;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAe;IAC5B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,EAAE,CAAC,CAAQ;IACnB,OAAO,CAAC,OAAO,CAAO;IACtB,QAAQ,CAAC,KAAK;;;;;MAA2D;IAEzE,OAAO,CAAC,MAAM,CAAa;IAC3B,OAAO,CAAC,OAAO,CAAwB;gBAE3B,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB;IAMhE,QAAQ,CAAC,CAAC,SAAS;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,GAAG,EAAE,QAAQ,EAAE,aAAa,GAAG,cAAc,CAAC,CAAC,CAAC;IAMpF,SAAS,IAAI,OAAO;IAIpB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B,KAAK,IAAI,IAAI;IAOP,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAMb,MAAM;IAiBpB,OAAO,CAAC,MAAM;YAoBA,GAAG;CAoClB"}
1
+ {"version":3,"file":"InformerFactory.d.ts","sourceRoot":"","sources":["../../src/informers/InformerFactory.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,13 @@
1
1
  import { Indexer } from "./Indexer.js";
2
- import { compareRv, minRv } from "./rv.js";
2
+ import { compareRv, minRv, rvGeneration, rvTimeMs } from "./rv.js";
3
3
  import { WatchExpiredError, } from "./WatchClient.js";
4
+ /** Thrown into the loop when a watch sends nothing for `idleTimeoutMs`. */
5
+ export class WatchIdleError extends Error {
6
+ constructor(idleMs) {
7
+ super(`watch idle for ${idleMs} ms: reconnecting`);
8
+ this.name = "WatchIdleError";
9
+ }
10
+ }
4
11
  export class SharedInformer {
5
12
  store = new Indexer();
6
13
  handlers = {
@@ -9,8 +16,10 @@ export class SharedInformer {
9
16
  delete: [],
10
17
  };
11
18
  resource;
12
- constructor(resource) {
19
+ logger;
20
+ constructor(resource, logger) {
13
21
  this.resource = resource;
22
+ this.logger = logger;
14
23
  }
15
24
  on(event, handler) {
16
25
  this.handlers[event].push(handler);
@@ -35,34 +44,51 @@ export class SharedInformer {
35
44
  try {
36
45
  h(obj, old);
37
46
  }
38
- catch {
47
+ catch (err) {
39
48
  // A handler's failure is the handler's: the store and the other
40
- // handlers carry on.
49
+ // handlers carry on. But it is said.
50
+ this.logger?.warn({ err, resource: this.resource, event, id: obj?.id }, "informers: handler threw");
41
51
  }
42
52
  }
43
53
  }
44
- /** @internal Apply an upsert; emits add/update. */
54
+ /** @internal Apply an upsert; emits add/update. false if dropped as stale. */
45
55
  apply(obj, rv) {
46
56
  const { applied, old } = this.store.upsert(obj, rv);
47
57
  if (applied)
48
58
  this.emit(old ? "update" : "add", obj, old);
59
+ return applied;
49
60
  }
50
- /** @internal Apply a delete; emits delete. */
61
+ /** @internal Apply a delete; emits delete. false if dropped as stale. */
51
62
  remove(id, rv) {
63
+ if (!this.store.get(id))
64
+ return true;
52
65
  const gone = this.store.delete(id, rv);
53
66
  if (gone)
54
67
  this.emit("delete", gone);
68
+ return !!gone;
55
69
  }
56
- /** @internal A relist: apply every item, then delete what was not listed. */
57
- replace(items, listRv) {
70
+ /**
71
+ * @internal A relist, authoritative like client-go's Replace: the listed
72
+ * objects become the store regardless of rvs (rvs of another generation
73
+ * are not comparable). add for new, update for changed, delete for gone.
74
+ */
75
+ replace(items) {
58
76
  const seen = new Set();
59
77
  for (const { rv, object } of items) {
60
78
  seen.add(object.id);
61
- this.apply(object, rv);
79
+ const old = this.store.set(object, rv);
80
+ if (!old)
81
+ this.emit("add", object);
82
+ else if (JSON.stringify(old) !== JSON.stringify(object))
83
+ this.emit("update", object, old);
84
+ }
85
+ for (const id of this.store.ids()) {
86
+ if (seen.has(id))
87
+ continue;
88
+ const gone = this.store.evict(id);
89
+ if (gone)
90
+ this.emit("delete", gone);
62
91
  }
63
- for (const id of this.store.ids())
64
- if (!seen.has(id))
65
- this.remove(id, listRv);
66
92
  }
67
93
  }
68
94
  export class InformerFactory {
@@ -72,15 +98,32 @@ export class InformerFactory {
72
98
  synced = false;
73
99
  syncWaiters = [];
74
100
  rv;
101
+ /** The generation of the current lists; events of another one → relist. */
102
+ gen = null;
75
103
  stopped = true;
76
- stats = { relists: 0, reconnects: 0, events: 0, lastEventAt: 0 };
104
+ stats = {
105
+ relists: 0,
106
+ reconnects: 0,
107
+ events: 0,
108
+ lastEventAt: 0,
109
+ /** Failed lists/watches, idle watches included (each one retried with backoff). */
110
+ errors: 0,
111
+ /** When the stores last held a complete list (initial sync or relist). */
112
+ lastSyncAt: 0,
113
+ /** Events older than what the store held (same generation), not applied. */
114
+ dropped: 0,
115
+ /** ERROR events other than watch_expired. */
116
+ watchErrors: 0,
117
+ /** From a 410 / generation change until a relist succeeds. */
118
+ relistPending: false,
119
+ };
77
120
  client;
78
121
  options;
79
122
  constructor(client, options) {
80
123
  this.client = client;
81
124
  this.options = options;
82
125
  for (const r of options.resources)
83
- this.informers.set(r, new SharedInformer(r));
126
+ this.informers.set(r, new SharedInformer(r, options.logger));
84
127
  }
85
128
  informer(resource) {
86
129
  const inf = this.informers.get(resource);
@@ -91,6 +134,15 @@ export class InformerFactory {
91
134
  hasSynced() {
92
135
  return this.synced;
93
136
  }
137
+ /**
138
+ * Synced, no relist pending, and something (an event — BOOKMARKs included —
139
+ * or a list) arrived within `maxQuietMs`.
140
+ */
141
+ isFresh(maxQuietMs) {
142
+ if (!this.synced || this.stats.relistPending)
143
+ return false;
144
+ return Date.now() - Math.max(this.stats.lastEventAt, this.stats.lastSyncAt) < maxQuietMs;
145
+ }
94
146
  waitForSync() {
95
147
  if (this.synced)
96
148
  return Promise.resolve();
@@ -108,17 +160,26 @@ export class InformerFactory {
108
160
  this.abort?.abort();
109
161
  await this.loop?.catch(() => { });
110
162
  }
163
+ /** Fetch every list first; replace only if all of them arrived (K4). */
111
164
  async relist() {
112
- const rvs = [];
113
- for (const [resource, inf] of this.informers) {
114
- const { rv, items } = await this.client.list(resource);
115
- inf.replace(items, rv);
116
- rvs.push(rv);
117
- }
165
+ const lists = await Promise.all([...this.informers].map(async ([resource, inf]) => [inf, await this.client.list(resource)]));
166
+ const rvs = lists.map(([, l]) => l.rv);
167
+ const gens = new Set(rvs.map(rvGeneration));
168
+ if (gens.size > 1)
169
+ throw new Error(`lists span generations (${rvs.join(", ")}): relisting`);
170
+ for (const [inf, l] of lists)
171
+ inf.replace(l.items);
118
172
  // Watch from the OLDEST list rv: events already reflected in a newer
119
173
  // list are dropped by the per-object rv.
120
174
  this.rv = minRv(rvs);
175
+ this.gen = rvGeneration(this.rv);
121
176
  this.stats.relists++;
177
+ this.stats.lastSyncAt = Date.now();
178
+ this.stats.relistPending = false;
179
+ const items = {};
180
+ for (const [inf, l] of lists)
181
+ items[inf.resource] = l.items.length;
182
+ this.options.logger?.info?.({ relists: this.stats.relists, rv: this.rv, items }, "informers: relisted");
122
183
  if (!this.synced) {
123
184
  this.synced = true;
124
185
  for (const w of this.syncWaiters.splice(0))
@@ -128,57 +189,155 @@ export class InformerFactory {
128
189
  handle(ev) {
129
190
  this.stats.events++;
130
191
  this.stats.lastEventAt = Date.now();
192
+ if (this.options.onEvent) {
193
+ const t = ev.type === "MODIFIED" || ev.type === "DELETED" ? rvTimeMs(ev.rv) : null;
194
+ try {
195
+ this.options.onEvent(ev, t === null ? null : Date.now() - t);
196
+ }
197
+ catch {
198
+ // Metrics must never break the watch.
199
+ }
200
+ }
201
+ if (ev.type === "ERROR") {
202
+ if (ev.code === "watch_expired")
203
+ return "relist";
204
+ this.stats.watchErrors++;
205
+ this.options.logger?.warn({ code: ev.code, message: ev.message }, "informers: watch ERROR event, reconnecting");
206
+ return "error";
207
+ }
208
+ // Another generation than the lists': the server's stream was reset and
209
+ // rvs across generations do not compare. Relist.
210
+ const gen = rvGeneration(ev.rv);
211
+ if (gen !== null && this.gen !== null && gen !== this.gen) {
212
+ this.options.logger?.info?.({ rv: ev.rv, generation: this.gen }, "informers: generation changed, relisting");
213
+ return "relist";
214
+ }
131
215
  switch (ev.type) {
132
216
  case "BOOKMARK":
133
217
  break;
134
218
  case "MODIFIED":
135
- this.informers.get(ev.resource)?.apply(ev.object, ev.objectRv);
219
+ if (this.informers.get(ev.resource)?.apply(ev.object, ev.objectRv) === false)
220
+ this.stats.dropped++;
136
221
  break;
137
222
  case "DELETED":
138
- this.informers.get(ev.resource)?.remove(ev.object.id, ev.rv);
223
+ if (this.informers.get(ev.resource)?.remove(ev.object.id, ev.rv) === false)
224
+ this.stats.dropped++;
139
225
  break;
140
- case "ERROR":
141
- return ev.code === "watch_expired" ? "relist" : "ok";
142
226
  }
143
227
  // Resume point: the stream position, which only moves forward.
144
- if ("rv" in ev && ev.rv && (!this.rv || compareRv(ev.rv, this.rv) > 0))
228
+ if (ev.rv && (!this.rv || compareRv(ev.rv, this.rv) > 0))
145
229
  this.rv = ev.rv;
146
230
  return "ok";
147
231
  }
232
+ /**
233
+ * One watch connection, read with an idle watchdog: no line for
234
+ * `idleTimeoutMs` aborts the request (its signal) and throws. The race
235
+ * does not trust the client to honour the signal.
236
+ */
237
+ async watchOnce(onProgress) {
238
+ const idleMs = this.options.idleTimeoutMs ?? 45_000;
239
+ const conn = new AbortController();
240
+ const outer = this.abort.signal;
241
+ const onStop = () => conn.abort();
242
+ // stop() may have run already: an aborted signal never fires again.
243
+ if (outer.aborted)
244
+ conn.abort();
245
+ else
246
+ outer.addEventListener("abort", onStop, { once: true });
247
+ const stopped = new Promise((r) => conn.signal.aborted ? r("stopped") : conn.signal.addEventListener("abort", () => r("stopped")));
248
+ const it = this.client.watch(this.options.resources, this.rv, conn.signal)[Symbol.asyncIterator]();
249
+ let events = 0;
250
+ try {
251
+ for (;;) {
252
+ let timer;
253
+ const idle = new Promise((r) => (timer = setTimeout(() => r("idle"), idleMs)));
254
+ let next;
255
+ try {
256
+ next = await Promise.race([it.next(), idle, stopped]);
257
+ }
258
+ finally {
259
+ clearTimeout(timer);
260
+ }
261
+ if (next === "stopped")
262
+ return "closed";
263
+ if (next === "idle")
264
+ throw new WatchIdleError(idleMs);
265
+ if (next.done) {
266
+ this.stats.reconnects++;
267
+ this.options.logger?.info?.({ reconnects: this.stats.reconnects, events, rv: this.rv }, "informers: watch closed, reconnecting");
268
+ return "closed";
269
+ }
270
+ events++;
271
+ const before = this.rv;
272
+ const out = this.handle(next.value);
273
+ if (out !== "ok")
274
+ return out;
275
+ if (this.rv !== before)
276
+ onProgress();
277
+ }
278
+ }
279
+ finally {
280
+ conn.abort();
281
+ outer.removeEventListener("abort", onStop);
282
+ // Not awaited: a stalled iterator may never settle.
283
+ it.return?.()?.catch?.(() => { });
284
+ }
285
+ }
286
+ sleep(ms) {
287
+ return new Promise((r) => {
288
+ const timer = setTimeout(done, ms);
289
+ const signal = this.abort.signal;
290
+ function done() {
291
+ clearTimeout(timer);
292
+ signal.removeEventListener("abort", done);
293
+ r();
294
+ }
295
+ if (signal.aborted)
296
+ done();
297
+ else
298
+ signal.addEventListener("abort", done, { once: true });
299
+ });
300
+ }
148
301
  async run() {
149
302
  const [first, max] = this.options.backoffMs ?? [500, 30_000];
150
303
  let backoff = first;
151
304
  let needList = true;
152
305
  while (!this.stopped) {
306
+ // Progress = the stream position moved. An attempt without it (error,
307
+ // empty stream, ERROR event, 410 right after a relist) backs off.
308
+ let progressed = false;
153
309
  try {
154
310
  if (needList) {
155
311
  await this.relist();
156
312
  needList = false;
157
313
  }
158
- let relist = false;
159
- for await (const ev of this.client.watch(this.options.resources, this.rv, this.abort.signal)) {
160
- if (this.handle(ev) === "relist") {
161
- relist = true;
162
- break;
163
- }
164
- backoff = first;
165
- }
166
- if (relist)
314
+ const out = await this.watchOnce(() => (progressed = true));
315
+ if (out === "relist") {
167
316
  needList = true;
168
- else
169
- this.stats.reconnects++;
317
+ this.stats.relistPending = true;
318
+ }
170
319
  }
171
320
  catch (err) {
172
321
  if (this.stopped)
173
322
  break;
174
323
  if (err instanceof WatchExpiredError) {
175
324
  needList = true;
176
- continue;
325
+ this.stats.relistPending = true;
177
326
  }
178
- this.options.logger?.warn({ err, backoff }, "informers: list/watch failed, retrying");
179
- await new Promise((r) => setTimeout(r, backoff));
180
- backoff = Math.min(backoff * 2, max);
327
+ else {
328
+ this.stats.errors++;
329
+ this.options.logger?.warn({ err, backoff }, "informers: list/watch failed, retrying");
330
+ }
331
+ }
332
+ if (this.stopped)
333
+ break;
334
+ if (progressed) {
335
+ backoff = first;
336
+ continue;
181
337
  }
338
+ // ±20 % jitter, so a fleet does not reconnect in step.
339
+ await this.sleep(backoff * (0.8 + Math.random() * 0.4));
340
+ backoff = Math.min(backoff * 2, max);
182
341
  }
183
342
  }
184
343
  }
@@ -1,6 +1,6 @@
1
1
  export { Indexer, type IndexFn } from "./Indexer.js";
2
- export { InformerFactory, SharedInformer, type InformerEvent, type InformerFactoryOptions, } from "./InformerFactory.js";
3
- export { compareRv, minRv } from "./rv.js";
2
+ export { InformerFactory, SharedInformer, WatchIdleError, type InformerEvent, type InformerFactoryOptions, } from "./InformerFactory.js";
3
+ export { compareRv, minRv, rvGeneration, rvTimeMs } from "./rv.js";
4
4
  export { WatchExpiredError, type ListResult, type WatchClient, type WatchEvent, type WatchResource, } from "./WatchClient.js";
5
5
  export { deployApiWatchClient } from "./deployApiWatchClient.js";
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/informers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,cAAc,CAAA;AACpD,OAAO,EACL,eAAe,EACf,cAAc,EACd,KAAK,aAAa,EAClB,KAAK,sBAAsB,GAC5B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC1C,OAAO,EACL,iBAAiB,EACjB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/informers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,cAAc,CAAA;AACpD,OAAO,EACL,eAAe,EACf,cAAc,EACd,cAAc,EACd,KAAK,aAAa,EAClB,KAAK,sBAAsB,GAC5B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAClE,OAAO,EACL,iBAAiB,EACjB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}
@@ -1,5 +1,5 @@
1
1
  export { Indexer } from "./Indexer.js";
2
- export { InformerFactory, SharedInformer, } from "./InformerFactory.js";
3
- export { compareRv, minRv } from "./rv.js";
2
+ export { InformerFactory, SharedInformer, WatchIdleError, } from "./InformerFactory.js";
3
+ export { compareRv, minRv, rvGeneration, rvTimeMs } from "./rv.js";
4
4
  export { WatchExpiredError, } from "./WatchClient.js";
5
5
  export { deployApiWatchClient } from "./deployApiWatchClient.js";
@@ -1,4 +1,15 @@
1
1
  /** <0, 0, >0 like a comparator; an unparsable rv sorts first. */
2
2
  export declare const compareRv: (a: string, b: string) => number;
3
+ /**
4
+ * When the change entered the cache's stream (ms since epoch), from the
5
+ * stream position in an rv. `now - rvTimeMs(rv)` is how long the event took
6
+ * to reach this process — the informer's freshness. null if unparsable.
7
+ */
8
+ export declare const rvTimeMs: (rv: string) => number | null;
9
+ /**
10
+ * The generation of an rv (it changes when the server's stream is reset).
11
+ * rvs of different generations are not comparable. null if unparsable.
12
+ */
13
+ export declare const rvGeneration: (rv: string) => number | null;
3
14
  export declare const minRv: (rvs: string[]) => string;
4
15
  //# sourceMappingURL=rv.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rv.d.ts","sourceRoot":"","sources":["../../src/informers/rv.ts"],"names":[],"mappings":"AASA,iEAAiE;AACjE,eAAO,MAAM,SAAS,MAAO,MAAM,KAAK,MAAM,KAAG,MAKhD,CAAA;AAED,eAAO,MAAM,KAAK,QAAS,MAAM,EAAE,KAAG,MACe,CAAA"}
1
+ {"version":3,"file":"rv.d.ts","sourceRoot":"","sources":["../../src/informers/rv.ts"],"names":[],"mappings":"AASA,iEAAiE;AACjE,eAAO,MAAM,SAAS,MAAO,MAAM,KAAK,MAAM,KAAG,MAKhD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,OAAQ,MAAM,KAAG,MAAM,GAAG,IAG9C,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,OAAQ,MAAM,KAAG,MAAM,GAAG,IAGlD,CAAA;AAED,eAAO,MAAM,KAAK,QAAS,MAAM,EAAE,KAAG,MACe,CAAA"}
@@ -13,4 +13,21 @@ export const compareRv = (a, b) => {
13
13
  return pa ? 1 : pb ? -1 : 0;
14
14
  return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2];
15
15
  };
16
+ /**
17
+ * When the change entered the cache's stream (ms since epoch), from the
18
+ * stream position in an rv. `now - rvTimeMs(rv)` is how long the event took
19
+ * to reach this process — the informer's freshness. null if unparsable.
20
+ */
21
+ export const rvTimeMs = (rv) => {
22
+ const p = parse(rv);
23
+ return p ? p[1] : null;
24
+ };
25
+ /**
26
+ * The generation of an rv (it changes when the server's stream is reset).
27
+ * rvs of different generations are not comparable. null if unparsable.
28
+ */
29
+ export const rvGeneration = (rv) => {
30
+ const p = parse(rv);
31
+ return p ? p[0] : null;
32
+ };
16
33
  export const minRv = (rvs) => rvs.reduce((m, r) => (compareRv(r, m) < 0 ? r : m));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@faable/deploy-sdk",
3
3
  "description": "Codebase for SDK building",
4
- "version": "2.18.0",
4
+ "version": "2.18.1",
5
5
  "author": "Marc Pomar <marc@faable.com>",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",