@goodandready/dsh-key-rotation 0.8.68 → 0.8.70
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/CHANGELOG.md +28 -0
- package/lib/clone-lifecycle.js +216 -0
- package/lib/index.js +7 -0
- package/lib/key-drain.js +6 -4
- package/lib/ops-config-io.js +9 -2
- package/lib/pool-builder.js +7 -0
- package/lib/pool-selection.js +149 -0
- package/lib/pool.js +13 -145
- package/lib/rotate.js +595 -716
- package/lib/stream-metrics.js +133 -0
- package/package.json +1 -1
package/lib/pool.js
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
pool.state.sessionSticky.set(sessionId, candidate);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getSessionSticky(pool, sessionId) {
|
|
18
|
-
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
19
|
-
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function clearSessionSticky(pool, sessionId) {
|
|
23
|
-
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
24
|
-
pool.state.sessionSticky.delete(sessionId);
|
|
25
|
-
}
|
|
1
|
+
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
2
|
+
import {
|
|
3
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
4
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
5
|
+
costForDay, costForWeek, budgetVerdict,
|
|
6
|
+
} from './pool-selection.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
10
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
11
|
+
costForDay, costForWeek, budgetVerdict,
|
|
12
|
+
};
|
|
26
13
|
|
|
27
14
|
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
28
15
|
import { decryptSecret } from './crypto-storage.js';
|
|
@@ -239,6 +226,7 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null, quo
|
|
|
239
226
|
let cleared = 0;
|
|
240
227
|
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
241
228
|
for (const st of poolState.values()) {
|
|
229
|
+
if (!st || !(st.failedUntil instanceof Map)) continue;
|
|
242
230
|
// #338: decay penalty failCounts for stable keys in maintenance sweep
|
|
243
231
|
decayPenalties({ state: st }, now);
|
|
244
232
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
@@ -288,19 +276,6 @@ export function parseRetryAfter(value) {
|
|
|
288
276
|
}
|
|
289
277
|
|
|
290
278
|
/** Pick a key pool for a (provider, model) pair. */
|
|
291
|
-
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
292
|
-
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
293
|
-
const base = providerToPool && providerToPool.get(provider);
|
|
294
|
-
if (!byModel || !model) return base ?? null;
|
|
295
|
-
if (byModel.has(model)) return byModel.get(model);
|
|
296
|
-
let best = null;
|
|
297
|
-
for (const key of byModel.keys()) {
|
|
298
|
-
if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
|
|
299
|
-
}
|
|
300
|
-
return (best ? byModel.get(best) : base) ?? null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
|
|
304
279
|
export function parseExpiry(v) {
|
|
305
280
|
if (typeof v === 'number' && v > 0) return v;
|
|
306
281
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
@@ -434,61 +409,6 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
434
409
|
* - 'least-loaded': keys with lowest active concurrency count first
|
|
435
410
|
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
436
411
|
*/
|
|
437
|
-
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
438
|
-
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
439
|
-
const list = refs.slice();
|
|
440
|
-
if (strategy === 'sticky') {
|
|
441
|
-
const last = deps.sessionSticky ||
|
|
442
|
-
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
443
|
-
deps.lastUsed ||
|
|
444
|
-
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
445
|
-
if (last && list.includes(last)) {
|
|
446
|
-
const idx = list.indexOf(last);
|
|
447
|
-
if (idx > 0) {
|
|
448
|
-
list.splice(idx, 1);
|
|
449
|
-
list.unshift(last);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return list;
|
|
453
|
-
}
|
|
454
|
-
if (strategy === 'tier-priority') {
|
|
455
|
-
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
456
|
-
return list.sort((a, b) => {
|
|
457
|
-
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
458
|
-
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
459
|
-
return ta - tb;
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
463
|
-
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
464
|
-
return list.sort((a, b) => {
|
|
465
|
-
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
466
|
-
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
467
|
-
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
468
|
-
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
469
|
-
const scoreA = ca / wa;
|
|
470
|
-
const scoreB = cb / wb;
|
|
471
|
-
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
472
|
-
return ca - cb;
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
476
|
-
return list.sort((a, b) => {
|
|
477
|
-
const sa = deps.latencyHistogram.snapshot(a);
|
|
478
|
-
const sb = deps.latencyHistogram.snapshot(b);
|
|
479
|
-
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
480
|
-
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
481
|
-
return la - lb;
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return list;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Unified check whether an error or payload represents a switchable failure.
|
|
489
|
-
* Prioritizes explicit HTTP status codes and gRPC codes before text matching.
|
|
490
|
-
*/
|
|
491
|
-
/** Raw socket-level codes the taxonomy maps to TRANSPORT (see classifyFailure). */
|
|
492
412
|
const SOCKET_TRANSPORT_CODES = new Set([
|
|
493
413
|
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
|
|
494
414
|
'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
|
|
@@ -565,58 +485,6 @@ export function formatExhaustionMessage(provider, pool, now = Date.now()) {
|
|
|
565
485
|
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
566
486
|
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
567
487
|
*/
|
|
568
|
-
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
569
|
-
if (!pool || !pool.expiresAt) return [];
|
|
570
|
-
const DAY_MS = 86400000;
|
|
571
|
-
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
572
|
-
return Object.entries(pool.expiresAt)
|
|
573
|
-
.filter(([, at]) => at > now && at <= horizon)
|
|
574
|
-
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
575
|
-
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** Dedupe: true when a day-level notification for key is due. */
|
|
579
|
-
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
580
|
-
const DAY_MS = 86400000;
|
|
581
|
-
if (!lastNotified.has(key)) {
|
|
582
|
-
lastNotified.set(key, now);
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
const last = lastNotified.get(key);
|
|
586
|
-
if (now - last < DAY_MS) return false;
|
|
587
|
-
lastNotified.set(key, now);
|
|
588
|
-
return true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
592
|
-
export function costForDay(costDays, day) {
|
|
593
|
-
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
594
|
-
let total = 0;
|
|
595
|
-
for (const perRef of (costDays?.values() ?? [])) {
|
|
596
|
-
total += perRef.get(d) ?? 0;
|
|
597
|
-
}
|
|
598
|
-
return total;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** Total spend over the last 7 ISO days ending today. */
|
|
602
|
-
export function costForWeek(costDays, now = Date.now()) {
|
|
603
|
-
let total = 0;
|
|
604
|
-
for (let i = 0; i < 7; i++) {
|
|
605
|
-
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
606
|
-
total += costForDay(costDays, d);
|
|
607
|
-
}
|
|
608
|
-
return total;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
612
|
-
export function budgetVerdict(spend, budget) {
|
|
613
|
-
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
614
|
-
const ratio = spend / budget;
|
|
615
|
-
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
/** Safely reset a provider's circuit breaker to closed state. */
|
|
620
488
|
export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
621
489
|
if (!circuitBreaker || !provider) return false;
|
|
622
490
|
const ok = bestEffort('resetCircuitForProvider', () => {
|