kubernetes-fluent-client 3.11.6 → 3.11.7
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/dist/fetch.d.ts +1 -1
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +2 -2
- package/dist/fluent/watch.d.ts.map +1 -1
- package/dist/fluent/watch.js +85 -39
- package/package.json +16 -15
- package/src/fetch.ts +2 -2
- package/src/fluent/watch.ts +89 -40
package/dist/fetch.d.ts
CHANGED
package/dist/fetch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,WAAW,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAEjF,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI;IAC7B,IAAI,EAAE,CAAC,CAAC;IACR,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,CAAC,CAAC,EAAE,OAAO,CAAC;CACb,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,wBAAsB,KAAK,CAAC,CAAC,EAC3B,GAAG,EAAE,GAAG,GAAG,WAAW,EACtB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CA2C3B"}
|
package/dist/fetch.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
|
|
3
3
|
import { StatusCodes } from "http-status-codes";
|
|
4
|
-
import { fetch as undiciFetch } from "undici";
|
|
4
|
+
import { fetch as undiciFetch, Headers } from "undici";
|
|
5
5
|
/**
|
|
6
6
|
* Perform an async HTTP call and return the parsed JSON response, optionally
|
|
7
7
|
* as a specific type.
|
|
@@ -39,7 +39,7 @@ export async function fetch(url, init) {
|
|
|
39
39
|
// Otherwise, return however the response was read
|
|
40
40
|
data = (await resp.text());
|
|
41
41
|
}
|
|
42
|
-
return { data, ok, status, statusText, headers };
|
|
42
|
+
return { data, ok, status, statusText, headers: headers };
|
|
43
43
|
}
|
|
44
44
|
catch (e) {
|
|
45
45
|
// Always treat a catch as a failure for callers — even when the HTTP
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/fluent/watch.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,EAAE,YAAY,EAAwB,MAAM,aAAa,CAAC;AACjE,OAAO,EAEL,OAAO,EAEP,WAAW,EAEZ,MAAM,mBAAmB,CAAC;AAG3B,oBAAY,UAAU;IACpB,sCAAsC;IACtC,OAAO,YAAY;IACnB,2BAA2B;IAC3B,aAAa,kBAAkB;IAC/B,kDAAkD;IAClD,UAAU,eAAe;IACzB,0BAA0B;IAC1B,SAAS,cAAc;IACvB,8BAA8B;IAC9B,OAAO,YAAY;IACnB,sBAAsB;IACtB,KAAK,UAAU;IACf,mCAAmC;IACnC,IAAI,SAAS;IACb,wCAAwC;IACxC,oBAAoB,yBAAyB;IAC7C,qCAAqC;IACrC,iBAAiB,sBAAsB;IACvC,kCAAkC;IAClC,IAAI,SAAS;IACb,2BAA2B;IAC3B,UAAU,eAAe;IACzB,mBAAmB;IACnB,UAAU,eAAe;IACzB,qCAAqC;IACrC,wBAAwB,6BAA6B;IACrD,iCAAiC;IACjC,eAAe,oBAAoB;IACnC,4BAA4B;IAC5B,WAAW,gBAAgB;CAC5B;AAED,4CAA4C;AAC5C,MAAM,MAAM,QAAQ,GAAG;IACrB,+HAA+H;IAC/H,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wHAAwH;IACxH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAKF,iDAAiD;AACjD,qBAAa,OAAO,CAAC,CAAC,SAAS,YAAY;;IAyBzC,YAAY,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/fluent/watch.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,EAAE,YAAY,EAAwB,MAAM,aAAa,CAAC;AACjE,OAAO,EAEL,OAAO,EAEP,WAAW,EAEZ,MAAM,mBAAmB,CAAC;AAG3B,oBAAY,UAAU;IACpB,sCAAsC;IACtC,OAAO,YAAY;IACnB,2BAA2B;IAC3B,aAAa,kBAAkB;IAC/B,kDAAkD;IAClD,UAAU,eAAe;IACzB,0BAA0B;IAC1B,SAAS,cAAc;IACvB,8BAA8B;IAC9B,OAAO,YAAY;IACnB,sBAAsB;IACtB,KAAK,UAAU;IACf,mCAAmC;IACnC,IAAI,SAAS;IACb,wCAAwC;IACxC,oBAAoB,yBAAyB;IAC7C,qCAAqC;IACrC,iBAAiB,sBAAsB;IACvC,kCAAkC;IAClC,IAAI,SAAS;IACb,2BAA2B;IAC3B,UAAU,eAAe;IACzB,mBAAmB;IACnB,UAAU,eAAe;IACzB,qCAAqC;IACrC,wBAAwB,6BAA6B;IACrD,iCAAiC;IACjC,eAAe,oBAAoB;IACnC,4BAA4B;IAC5B,WAAW,gBAAgB;CAC5B;AAED,4CAA4C;AAC5C,MAAM,MAAM,QAAQ,GAAG;IACrB,+HAA+H;IAC/H,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wHAAwH;IACxH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAKF,iDAAiD;AACjD,qBAAa,OAAO,CAAC,CAAC,SAAS,YAAY;;IAyBzC,YAAY,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;IAiB9B;;;;;;;;;;;OAWG;gBACS,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,GAAE,QAAa;IA+CzF;;;;OAIG;IACU,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC;IAO9C,gGAAgG;IACzF,KAAK;IAQZ;;;;;;OAMG;IACH,IAAW,MAAM,IAAI,YAAY,CAEhC;CAgdF"}
|
package/dist/fluent/watch.js
CHANGED
|
@@ -4,7 +4,7 @@ import { EventEmitter } from "events";
|
|
|
4
4
|
import { Readable } from "stream";
|
|
5
5
|
import { fetch } from "undici";
|
|
6
6
|
import { FetchMethods, WatchPhase, } from "./shared-types.js";
|
|
7
|
-
import { getHeaders, k8sCfg, pathBuilder, sleep
|
|
7
|
+
import { getHeaders, k8sCfg, pathBuilder, sleep } from "./utils.js";
|
|
8
8
|
export var WatchEvent;
|
|
9
9
|
(function (WatchEvent) {
|
|
10
10
|
/** Watch is connected successfully */
|
|
@@ -65,6 +65,8 @@ export class Watcher {
|
|
|
65
65
|
#resyncTimer;
|
|
66
66
|
// Track if a reconnect is pending
|
|
67
67
|
#pendingReconnect = false;
|
|
68
|
+
// Track if a list operation is in progress to prevent concurrent lists
|
|
69
|
+
#listInProgress = false;
|
|
68
70
|
// The resource version to start the watch at, this will be updated after the list operation.
|
|
69
71
|
#resourceVersion;
|
|
70
72
|
// Track the list of items in the cache
|
|
@@ -98,7 +100,12 @@ export class Watcher {
|
|
|
98
100
|
this.$relistTimer = setInterval(() => {
|
|
99
101
|
this.#latestRelistWindow = new Date().toISOString();
|
|
100
102
|
this.#events.emit(WatchEvent.INIT_CACHE_MISS, this.#latestRelistWindow);
|
|
101
|
-
void
|
|
103
|
+
void (async () => {
|
|
104
|
+
const success = await this.#list();
|
|
105
|
+
if (!success) {
|
|
106
|
+
this.#lastSeenTime = OVERRIDE;
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
102
109
|
}, watchCfg.relistIntervalSec * 1000 + jitter);
|
|
103
110
|
// Rebuild the watch every resync delay interval
|
|
104
111
|
this.#resyncTimer = setInterval(this.#checkResync, watchCfg.resyncDelaySec * 1000 + jitter);
|
|
@@ -176,9 +183,17 @@ export class Watcher {
|
|
|
176
183
|
* @param continueToken - the continue token for the list
|
|
177
184
|
* @param removedItems - the list of items that have been removed
|
|
178
185
|
* @param retryCount - current retry attempt count
|
|
186
|
+
* @param pageCount - number of continuation pages already fetched (0-based)
|
|
179
187
|
* @returns resolves when list processing is complete
|
|
180
188
|
*/
|
|
181
|
-
#list = async (continueToken, removedItems, retryCount = 0) => {
|
|
189
|
+
#list = async (continueToken, removedItems, retryCount = 0, pageCount = 0) => {
|
|
190
|
+
const isTopLevel = !continueToken && !removedItems && retryCount === 0;
|
|
191
|
+
if (isTopLevel) {
|
|
192
|
+
if (this.#listInProgress) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
this.#listInProgress = true;
|
|
196
|
+
}
|
|
182
197
|
const maxRetries = 5;
|
|
183
198
|
const maxPages = 10;
|
|
184
199
|
try {
|
|
@@ -192,18 +207,16 @@ export class Watcher {
|
|
|
192
207
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
193
208
|
// Retry with exponential backoff if under retry limit to prevent infinite recursion if the server is returning errors
|
|
194
209
|
if (retryCount < maxRetries && retryAfterHeader) {
|
|
195
|
-
const backoffTime = retryAfterHeader
|
|
196
|
-
? parseInt(retryAfterHeader) * 1000
|
|
197
|
-
: Math.min(startSleep * Math.pow(2, retryCount), 30000);
|
|
210
|
+
const backoffTime = parseInt(retryAfterHeader) * 1000;
|
|
198
211
|
await sleep(backoffTime);
|
|
199
212
|
try {
|
|
200
|
-
return this.#list(continueToken, removedItems, retryCount + 1);
|
|
213
|
+
return await this.#list(continueToken, removedItems, retryCount + 1, pageCount);
|
|
201
214
|
}
|
|
202
215
|
catch (e) {
|
|
203
216
|
this.#events.emit(WatchEvent.LIST_ERROR, `retry list failed attempt ${retryCount}: ${e}`);
|
|
204
217
|
}
|
|
205
218
|
}
|
|
206
|
-
return;
|
|
219
|
+
return false;
|
|
207
220
|
}
|
|
208
221
|
// Unconditionally assign the continue token from the response so that it is cleared on the last page
|
|
209
222
|
continueToken = list.metadata.continue;
|
|
@@ -213,6 +226,7 @@ export class Watcher {
|
|
|
213
226
|
this.#resourceVersion = list.metadata?.resourceVersion;
|
|
214
227
|
// If removed items are not provided, clone the cache
|
|
215
228
|
removedItems = removedItems || new Map(this.#cache.entries());
|
|
229
|
+
let anyCallbackFailed = false;
|
|
216
230
|
// Process each item in the list
|
|
217
231
|
for (const item of list.items || []) {
|
|
218
232
|
const { uid } = item.metadata;
|
|
@@ -221,8 +235,15 @@ export class Watcher {
|
|
|
221
235
|
// If the item does not exist, it is new and should be added
|
|
222
236
|
if (!alreadyExists) {
|
|
223
237
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
224
|
-
|
|
225
|
-
|
|
238
|
+
try {
|
|
239
|
+
// Keep lastSeenTime fresh to prevent spurious resync during slow list processing
|
|
240
|
+
this.#lastSeenTime = Date.now();
|
|
241
|
+
await this.#process(item, WatchPhase.Added);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
anyCallbackFailed = true;
|
|
245
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
246
|
+
}
|
|
226
247
|
continue;
|
|
227
248
|
}
|
|
228
249
|
// Check if the resource version has changed for items that already exist
|
|
@@ -231,30 +252,53 @@ export class Watcher {
|
|
|
231
252
|
// Check if the resource version is newer than the cached version
|
|
232
253
|
if (itemRV > cachedRV) {
|
|
233
254
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
try {
|
|
256
|
+
this.#lastSeenTime = Date.now();
|
|
257
|
+
await this.#process(item, WatchPhase.Modified);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
anyCallbackFailed = true;
|
|
261
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
262
|
+
}
|
|
236
263
|
}
|
|
237
264
|
}
|
|
238
265
|
// If there is a continue token, call the list function again with the same removed items
|
|
239
266
|
if (continueToken) {
|
|
240
267
|
// Safeguard against infinite pagination
|
|
241
|
-
if (
|
|
268
|
+
if (pageCount >= maxPages) {
|
|
242
269
|
this.#events.emit(WatchEvent.LIST_ERROR, new Error(`Maximum pagination limit (${maxPages}) reached, stopping list operation`));
|
|
243
|
-
return;
|
|
270
|
+
return false;
|
|
244
271
|
}
|
|
245
|
-
// Continue pagination (not a retry, so reset retryCount to 0)
|
|
246
|
-
|
|
272
|
+
// Continue pagination (not a retry, so reset retryCount to 0).
|
|
273
|
+
// Combine with current page's failure state so a callback failure on
|
|
274
|
+
// an earlier page is not masked by a successful later page.
|
|
275
|
+
const nextPageSucceeded = await this.#list(continueToken, removedItems, 0, pageCount + 1);
|
|
276
|
+
return !anyCallbackFailed && nextPageSucceeded;
|
|
247
277
|
}
|
|
248
278
|
else {
|
|
249
279
|
// Otherwise, process the removed items
|
|
250
280
|
for (const item of removedItems.values()) {
|
|
251
281
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
252
|
-
|
|
282
|
+
try {
|
|
283
|
+
this.#lastSeenTime = Date.now();
|
|
284
|
+
await this.#process(item, WatchPhase.Deleted);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
anyCallbackFailed = true;
|
|
288
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
289
|
+
}
|
|
253
290
|
}
|
|
254
291
|
}
|
|
292
|
+
return !anyCallbackFailed;
|
|
255
293
|
}
|
|
256
294
|
catch (err) {
|
|
257
295
|
this.#events.emit(WatchEvent.LIST_ERROR, err);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
if (isTopLevel) {
|
|
300
|
+
this.#listInProgress = false;
|
|
301
|
+
}
|
|
258
302
|
}
|
|
259
303
|
};
|
|
260
304
|
/**
|
|
@@ -264,26 +308,21 @@ export class Watcher {
|
|
|
264
308
|
* @param phase - the event phase
|
|
265
309
|
*/
|
|
266
310
|
#process = async (payload, phase) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// Emit the data event
|
|
280
|
-
this.#events.emit(WatchEvent.DATA, payload, phase);
|
|
281
|
-
// Call the callback function with the parsed payload
|
|
282
|
-
await this.#callback(payload, phase);
|
|
283
|
-
}
|
|
284
|
-
catch (err) {
|
|
285
|
-
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
311
|
+
// Call the callback function — if it throws, the cache is NOT updated
|
|
312
|
+
// so the next relist will detect the item as unprocessed and retry
|
|
313
|
+
await this.#callback(payload, phase);
|
|
314
|
+
// Only update cache after the callback succeeds
|
|
315
|
+
switch (phase) {
|
|
316
|
+
case WatchPhase.Added:
|
|
317
|
+
case WatchPhase.Modified:
|
|
318
|
+
this.#cache.set(payload.metadata.uid, payload);
|
|
319
|
+
break;
|
|
320
|
+
case WatchPhase.Deleted:
|
|
321
|
+
this.#cache.delete(payload.metadata.uid);
|
|
322
|
+
break;
|
|
286
323
|
}
|
|
324
|
+
// Emit data event only after callback succeeds and cache is updated
|
|
325
|
+
this.#events.emit(WatchEvent.DATA, payload, phase);
|
|
287
326
|
};
|
|
288
327
|
// process a line from the chunk
|
|
289
328
|
#processLine = async (line, process) => {
|
|
@@ -299,7 +338,9 @@ export class Watcher {
|
|
|
299
338
|
message: this.#resourceVersion,
|
|
300
339
|
};
|
|
301
340
|
}
|
|
302
|
-
// Process the event payload, do not update the resource version as that is handled by the list operation
|
|
341
|
+
// Process the event payload, do not update the resource version as that is handled by the list operation.
|
|
342
|
+
// Watch-stream events are not retried on callback failure — the relist timer
|
|
343
|
+
// will re-detect unprocessed items on its next cycle.
|
|
303
344
|
await process(payload, phase);
|
|
304
345
|
}
|
|
305
346
|
catch (err) {
|
|
@@ -318,13 +359,18 @@ export class Watcher {
|
|
|
318
359
|
*/
|
|
319
360
|
#watch = async (retryCount = 0) => {
|
|
320
361
|
const maxRetries = 5;
|
|
321
|
-
// Start with a list operation
|
|
362
|
+
// Start with a list operation
|
|
363
|
+
let listSucceeded = false;
|
|
322
364
|
try {
|
|
323
|
-
await this.#list();
|
|
365
|
+
listSucceeded = await this.#list();
|
|
324
366
|
}
|
|
325
367
|
catch (listError) {
|
|
326
368
|
this.#events.emit(WatchEvent.LIST_ERROR, listError);
|
|
327
369
|
}
|
|
370
|
+
// If the list failed, trigger a faster resync so items aren't permanently missed
|
|
371
|
+
if (!listSucceeded) {
|
|
372
|
+
this.#lastSeenTime = OVERRIDE;
|
|
373
|
+
}
|
|
328
374
|
// Build the URL and request options
|
|
329
375
|
try {
|
|
330
376
|
const { opts, serverUrl } = await this.#buildURL(true, this.#resourceVersion);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kubernetes-fluent-client",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.7",
|
|
4
4
|
"description": "A @kubernetes/client-node fluent API wrapper that leverages K8s Server Side Apply.",
|
|
5
5
|
"bin": "./dist/cli.js",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"node-fetch": "2.7.0",
|
|
57
57
|
"quicktype-core": "23.2.6",
|
|
58
58
|
"tsx": "4.21.0",
|
|
59
|
-
"undici": "7.24.
|
|
59
|
+
"undici": "7.24.6",
|
|
60
60
|
"yargs": "18.0.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@commitlint/cli": "20.
|
|
64
|
-
"@commitlint/config-conventional": "20.
|
|
63
|
+
"@commitlint/cli": "20.5.0",
|
|
64
|
+
"@commitlint/config-conventional": "20.5.0",
|
|
65
65
|
"@defenseunicorns/eslint-config": "1.2.0",
|
|
66
66
|
"@eslint/eslintrc": "3.3.5",
|
|
67
67
|
"@eslint/js": "10.0.1",
|
|
@@ -70,22 +70,23 @@
|
|
|
70
70
|
"@types/urijs": "1.19.26",
|
|
71
71
|
"@types/ws": "8.18.1",
|
|
72
72
|
"@types/yargs": "17.0.35",
|
|
73
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
74
|
-
"@typescript-eslint/parser": "8.
|
|
75
|
-
"@vitest/coverage-v8": "4.
|
|
76
|
-
"command-line-args": "6.0.
|
|
77
|
-
"eslint": "10.0
|
|
78
|
-
"eslint-plugin-jsdoc": "62.
|
|
79
|
-
"globals": "17.
|
|
73
|
+
"@typescript-eslint/eslint-plugin": "8.58.2",
|
|
74
|
+
"@typescript-eslint/parser": "8.58.2",
|
|
75
|
+
"@vitest/coverage-v8": "4.1.4",
|
|
76
|
+
"command-line-args": "6.0.2",
|
|
77
|
+
"eslint": "10.2.0",
|
|
78
|
+
"eslint-plugin-jsdoc": "62.9.0",
|
|
79
|
+
"globals": "17.5.0",
|
|
80
80
|
"husky": "9.1.7",
|
|
81
|
-
"lint-staged": "16.
|
|
82
|
-
"prettier": "3.8.
|
|
81
|
+
"lint-staged": "16.4.0",
|
|
82
|
+
"prettier": "3.8.3",
|
|
83
83
|
"semantic-release": "25.0.3",
|
|
84
|
-
"typescript": "
|
|
85
|
-
"vitest": "4.
|
|
84
|
+
"typescript": "6.0.2",
|
|
85
|
+
"vitest": "4.1.4"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|
|
88
88
|
"tar-fs": "3.1.1",
|
|
89
|
+
"typescript-eslint": "8.58.0",
|
|
89
90
|
"semantic-release@24.2.0": {
|
|
90
91
|
"npm": {
|
|
91
92
|
"glob": {
|
package/src/fetch.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
|
|
3
3
|
|
|
4
4
|
import { StatusCodes } from "http-status-codes";
|
|
5
|
-
import { fetch as undiciFetch, RequestInfo, RequestInit } from "undici";
|
|
5
|
+
import { fetch as undiciFetch, Headers, RequestInfo, RequestInit } from "undici";
|
|
6
6
|
|
|
7
7
|
export type FetchResponse<T> = {
|
|
8
8
|
data: T;
|
|
@@ -57,7 +57,7 @@ export async function fetch<T>(
|
|
|
57
57
|
data = (await resp.text()) as unknown as T;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
return { data, ok, status, statusText, headers };
|
|
60
|
+
return { data, ok, status, statusText, headers: headers! };
|
|
61
61
|
} catch (e) {
|
|
62
62
|
// Always treat a catch as a failure for callers — even when the HTTP
|
|
63
63
|
// transport returned 2xx, a body-parse error means `data` is unusable.
|
package/src/fluent/watch.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
WatchAction,
|
|
13
13
|
WatchPhase,
|
|
14
14
|
} from "./shared-types.js";
|
|
15
|
-
import { getHeaders, k8sCfg, pathBuilder, sleep
|
|
15
|
+
import { getHeaders, k8sCfg, pathBuilder, sleep } from "./utils.js";
|
|
16
16
|
|
|
17
17
|
export enum WatchEvent {
|
|
18
18
|
/** Watch is connected successfully */
|
|
@@ -96,6 +96,9 @@ export class Watcher<T extends GenericClass> {
|
|
|
96
96
|
// Track if a reconnect is pending
|
|
97
97
|
#pendingReconnect = false;
|
|
98
98
|
|
|
99
|
+
// Track if a list operation is in progress to prevent concurrent lists
|
|
100
|
+
#listInProgress = false;
|
|
101
|
+
|
|
99
102
|
// The resource version to start the watch at, this will be updated after the list operation.
|
|
100
103
|
#resourceVersion?: string;
|
|
101
104
|
|
|
@@ -138,7 +141,12 @@ export class Watcher<T extends GenericClass> {
|
|
|
138
141
|
() => {
|
|
139
142
|
this.#latestRelistWindow = new Date().toISOString();
|
|
140
143
|
this.#events.emit(WatchEvent.INIT_CACHE_MISS, this.#latestRelistWindow);
|
|
141
|
-
void
|
|
144
|
+
void (async () => {
|
|
145
|
+
const success = await this.#list();
|
|
146
|
+
if (!success) {
|
|
147
|
+
this.#lastSeenTime = OVERRIDE;
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
142
150
|
},
|
|
143
151
|
watchCfg.relistIntervalSec * 1000 + jitter,
|
|
144
152
|
);
|
|
@@ -238,13 +246,23 @@ export class Watcher<T extends GenericClass> {
|
|
|
238
246
|
* @param continueToken - the continue token for the list
|
|
239
247
|
* @param removedItems - the list of items that have been removed
|
|
240
248
|
* @param retryCount - current retry attempt count
|
|
249
|
+
* @param pageCount - number of continuation pages already fetched (0-based)
|
|
241
250
|
* @returns resolves when list processing is complete
|
|
242
251
|
*/
|
|
243
252
|
#list = async (
|
|
244
253
|
continueToken?: string,
|
|
245
254
|
removedItems?: Map<string, InstanceType<T>>,
|
|
246
255
|
retryCount = 0,
|
|
247
|
-
|
|
256
|
+
pageCount = 0,
|
|
257
|
+
): Promise<boolean> => {
|
|
258
|
+
const isTopLevel = !continueToken && !removedItems && retryCount === 0;
|
|
259
|
+
if (isTopLevel) {
|
|
260
|
+
if (this.#listInProgress) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
this.#listInProgress = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
248
266
|
const maxRetries = 5;
|
|
249
267
|
const maxPages = 10;
|
|
250
268
|
|
|
@@ -266,13 +284,11 @@ export class Watcher<T extends GenericClass> {
|
|
|
266
284
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
267
285
|
// Retry with exponential backoff if under retry limit to prevent infinite recursion if the server is returning errors
|
|
268
286
|
if (retryCount < maxRetries && retryAfterHeader) {
|
|
269
|
-
const backoffTime = retryAfterHeader
|
|
270
|
-
? parseInt(retryAfterHeader) * 1000
|
|
271
|
-
: Math.min(startSleep * Math.pow(2, retryCount), 30000);
|
|
287
|
+
const backoffTime = parseInt(retryAfterHeader) * 1000;
|
|
272
288
|
|
|
273
289
|
await sleep(backoffTime);
|
|
274
290
|
try {
|
|
275
|
-
return this.#list(continueToken, removedItems, retryCount + 1);
|
|
291
|
+
return await this.#list(continueToken, removedItems, retryCount + 1, pageCount);
|
|
276
292
|
} catch (e) {
|
|
277
293
|
this.#events.emit(
|
|
278
294
|
WatchEvent.LIST_ERROR,
|
|
@@ -281,7 +297,7 @@ export class Watcher<T extends GenericClass> {
|
|
|
281
297
|
}
|
|
282
298
|
}
|
|
283
299
|
|
|
284
|
-
return;
|
|
300
|
+
return false;
|
|
285
301
|
}
|
|
286
302
|
|
|
287
303
|
// Unconditionally assign the continue token from the response so that it is cleared on the last page
|
|
@@ -296,6 +312,8 @@ export class Watcher<T extends GenericClass> {
|
|
|
296
312
|
// If removed items are not provided, clone the cache
|
|
297
313
|
removedItems = removedItems || new Map(this.#cache.entries());
|
|
298
314
|
|
|
315
|
+
let anyCallbackFailed = false;
|
|
316
|
+
|
|
299
317
|
// Process each item in the list
|
|
300
318
|
for (const item of list.items || []) {
|
|
301
319
|
const { uid } = item.metadata;
|
|
@@ -306,8 +324,14 @@ export class Watcher<T extends GenericClass> {
|
|
|
306
324
|
// If the item does not exist, it is new and should be added
|
|
307
325
|
if (!alreadyExists) {
|
|
308
326
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
try {
|
|
328
|
+
// Keep lastSeenTime fresh to prevent spurious resync during slow list processing
|
|
329
|
+
this.#lastSeenTime = Date.now();
|
|
330
|
+
await this.#process(item, WatchPhase.Added);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
anyCallbackFailed = true;
|
|
333
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
334
|
+
}
|
|
311
335
|
continue;
|
|
312
336
|
}
|
|
313
337
|
|
|
@@ -318,33 +342,54 @@ export class Watcher<T extends GenericClass> {
|
|
|
318
342
|
// Check if the resource version is newer than the cached version
|
|
319
343
|
if (itemRV > cachedRV) {
|
|
320
344
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
321
|
-
|
|
322
|
-
|
|
345
|
+
try {
|
|
346
|
+
this.#lastSeenTime = Date.now();
|
|
347
|
+
await this.#process(item, WatchPhase.Modified);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
anyCallbackFailed = true;
|
|
350
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
351
|
+
}
|
|
323
352
|
}
|
|
324
353
|
}
|
|
325
354
|
|
|
326
355
|
// If there is a continue token, call the list function again with the same removed items
|
|
327
356
|
if (continueToken) {
|
|
328
357
|
// Safeguard against infinite pagination
|
|
329
|
-
if (
|
|
358
|
+
if (pageCount >= maxPages) {
|
|
330
359
|
this.#events.emit(
|
|
331
360
|
WatchEvent.LIST_ERROR,
|
|
332
361
|
new Error(`Maximum pagination limit (${maxPages}) reached, stopping list operation`),
|
|
333
362
|
);
|
|
334
|
-
return;
|
|
363
|
+
return false;
|
|
335
364
|
}
|
|
336
365
|
|
|
337
|
-
// Continue pagination (not a retry, so reset retryCount to 0)
|
|
338
|
-
|
|
366
|
+
// Continue pagination (not a retry, so reset retryCount to 0).
|
|
367
|
+
// Combine with current page's failure state so a callback failure on
|
|
368
|
+
// an earlier page is not masked by a successful later page.
|
|
369
|
+
const nextPageSucceeded = await this.#list(continueToken, removedItems, 0, pageCount + 1);
|
|
370
|
+
return !anyCallbackFailed && nextPageSucceeded;
|
|
339
371
|
} else {
|
|
340
372
|
// Otherwise, process the removed items
|
|
341
373
|
for (const item of removedItems.values()) {
|
|
342
374
|
this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
|
|
343
|
-
|
|
375
|
+
try {
|
|
376
|
+
this.#lastSeenTime = Date.now();
|
|
377
|
+
await this.#process(item, WatchPhase.Deleted);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
anyCallbackFailed = true;
|
|
380
|
+
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
381
|
+
}
|
|
344
382
|
}
|
|
345
383
|
}
|
|
384
|
+
|
|
385
|
+
return !anyCallbackFailed;
|
|
346
386
|
} catch (err) {
|
|
347
387
|
this.#events.emit(WatchEvent.LIST_ERROR, err);
|
|
388
|
+
return false;
|
|
389
|
+
} finally {
|
|
390
|
+
if (isTopLevel) {
|
|
391
|
+
this.#listInProgress = false;
|
|
392
|
+
}
|
|
348
393
|
}
|
|
349
394
|
};
|
|
350
395
|
|
|
@@ -355,28 +400,24 @@ export class Watcher<T extends GenericClass> {
|
|
|
355
400
|
* @param phase - the event phase
|
|
356
401
|
*/
|
|
357
402
|
#process = async (payload: InstanceType<T>, phase: WatchPhase) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
this.#cache.delete(payload.metadata.uid);
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Emit the data event
|
|
373
|
-
this.#events.emit(WatchEvent.DATA, payload, phase);
|
|
403
|
+
// Call the callback function — if it throws, the cache is NOT updated
|
|
404
|
+
// so the next relist will detect the item as unprocessed and retry
|
|
405
|
+
await this.#callback(payload, phase);
|
|
406
|
+
|
|
407
|
+
// Only update cache after the callback succeeds
|
|
408
|
+
switch (phase) {
|
|
409
|
+
case WatchPhase.Added:
|
|
410
|
+
case WatchPhase.Modified:
|
|
411
|
+
this.#cache.set(payload.metadata.uid, payload);
|
|
412
|
+
break;
|
|
374
413
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
this.#events.emit(WatchEvent.DATA_ERROR, err);
|
|
414
|
+
case WatchPhase.Deleted:
|
|
415
|
+
this.#cache.delete(payload.metadata.uid);
|
|
416
|
+
break;
|
|
379
417
|
}
|
|
418
|
+
|
|
419
|
+
// Emit data event only after callback succeeds and cache is updated
|
|
420
|
+
this.#events.emit(WatchEvent.DATA, payload, phase);
|
|
380
421
|
};
|
|
381
422
|
|
|
382
423
|
// process a line from the chunk
|
|
@@ -402,7 +443,9 @@ export class Watcher<T extends GenericClass> {
|
|
|
402
443
|
};
|
|
403
444
|
}
|
|
404
445
|
|
|
405
|
-
// Process the event payload, do not update the resource version as that is handled by the list operation
|
|
446
|
+
// Process the event payload, do not update the resource version as that is handled by the list operation.
|
|
447
|
+
// Watch-stream events are not retried on callback failure — the relist timer
|
|
448
|
+
// will re-detect unprocessed items on its next cycle.
|
|
406
449
|
await process(payload, phase);
|
|
407
450
|
} catch (err) {
|
|
408
451
|
if (err.name === "TooOld") {
|
|
@@ -422,13 +465,19 @@ export class Watcher<T extends GenericClass> {
|
|
|
422
465
|
*/
|
|
423
466
|
#watch = async (retryCount = 0): Promise<void> => {
|
|
424
467
|
const maxRetries = 5;
|
|
425
|
-
// Start with a list operation
|
|
468
|
+
// Start with a list operation
|
|
469
|
+
let listSucceeded = false;
|
|
426
470
|
try {
|
|
427
|
-
await this.#list();
|
|
471
|
+
listSucceeded = await this.#list();
|
|
428
472
|
} catch (listError) {
|
|
429
473
|
this.#events.emit(WatchEvent.LIST_ERROR, listError);
|
|
430
474
|
}
|
|
431
475
|
|
|
476
|
+
// If the list failed, trigger a faster resync so items aren't permanently missed
|
|
477
|
+
if (!listSucceeded) {
|
|
478
|
+
this.#lastSeenTime = OVERRIDE;
|
|
479
|
+
}
|
|
480
|
+
|
|
432
481
|
// Build the URL and request options
|
|
433
482
|
try {
|
|
434
483
|
const { opts, serverUrl } = await this.#buildURL(true, this.#resourceVersion);
|