gh-glance 0.12.0 → 0.13.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.
- package/CHANGELOG.md +78 -1
- package/index.mjs +583 -183
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -2098,6 +2098,7 @@ function scheduleIntents({
|
|
|
2098
2098
|
cursors = {},
|
|
2099
2099
|
nowMs,
|
|
2100
2100
|
maxGrants = Number.POSITIVE_INFINITY,
|
|
2101
|
+
manualStreak = 0,
|
|
2101
2102
|
}) {
|
|
2102
2103
|
const valid = [];
|
|
2103
2104
|
const prunedIntentIds = [];
|
|
@@ -2138,11 +2139,29 @@ function scheduleIntents({
|
|
|
2138
2139
|
const liveLeaseCount = countLiveLeases(leases, nowMs);
|
|
2139
2140
|
const priorities = [...new Set(valid.map((intent) => intent.normalizedPriority))].sort((a, b) => a - b);
|
|
2140
2141
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2142
|
+
let streak = Number.isSafeInteger(manualStreak) && manualStreak > 0 ? manualStreak : 0;
|
|
2143
|
+
const grantedIds = new Set();
|
|
2144
|
+
// Priority still decides every other question. This says only that "higher
|
|
2145
|
+
// priority" cannot mean "always": once manual work has taken the last three
|
|
2146
|
+
// turns and an active owner is waiting, that owner takes this one. The turn
|
|
2147
|
+
// is a single grant, and it is subject to exactly the same budget, phase and
|
|
2148
|
+
// lane checks as any other -- fairness reorders work, it never admits work
|
|
2149
|
+
// that could not otherwise be paid for.
|
|
2150
|
+
const owedActiveTurn = streak >= MANUAL_GRANT_STREAK_LIMIT &&
|
|
2151
|
+
valid.some((intent) => intent.normalizedPriority === REQUEST_PRIORITIES.active);
|
|
2152
|
+
const passes = owedActiveTurn
|
|
2153
|
+
? [{ priority: REQUEST_PRIORITIES.active, limit: 1, record: false },
|
|
2154
|
+
...priorities.map((priority) => ({ priority, limit: Number.POSITIVE_INFINITY, record: true }))]
|
|
2155
|
+
: priorities.map((priority) => ({ priority, limit: Number.POSITIVE_INFINITY, record: true }));
|
|
2156
|
+
|
|
2157
|
+
for (const pass of passes) {
|
|
2158
|
+
const priority = pass.priority;
|
|
2159
|
+
const pending = valid.filter((intent) =>
|
|
2160
|
+
intent.normalizedPriority === priority && !grantedIds.has(intent.id));
|
|
2143
2161
|
const roundRobinState = createRoundRobinState(pending, updatedCursors);
|
|
2162
|
+
let grantedThisPass = 0;
|
|
2144
2163
|
while (pending.length > 0) {
|
|
2145
|
-
if (grants.length >= maxGrants) break;
|
|
2164
|
+
if (grants.length >= maxGrants || grantedThisPass >= pass.limit) break;
|
|
2146
2165
|
const intent = pending.splice(nextRoundRobinIndex(pending, roundRobinState), 1)[0];
|
|
2147
2166
|
const resources = RATE_RESOURCES.filter((resource) => intent.costs[resource] > 0);
|
|
2148
2167
|
const decisions = Object.fromEntries(resources.map((resource) => [
|
|
@@ -2158,7 +2177,10 @@ function scheduleIntents({
|
|
|
2158
2177
|
]));
|
|
2159
2178
|
const blocked = resources.find((resource) => decisions[resource].mode !== "open");
|
|
2160
2179
|
if (blocked) {
|
|
2161
|
-
|
|
2180
|
+
// Only the ordinary pass records a denial. The owed-turn pass looks at
|
|
2181
|
+
// the same intents again immediately afterwards, and recording here
|
|
2182
|
+
// would report every one of them twice.
|
|
2183
|
+
if (pass.record) denied.push({ intentId: intent.id, resource: blocked, ...decisions[blocked] });
|
|
2162
2184
|
continue;
|
|
2163
2185
|
}
|
|
2164
2186
|
|
|
@@ -2173,6 +2195,7 @@ function scheduleIntents({
|
|
|
2173
2195
|
);
|
|
2174
2196
|
const expiring = resources.find((resource) => notBefore >= decisions[resource].resetMs);
|
|
2175
2197
|
if (expiring) {
|
|
2198
|
+
if (!pass.record) continue;
|
|
2176
2199
|
denied.push({
|
|
2177
2200
|
intentId: intent.id,
|
|
2178
2201
|
resource: expiring,
|
|
@@ -2207,6 +2230,12 @@ function scheduleIntents({
|
|
|
2207
2230
|
reservation.sharingOwnerLeaseIds = sharingOwnerLeaseIds;
|
|
2208
2231
|
}
|
|
2209
2232
|
grants.push(reservation);
|
|
2233
|
+
grantedIds.add(intent.id);
|
|
2234
|
+
grantedThisPass += 1;
|
|
2235
|
+
// A manual grant extends the run; anything else ends it. The counter has
|
|
2236
|
+
// to persist, because the run this bounds is one manual intent per
|
|
2237
|
+
// planning pass rather than several within one.
|
|
2238
|
+
streak = priority === REQUEST_PRIORITIES.manual ? streak + 1 : 0;
|
|
2210
2239
|
for (const resource of resources) {
|
|
2211
2240
|
chargedTotals[resource] += intent.costs[resource];
|
|
2212
2241
|
updatedLanes[resource] = {
|
|
@@ -2225,6 +2254,7 @@ function scheduleIntents({
|
|
|
2225
2254
|
cursors: updatedCursors,
|
|
2226
2255
|
denied,
|
|
2227
2256
|
prunedIntentIds,
|
|
2257
|
+
manualStreak: streak,
|
|
2228
2258
|
};
|
|
2229
2259
|
}
|
|
2230
2260
|
|
|
@@ -2373,10 +2403,16 @@ function normalizeIdentityRegistry(raw) {
|
|
|
2373
2403
|
if (!isRecord(transport)) return null;
|
|
2374
2404
|
const keys = ["lastStartedAt", "cooldownUntil", "permit"];
|
|
2375
2405
|
if (Object.hasOwn(transport, "waiters")) keys.push("waiters");
|
|
2406
|
+
if (Object.hasOwn(transport, "throttle")) keys.push("throttle");
|
|
2376
2407
|
if (!normalizeHost(host) || !exactKeys(transport, keys) ||
|
|
2377
2408
|
!time(transport.lastStartedAt) || !time(transport.cooldownUntil)) return null;
|
|
2378
2409
|
if (transport.permit !== null && (!exactKeys(transport.permit, ["pid", "nonce", "startedAt"]) ||
|
|
2379
2410
|
!Number.isSafeInteger(transport.permit.pid) || transport.permit.pid < 1 || !validGovernorId(transport.permit.nonce) || !time(transport.permit.startedAt))) return null;
|
|
2411
|
+
if (!Object.hasOwn(transport, "throttle")) transport.throttle = emptyTransportThrottle();
|
|
2412
|
+
if (!isRecord(transport.throttle) || !exactKeys(transport.throttle, ["attempts", "lastAt", "paused"]) ||
|
|
2413
|
+
!Number.isSafeInteger(transport.throttle.attempts) || transport.throttle.attempts < 0 ||
|
|
2414
|
+
transport.throttle.attempts > THROTTLE_PAUSE_AFTER ||
|
|
2415
|
+
!time(transport.throttle.lastAt) || typeof transport.throttle.paused !== "boolean") return null;
|
|
2380
2416
|
if (!Object.hasOwn(transport, "waiters")) transport.waiters = [];
|
|
2381
2417
|
if (!Array.isArray(transport.waiters) || transport.waiters.length > HTTP_MAX_WAITERS ||
|
|
2382
2418
|
new Set(transport.waiters.map((waiter) => waiter?.nonce)).size !== transport.waiters.length ||
|
|
@@ -2474,7 +2510,7 @@ function inspectLegacyMigration(root, state, now) {
|
|
|
2474
2510
|
// binary fail closed on its own gate, but this side must still recognise
|
|
2475
2511
|
// that binary's file well enough to see it holds a live lease.
|
|
2476
2512
|
const legacy = LEGACY_GOVERNOR_VERSIONS
|
|
2477
|
-
.map((version) =>
|
|
2513
|
+
.map((version) => readLegacyGovernorState(raw, now, version))
|
|
2478
2514
|
.find(Boolean) ?? migrateGovernorState(raw, now);
|
|
2479
2515
|
if (legacy && raw.version === 1 && raw.budgets?.core) {
|
|
2480
2516
|
const core = raw.budgets.core;
|
|
@@ -2840,7 +2876,13 @@ async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now }
|
|
|
2840
2876
|
const migration = inspectLegacyMigration(coordinator.root, state, now());
|
|
2841
2877
|
if (!migration.ok) return migration;
|
|
2842
2878
|
if (now() >= deadline) return { ok: false, reason: "transport-busy", retryAt: deadline };
|
|
2843
|
-
const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [] };
|
|
2879
|
+
const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [], throttle: emptyTransportThrottle() };
|
|
2880
|
+
// Five consecutive throttles means the ladder has stopped being useful.
|
|
2881
|
+
// Waiting out the deadline is what produced the last one, so the pause
|
|
2882
|
+
// holds until a person retries or a primary reset opens a recovery.
|
|
2883
|
+
if (transport.throttle?.paused) {
|
|
2884
|
+
return { ok: false, reason: "throttle-paused", retryAt: transport.cooldownUntil };
|
|
2885
|
+
}
|
|
2844
2886
|
if (transport.permit && staleHttpPermit(transport.permit, now())) transport.permit = null;
|
|
2845
2887
|
// Admission can happen during the mandatory start gap. Persist call
|
|
2846
2888
|
// order once so later polling cannot overtake an earlier caller.
|
|
@@ -2886,17 +2928,92 @@ async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now }
|
|
|
2886
2928
|
// stops applying to the other.
|
|
2887
2929
|
const SECONDARY_LIMIT_PATTERN = /secondary rate|abuse/i;
|
|
2888
2930
|
|
|
2889
|
-
|
|
2890
|
-
|
|
2931
|
+
// The locally chosen delay climbs by consecutive throttle and stops climbing at
|
|
2932
|
+
// fifteen minutes. The cap bounds only what this client invents for itself: a
|
|
2933
|
+
// server that asks for two hours is given two hours, because it knows something
|
|
2934
|
+
// we do not.
|
|
2935
|
+
const THROTTLE_LADDER_MS = [60_000, 120_000, 240_000, 480_000, 900_000];
|
|
2936
|
+
// After this many consecutive throttles the transport stops choosing new delays
|
|
2937
|
+
// and waits for a person or a primary reset. Climbing forever keeps a wedged
|
|
2938
|
+
// credential politely hammering a limit it has no way to satisfy.
|
|
2939
|
+
const THROTTLE_PAUSE_AFTER = 5;
|
|
2940
|
+
|
|
2941
|
+
function throttleLadderMs(priorThrottles) {
|
|
2942
|
+
const step = Number.isSafeInteger(priorThrottles) && priorThrottles > 0 ? priorThrottles : 0;
|
|
2943
|
+
return THROTTLE_LADDER_MS[Math.min(step, THROTTLE_LADDER_MS.length - 1)];
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Reads throttle evidence without inventing any. The case this exists to get
|
|
2947
|
+
// right is the permission-only 403: it carries the same status as an exhausted
|
|
2948
|
+
// primary limit and is not a throttle at all, so treating it as one pauses a
|
|
2949
|
+
// pane that was never limited -- it was just not allowed.
|
|
2950
|
+
function classifyThrottle({ status = null, headers = null, graphqlErrors = null, stderr = "", body = null } = {}) {
|
|
2951
|
+
const header = (name) => {
|
|
2952
|
+
const value = headers?.[name];
|
|
2953
|
+
return typeof value === "string" ? value.trim() : null;
|
|
2954
|
+
};
|
|
2955
|
+
const integer = (name) => {
|
|
2956
|
+
const value = Number(header(name));
|
|
2957
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
2958
|
+
};
|
|
2959
|
+
const secondary = SECONDARY_LIMIT_PATTERN.test([
|
|
2960
|
+
String(body?.message ?? ""),
|
|
2961
|
+
String(stderr ?? ""),
|
|
2962
|
+
...(Array.isArray(graphqlErrors)
|
|
2963
|
+
? graphqlErrors.map((error) => `${error?.type ?? ""} ${error?.message ?? ""}`)
|
|
2964
|
+
: []),
|
|
2965
|
+
].join("\n"));
|
|
2966
|
+
const retryAfter = header("retry-after");
|
|
2967
|
+
const remaining = integer("x-ratelimit-remaining");
|
|
2968
|
+
const reset = integer("x-ratelimit-reset");
|
|
2969
|
+
const resource = header("x-ratelimit-resource")?.toLowerCase() ?? null;
|
|
2970
|
+
// The counter itself says there is nothing left. That holds its own resource
|
|
2971
|
+
// until its own reset, and says nothing about the other one.
|
|
2972
|
+
if (!secondary && remaining === 0 && reset !== null && RATE_RESOURCES.includes(resource) &&
|
|
2973
|
+
(status === 403 || status === 429)) {
|
|
2974
|
+
return { kind: "primary", resource, resetMs: reset * 1000 };
|
|
2975
|
+
}
|
|
2976
|
+
if (retryAfter !== null || secondary || status === 429) {
|
|
2977
|
+
return { kind: "secondary", retryAfter, secondary, status };
|
|
2978
|
+
}
|
|
2979
|
+
return { kind: "none" };
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
function transportCooldownDeadline({ retryAfter, status, secondary, at, attempts = 0 }) {
|
|
2983
|
+
const supplied = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
|
|
2891
2984
|
? at + Number(retryAfter) * 1000
|
|
2892
2985
|
: Date.parse(retryAfter ?? "");
|
|
2893
|
-
|
|
2894
|
-
|
|
2986
|
+
// A deadline the server supplied is honoured exactly, however long. Only the
|
|
2987
|
+
// delay chosen here is laddered, and only it is capped.
|
|
2988
|
+
if (Number.isFinite(supplied)) return supplied;
|
|
2989
|
+
return status === 429 || secondary === true ? at + throttleLadderMs(attempts) : null;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
function emptyTransportThrottle() {
|
|
2993
|
+
return { attempts: 0, lastAt: 0, paused: false };
|
|
2895
2994
|
}
|
|
2896
2995
|
|
|
2897
2996
|
function applyTransportCooldown(transport, evidence) {
|
|
2898
|
-
const
|
|
2899
|
-
|
|
2997
|
+
const throttle = transport.throttle ?? emptyTransportThrottle();
|
|
2998
|
+
const deadline = transportCooldownDeadline({ ...evidence, attempts: throttle.attempts });
|
|
2999
|
+
if (deadline === null) return null;
|
|
3000
|
+
// Merged by maximum, so a shorter concurrent error never shortens a hold
|
|
3001
|
+
// another response already established, and the hold survives a primary epoch
|
|
3002
|
+
// change: secondary limits are not accounted in the primary counter.
|
|
3003
|
+
transport.cooldownUntil = Math.max(transport.cooldownUntil, deadline);
|
|
3004
|
+
const attempts = Math.min(throttle.attempts + 1, THROTTLE_PAUSE_AFTER);
|
|
3005
|
+
transport.throttle = { attempts, lastAt: evidence.at, paused: attempts >= THROTTLE_PAUSE_AFTER };
|
|
3006
|
+
return transport.cooldownUntil;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// A request that came back carrying no throttle evidence is the only thing that
|
|
3010
|
+
// proves the hold is over, so it is the only thing that resets the ladder.
|
|
3011
|
+
// Merely waiting out a deadline does not: that is what produced the next
|
|
3012
|
+
// throttle last time.
|
|
3013
|
+
function clearTransportThrottle(transport) {
|
|
3014
|
+
if (transport && transport.throttle && transport.throttle.attempts > 0) {
|
|
3015
|
+
transport.throttle = emptyTransportThrottle();
|
|
3016
|
+
}
|
|
2900
3017
|
}
|
|
2901
3018
|
|
|
2902
3019
|
function releaseIdentityHttpPermit(coordinator, permit, error = null) {
|
|
@@ -2905,22 +3022,48 @@ function releaseIdentityHttpPermit(coordinator, permit, error = null) {
|
|
|
2905
3022
|
if (transport?.permit?.nonce === permit.nonce) transport.permit = null;
|
|
2906
3023
|
if (transport && error) {
|
|
2907
3024
|
const response = parseGhApiResponse(error.stdout ?? "");
|
|
2908
|
-
|
|
2909
|
-
retryAfter: response.headers["retry-after"],
|
|
3025
|
+
const verdict = classifyThrottle({
|
|
2910
3026
|
status: response.status,
|
|
2911
|
-
|
|
2912
|
-
|
|
3027
|
+
headers: response.headers,
|
|
3028
|
+
stderr: error.stderr,
|
|
2913
3029
|
});
|
|
3030
|
+
// A permission-only 403 reaches here too. It is a failure, but it is not a
|
|
3031
|
+
// throttle, and holding the shared transport for it would pause every
|
|
3032
|
+
// pane over one repository the user cannot read.
|
|
3033
|
+
if (verdict.kind === "secondary") {
|
|
3034
|
+
applyTransportCooldown(transport, {
|
|
3035
|
+
retryAfter: verdict.retryAfter,
|
|
3036
|
+
status: verdict.status,
|
|
3037
|
+
secondary: verdict.secondary,
|
|
3038
|
+
at: Date.now(),
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
} else if (transport && !error) {
|
|
3042
|
+
clearTransportThrottle(transport);
|
|
2914
3043
|
}
|
|
2915
3044
|
return { ok: true };
|
|
2916
3045
|
});
|
|
2917
3046
|
}
|
|
2918
3047
|
|
|
3048
|
+
// The explicit retry a paused transport waits for. Clears the ladder and the
|
|
3049
|
+
// deadline together: a person choosing to retry is choosing to spend the next
|
|
3050
|
+
// request finding out, which is exactly the recovery election the pause defers.
|
|
3051
|
+
function retryThrottledTransport(root, host, { now = Date.now } = {}) {
|
|
3052
|
+
return withIdentityRegistry(root, (state) => {
|
|
3053
|
+
const transport = state.hosts[host];
|
|
3054
|
+
if (!transport?.throttle?.paused) return { ok: true, value: { resumed: false } };
|
|
3055
|
+
transport.throttle = emptyTransportThrottle();
|
|
3056
|
+
transport.cooldownUntil = Math.min(transport.cooldownUntil, now());
|
|
3057
|
+
return { ok: true, value: { resumed: true } };
|
|
3058
|
+
}, { now: now() });
|
|
3059
|
+
}
|
|
3060
|
+
|
|
2919
3061
|
function identityCoordinationMessage(reason) {
|
|
2920
3062
|
if (reason === "restart-required") return "Restart required: close older gh-glance panes";
|
|
2921
3063
|
if (["migration-hold", "legacy-unresolved"].includes(reason)) return "Upgrade waiting for legacy quota reset";
|
|
2922
3064
|
if (["legacy-corrupt", "corrupt"].includes(reason)) return "Coordination state unavailable; evidence preserved";
|
|
2923
3065
|
if (reason === "transport-busy") return "Shared HTTP request or cooldown in progress";
|
|
3066
|
+
if (reason === "throttle-paused") return "Paused after repeated rate limits; refresh to retry";
|
|
2924
3067
|
if (retryableCoordination(reason)) return "Coordinating with your other panes";
|
|
2925
3068
|
if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
|
|
2926
3069
|
return "Verified identity unavailable; waiting to retry";
|
|
@@ -2937,9 +3080,27 @@ const GOVERNOR_SCOPE_VERSION = 1;
|
|
|
2937
3080
|
// turns one rejected budget into total loss, discarding every live pane's
|
|
2938
3081
|
// leases, intents and reservations. The version gate makes it fail closed on the
|
|
2939
3082
|
// file instead, which is the whole point of having one.
|
|
2940
|
-
|
|
2941
|
-
//
|
|
2942
|
-
|
|
3083
|
+
// 4: observer claims, readiness and outcomes are per resource, so the schema
|
|
3084
|
+
// shape changed rather than a field's contents. Replaced atomically -- an older
|
|
3085
|
+
// build must fail closed on the version gate rather than half-read it.
|
|
3086
|
+
// 5: scheduling fairness is persisted, because the starvation it prevents
|
|
3087
|
+
// happens *between* planning passes -- each manual refresh is its own pass, so
|
|
3088
|
+
// a counter that lives only inside one cannot see a run of them.
|
|
3089
|
+
const GOVERNOR_STATE_VERSION = 5;
|
|
3090
|
+
// Readable-as-evidence, never written: the shapes a still-running older pane
|
|
3091
|
+
// holds. Recognising that such a pane owns a live lease is what the restart
|
|
3092
|
+
// boundary depends on.
|
|
3093
|
+
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 4, 3, 2];
|
|
3094
|
+
// The first version whose file is already in the per-resource observer shape.
|
|
3095
|
+
// Anything below it is adapted before it can be read; see readLegacyGovernorState.
|
|
3096
|
+
const GOVERNOR_OBSERVER_SPLIT_VERSION = 4;
|
|
3097
|
+
// The first version carrying persisted scheduling fairness. A version 4 file is
|
|
3098
|
+
// otherwise in the current shape and only wants the field defaulted.
|
|
3099
|
+
const GOVERNOR_FAIRNESS_VERSION = 5;
|
|
3100
|
+
// Manual work outranks active work, which is right until it means "always". A
|
|
3101
|
+
// user holding the refresh key issues one manual intent per planning pass, and
|
|
3102
|
+
// without a bound the active owner behind them never gets a turn at all.
|
|
3103
|
+
const MANUAL_GRANT_STREAK_LIMIT = 3;
|
|
2943
3104
|
const GOVERNOR_MAX_LEASES = 128;
|
|
2944
3105
|
const GOVERNOR_MAX_INTENTS = 512;
|
|
2945
3106
|
const GOVERNOR_MAX_RESERVATIONS = 512;
|
|
@@ -3052,11 +3213,16 @@ function emptyGovernorState() {
|
|
|
3052
3213
|
version: GOVERNOR_STATE_VERSION,
|
|
3053
3214
|
epochs: { core: null, graphql: null },
|
|
3054
3215
|
budgets: {},
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
probeClaim
|
|
3059
|
-
|
|
3216
|
+
fairness: { manualStreak: 0 },
|
|
3217
|
+
// Symmetric on purpose. Core observer state lived in `observers.core` while
|
|
3218
|
+
// the GraphQL one lived in a differently-shaped `probeOutcome`, and a single
|
|
3219
|
+
// `probeClaim` serialized both -- so a slow or failed observer for one
|
|
3220
|
+
// resource held up the other's readiness for no reason the resources
|
|
3221
|
+
// themselves impose.
|
|
3222
|
+
observers: Object.fromEntries(RATE_RESOURCES.map((resource) => [
|
|
3223
|
+
resource, { etag: null, outcome: "idle", at: 0, nextAt: 0 },
|
|
3224
|
+
])),
|
|
3225
|
+
probeClaims: Object.fromEntries(RATE_RESOURCES.map((resource) => [resource, null])),
|
|
3060
3226
|
leases: {},
|
|
3061
3227
|
intents: {},
|
|
3062
3228
|
reservations: {},
|
|
@@ -3180,7 +3346,7 @@ function normalizeGovernorReservation(raw, nowMs) {
|
|
|
3180
3346
|
return { ...raw, costs, actualCosts, accountedCosts, epochs };
|
|
3181
3347
|
}
|
|
3182
3348
|
|
|
3183
|
-
function
|
|
3349
|
+
function normalizeResourceObserver(raw, nowMs) {
|
|
3184
3350
|
if (!exactKeys(raw, ["etag", "outcome", "at", "nextAt"])) return null;
|
|
3185
3351
|
if (
|
|
3186
3352
|
raw.etag !== null && (typeof raw.etag !== "string" || raw.etag.length === 0) ||
|
|
@@ -3194,24 +3360,19 @@ function normalizeCoreObserver(raw, nowMs) {
|
|
|
3194
3360
|
function normalizeProbeClaim(raw, nowMs) {
|
|
3195
3361
|
if (raw === null) return null;
|
|
3196
3362
|
if (!exactKeys(raw, [
|
|
3197
|
-
"ownerLeaseId", "nonce", "leaseUntil", "nextAt", "claimAt", "startedReservationIds",
|
|
3363
|
+
"ownerLeaseId", "nonce", "leaseUntil", "nextAt", "claimAt", "startedReservationIds",
|
|
3198
3364
|
])) {
|
|
3199
3365
|
return undefined;
|
|
3200
3366
|
}
|
|
3201
|
-
const resources = Array.isArray(raw.resources) && raw.resources.length > 0 &&
|
|
3202
|
-
raw.resources.every((resource) => RATE_RESOURCES.includes(resource))
|
|
3203
|
-
? [...new Set(raw.resources)]
|
|
3204
|
-
: null;
|
|
3205
3367
|
if (
|
|
3206
3368
|
!validGovernorId(raw.ownerLeaseId) || !validGovernorId(raw.nonce) ||
|
|
3207
3369
|
finiteTimestamp(raw.leaseUntil, nowMs) === undefined ||
|
|
3208
3370
|
finiteTimestamp(raw.nextAt, nowMs) === undefined ||
|
|
3209
3371
|
finiteTimestamp(raw.claimAt, nowMs) === undefined || raw.claimAt > nowMs ||
|
|
3210
3372
|
!Array.isArray(raw.startedReservationIds) || raw.startedReservationIds.length > GOVERNOR_MAX_RESERVATIONS ||
|
|
3211
|
-
raw.startedReservationIds.some((id) => !id.startsWith("reservation:") || !validGovernorId(id.slice(12)))
|
|
3212
|
-
!resources
|
|
3373
|
+
raw.startedReservationIds.some((id) => !id.startsWith("reservation:") || !validGovernorId(id.slice(12)))
|
|
3213
3374
|
) return undefined;
|
|
3214
|
-
return { ...raw, startedReservationIds: [...new Set(raw.startedReservationIds)]
|
|
3375
|
+
return { ...raw, startedReservationIds: [...new Set(raw.startedReservationIds)] };
|
|
3215
3376
|
}
|
|
3216
3377
|
|
|
3217
3378
|
function normalizeManualProbe(raw, nowMs) {
|
|
@@ -3232,27 +3393,28 @@ function normalizeManualProbe(raw, nowMs) {
|
|
|
3232
3393
|
// version this build does not write is unreadable and fails closed.
|
|
3233
3394
|
function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVERNOR_STATE_VERSION } = {}) {
|
|
3234
3395
|
if (!exactKeys(raw, [
|
|
3235
|
-
"version", "epochs", "budgets", "
|
|
3396
|
+
"version", "epochs", "budgets", "fairness", "observers", "probeClaims", "leases",
|
|
3236
3397
|
"intents", "reservations", "manualProbe",
|
|
3237
3398
|
]) || raw.version !== acceptVersion) return null;
|
|
3399
|
+
if (!exactKeys(raw.fairness, ["manualStreak"]) ||
|
|
3400
|
+
!Number.isSafeInteger(raw.fairness.manualStreak) || raw.fairness.manualStreak < 0) return null;
|
|
3238
3401
|
if (
|
|
3239
3402
|
!exactKeys(raw.epochs, RATE_RESOURCES) ||
|
|
3240
3403
|
RATE_RESOURCES.some((resource) => raw.epochs[resource] !== null && !validGovernorEpoch(raw.epochs[resource])) ||
|
|
3241
3404
|
!isRecord(raw.budgets) || Object.keys(raw.budgets).some((resource) => !RATE_RESOURCES.includes(resource)) ||
|
|
3242
|
-
!exactKeys(raw.observers,
|
|
3243
|
-
!
|
|
3244
|
-
!
|
|
3245
|
-
!GOVERNOR_PROBE_STATUSES.has(raw.probeOutcome.status) ||
|
|
3246
|
-
finiteTimestamp(raw.probeOutcome.at, nowMs) === undefined ||
|
|
3247
|
-
raw.probeOutcome.at > nowMs ||
|
|
3248
|
-
finiteTimestamp(raw.probeOutcome.nextAt, nowMs) === undefined
|
|
3405
|
+
!exactKeys(raw.observers, RATE_RESOURCES) ||
|
|
3406
|
+
!exactKeys(raw.probeClaims, RATE_RESOURCES) ||
|
|
3407
|
+
!isRecord(raw.leases) || !isRecord(raw.intents) || !isRecord(raw.reservations)
|
|
3249
3408
|
) return null;
|
|
3250
3409
|
|
|
3251
3410
|
const state = emptyGovernorState();
|
|
3252
3411
|
state.epochs = { ...raw.epochs };
|
|
3253
|
-
state.
|
|
3254
|
-
|
|
3255
|
-
|
|
3412
|
+
state.fairness = { ...raw.fairness };
|
|
3413
|
+
for (const resource of RATE_RESOURCES) {
|
|
3414
|
+
const observer = normalizeResourceObserver(raw.observers[resource], nowMs);
|
|
3415
|
+
if (!observer) return null;
|
|
3416
|
+
state.observers[resource] = observer;
|
|
3417
|
+
}
|
|
3256
3418
|
for (const resource of Object.keys(raw.budgets)) {
|
|
3257
3419
|
const budget = normalizeGovernorBudget(raw.budgets[resource], nowMs, resource);
|
|
3258
3420
|
if (!budget) return null;
|
|
@@ -3282,21 +3444,104 @@ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVE
|
|
|
3282
3444
|
) state.reservations[id] = reservation;
|
|
3283
3445
|
}
|
|
3284
3446
|
if (Object.keys(state.reservations).length > GOVERNOR_MAX_RESERVATIONS) return null;
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3447
|
+
for (const resource of RATE_RESOURCES) {
|
|
3448
|
+
const claim = normalizeProbeClaim(raw.probeClaims[resource], nowMs);
|
|
3449
|
+
if (claim === undefined) return null;
|
|
3450
|
+
state.probeClaims[resource] = claim?.leaseUntil > nowMs ? claim : null;
|
|
3451
|
+
}
|
|
3288
3452
|
state.manualProbe = normalizeManualProbe(raw.manualProbe, nowMs);
|
|
3289
3453
|
if (state.manualProbe === undefined) return null;
|
|
3290
3454
|
return state;
|
|
3291
3455
|
}
|
|
3292
3456
|
|
|
3457
|
+
// Reads a still-running older pane's file as evidence. A version number alone
|
|
3458
|
+
// is not enough to identify a document: versions 2 and 3 share a *shape* that
|
|
3459
|
+
// version 4 replaced, so accepting their version against the current key set
|
|
3460
|
+
// would reject every real file they wrote. Read-only -- the result is never
|
|
3461
|
+
// published and the file is never rewritten.
|
|
3462
|
+
function readLegacyGovernorState(raw, nowMs, version) {
|
|
3463
|
+
if (!isRecord(raw) || raw.version !== version) return null;
|
|
3464
|
+
const options = { prune: false, acceptVersion: version };
|
|
3465
|
+
if (version >= GOVERNOR_FAIRNESS_VERSION) return normalizeGovernorState(raw, nowMs, options);
|
|
3466
|
+
// A version 4 file is already in the per-resource observer shape and differs
|
|
3467
|
+
// only by the field this version added, so defaulting it is the whole
|
|
3468
|
+
// adaptation. Older files need their observer shape rewritten first.
|
|
3469
|
+
const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION ? { ...raw } : adaptPreSplitGovernorShape(raw);
|
|
3470
|
+
if (!shaped) return null;
|
|
3471
|
+
shaped.fairness = { manualStreak: 0 };
|
|
3472
|
+
return normalizeGovernorState(shaped, nowMs, options);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// Versions 2 and 3 held one observer for core, a differently shaped
|
|
3476
|
+
// `probeOutcome` standing in for GraphQL, and a single claim naming the
|
|
3477
|
+
// resources it covered. Rewrite that into the current shape so the inspector
|
|
3478
|
+
// above can see the leases, deadlines and uncertain work such a pane holds.
|
|
3479
|
+
// The claim's own `resources` list says which resources it covered, so
|
|
3480
|
+
// splitting it in two reads the file rather than guessing at it.
|
|
3481
|
+
function adaptPreSplitGovernorShape(raw) {
|
|
3482
|
+
if (!exactKeys(raw, [
|
|
3483
|
+
"version", "epochs", "budgets", "observers", "probeClaim", "probeOutcome",
|
|
3484
|
+
"leases", "intents", "reservations", "manualProbe",
|
|
3485
|
+
])) return null;
|
|
3486
|
+
if (!exactKeys(raw.observers, ["core"])) return null;
|
|
3487
|
+
if (!exactKeys(raw.probeOutcome, ["status", "at", "nextAt"])) return null;
|
|
3488
|
+
if (raw.probeClaim !== null && !isRecord(raw.probeClaim)) return null;
|
|
3489
|
+
const { resources, ...claim } = raw.probeClaim ?? {};
|
|
3490
|
+
const covered = Array.isArray(resources) ? resources : [];
|
|
3491
|
+
const adapted = structuredClone(raw);
|
|
3492
|
+
delete adapted.probeClaim;
|
|
3493
|
+
delete adapted.probeOutcome;
|
|
3494
|
+
adapted.observers = {
|
|
3495
|
+
core: raw.observers.core,
|
|
3496
|
+
graphql: {
|
|
3497
|
+
etag: null,
|
|
3498
|
+
outcome: raw.probeOutcome.status,
|
|
3499
|
+
at: raw.probeOutcome.at,
|
|
3500
|
+
nextAt: raw.probeOutcome.nextAt,
|
|
3501
|
+
},
|
|
3502
|
+
};
|
|
3503
|
+
adapted.probeClaims = Object.fromEntries(RATE_RESOURCES.map((resource) => [
|
|
3504
|
+
resource, raw.probeClaim && covered.includes(resource) ? structuredClone(claim) : null,
|
|
3505
|
+
]));
|
|
3506
|
+
return adapted;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3293
3509
|
function serializeGovernorState(state) {
|
|
3294
3510
|
return `${JSON.stringify(state)}\n`;
|
|
3295
3511
|
}
|
|
3296
3512
|
|
|
3513
|
+
// Failing closed on a version this build does not write is right in one
|
|
3514
|
+
// direction only. An *older* binary meeting a newer file must refuse it, because
|
|
3515
|
+
// it cannot know what the new fields mean. A *newer* binary meeting an older
|
|
3516
|
+
// file at its own canonical path has to migrate it: the read happens before any
|
|
3517
|
+
// write, so refusing leaves the file in place and the pane never recovers. It is
|
|
3518
|
+
// not a transient failure, it is a permanent one, and every later launch repeats
|
|
3519
|
+
// it.
|
|
3520
|
+
//
|
|
3521
|
+
// That asymmetry was missed when the observer split moved the protocol from 3 to
|
|
3522
|
+
// 5. Versions 1 and 2 escaped it only because the file moved path at the same
|
|
3523
|
+
// time, so a new build simply found nothing and started clean; 3 onwards share a
|
|
3524
|
+
// path with 5 and collided.
|
|
3297
3525
|
function migrateGovernorState(raw, nowMs) {
|
|
3526
|
+
if (!isRecord(raw) || !Number.isSafeInteger(raw.version) || raw.version < 1) return null;
|
|
3527
|
+
if (raw.version >= GOVERNOR_STATE_VERSION) return null;
|
|
3528
|
+
if (raw.version === 1) return migrateV1GovernorState(raw, nowMs);
|
|
3529
|
+
// Everything from 2 onwards is this build's shape or one adaptation away from
|
|
3530
|
+
// it, and what it holds is worth keeping: the uncertain reservations are debts
|
|
3531
|
+
// that must not be forgiven by an upgrade, and the budgets were written by the
|
|
3532
|
+
// same observers this build trusts.
|
|
3533
|
+
const shaped = raw.version >= GOVERNOR_OBSERVER_SPLIT_VERSION
|
|
3534
|
+
? structuredClone(raw)
|
|
3535
|
+
: adaptPreSplitGovernorShape(raw);
|
|
3536
|
+
if (!shaped) return null;
|
|
3537
|
+
if (!isRecord(shaped.fairness)) shaped.fairness = { manualStreak: 0 };
|
|
3538
|
+
shaped.version = GOVERNOR_STATE_VERSION;
|
|
3539
|
+
return normalizeGovernorState(shaped, nowMs);
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
function migrateV1GovernorState(raw, nowMs) {
|
|
3298
3543
|
if (!exactKeys(raw, [
|
|
3299
|
-
"version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases",
|
|
3544
|
+
"version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases", // v1 shape
|
|
3300
3545
|
"intents", "reservations", "manualProbe",
|
|
3301
3546
|
]) || raw.version !== 1 || !isRecord(raw.budgets) || !isRecord(raw.reservations)) return null;
|
|
3302
3547
|
const migrated = structuredClone(raw);
|
|
@@ -3318,7 +3563,16 @@ function migrateGovernorState(raw, nowMs) {
|
|
|
3318
3563
|
for (const reservation of Object.values(migrated.reservations)) {
|
|
3319
3564
|
reservation.accountedCosts = { core: 0, graphql: 0 };
|
|
3320
3565
|
}
|
|
3321
|
-
|
|
3566
|
+
// v1 carried one claim for both resources; the new shape has one per resource
|
|
3567
|
+
// and a migrated claim belongs to neither, so it is dropped rather than
|
|
3568
|
+
// guessed at. The next probe re-claims what is actually due.
|
|
3569
|
+
delete migrated.probeClaim;
|
|
3570
|
+
delete migrated.probeOutcome;
|
|
3571
|
+
migrated.observers = Object.fromEntries(RATE_RESOURCES.map((resource) => [
|
|
3572
|
+
resource, { etag: null, outcome: "idle", at: 0, nextAt: 0 },
|
|
3573
|
+
]));
|
|
3574
|
+
migrated.probeClaims = Object.fromEntries(RATE_RESOURCES.map((resource) => [resource, null]));
|
|
3575
|
+
migrated.fairness = { manualStreak: 0 };
|
|
3322
3576
|
return normalizeGovernorState(migrated, nowMs);
|
|
3323
3577
|
}
|
|
3324
3578
|
|
|
@@ -3601,6 +3855,33 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
3601
3855
|
});
|
|
3602
3856
|
}
|
|
3603
3857
|
|
|
3858
|
+
// The largest single operation the governor will ever admit. Returned pacing is
|
|
3859
|
+
// capped at one of these so an idle stretch cannot accumulate into a burst: at
|
|
3860
|
+
// most one operation's worth of the lane is ever given back at a time.
|
|
3861
|
+
const GOVERNOR_MAX_ATOMIC_COST = Math.max(...Object.values(OPERATION_COSTS)
|
|
3862
|
+
.flatMap((costs) => RATE_RESOURCES.map((resource) => costs?.[resource] ?? 0)));
|
|
3863
|
+
|
|
3864
|
+
// A grant paces the lane by what it *reserved*, which is a worst case. When the
|
|
3865
|
+
// request costs less than that -- a 304 costs nothing -- or never happens at
|
|
3866
|
+
// all, the difference is capacity paced away for nobody, and the next request
|
|
3867
|
+
// waits out a slot no one used.
|
|
3868
|
+
//
|
|
3869
|
+
// The lane is never pulled earlier than the transport gap, which is what keeps
|
|
3870
|
+
// this from becoming a burst. Recomputing the rate here can differ from the
|
|
3871
|
+
// rate at grant time if capacity has since changed, so the returned credit is
|
|
3872
|
+
// an estimate rather than an exact reversal; that is safe because admission
|
|
3873
|
+
// still re-checks affordability against the reserve before anything starts.
|
|
3874
|
+
// Pacing decides when work may go, never whether it may.
|
|
3875
|
+
function returnPacingCredit(state, resource, unusedCost, nowMs) {
|
|
3876
|
+
const budget = state.budgets[resource];
|
|
3877
|
+
if (!budget || !(unusedCost > 0)) return;
|
|
3878
|
+
const decision = resourceDecision({ budget, resource, nowMs, cost: 0, chargedCost: 0 });
|
|
3879
|
+
const callsPerMs = decision?.callsPerMs;
|
|
3880
|
+
if (!Number.isFinite(callsPerMs) || callsPerMs <= 0) return;
|
|
3881
|
+
const credit = Math.min(unusedCost, GOVERNOR_MAX_ATOMIC_COST) / callsPerMs;
|
|
3882
|
+
budget.laneNextAt = Math.max(nowMs + HTTP_START_GAP_MS, budget.laneNextAt - credit);
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3604
3885
|
function scheduleGovernorState(state, nowMs) {
|
|
3605
3886
|
if (Object.keys(state.intents).length === 0) return { grants: [], denied: [] };
|
|
3606
3887
|
let reservationCount = Object.keys(state.reservations).length;
|
|
@@ -3612,7 +3893,7 @@ function scheduleGovernorState(state, nowMs) {
|
|
|
3612
3893
|
? [[resource, state.budgets[resource].roundRobinCursor]]
|
|
3613
3894
|
: [],
|
|
3614
3895
|
));
|
|
3615
|
-
const deferredBackground = state.
|
|
3896
|
+
const deferredBackground = state.observers.graphql.outcome === "failed"
|
|
3616
3897
|
? Object.entries(state.intents)
|
|
3617
3898
|
.filter(([, intent]) => intentPriority(intent) === REQUEST_PRIORITIES.background)
|
|
3618
3899
|
.map(([id]) => id)
|
|
@@ -3633,7 +3914,9 @@ function scheduleGovernorState(state, nowMs) {
|
|
|
3633
3914
|
cursors,
|
|
3634
3915
|
nowMs,
|
|
3635
3916
|
maxGrants: GOVERNOR_MAX_RESERVATIONS - reservationCount,
|
|
3917
|
+
manualStreak: state.fairness.manualStreak,
|
|
3636
3918
|
});
|
|
3919
|
+
state.fairness.manualStreak = result.manualStreak;
|
|
3637
3920
|
result.denied.push(...deferredBackground.map((intentId) => ({
|
|
3638
3921
|
intentId,
|
|
3639
3922
|
mode: "paused",
|
|
@@ -3720,76 +4003,79 @@ function maintainControlLease(scope, leaseId, floorMs, activeTab, nowMs) {
|
|
|
3720
4003
|
: registered;
|
|
3721
4004
|
}
|
|
3722
4005
|
|
|
3723
|
-
|
|
4006
|
+
// Claims the observer for one resource. Independence is the point: a slow or
|
|
4007
|
+
// failed GraphQL observer must not hold up core readiness, because nothing about
|
|
4008
|
+
// the resources themselves couples them. What still couples them is the shared
|
|
4009
|
+
// HTTP permit and any account-wide secondary hold, and those are enforced
|
|
4010
|
+
// elsewhere -- not by making one claim stand for both.
|
|
4011
|
+
function claimProbe(scope, leaseId, nowMs, resource) {
|
|
4012
|
+
if (!RATE_RESOURCES.includes(resource)) return { ok: false, reason: "corrupt" };
|
|
3724
4013
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3725
4014
|
if (!state.leases[leaseId]) return { ok: false, reason: "stale" };
|
|
3726
|
-
|
|
3727
|
-
|
|
4015
|
+
const held = state.probeClaims[resource];
|
|
4016
|
+
if (held && held.leaseUntil > at) {
|
|
4017
|
+
return { value: { status: "waiting", leaseUntil: held.leaseUntil } };
|
|
3728
4018
|
}
|
|
4019
|
+
const resetAt = Number.isFinite(state.budgets[resource]?.resetMs)
|
|
4020
|
+
? state.budgets[resource].resetMs + BUDGET_RESET_GRACE_MS
|
|
4021
|
+
: Number.POSITIVE_INFINITY;
|
|
4022
|
+
// A core reset starts a new shared accounting epoch, so the GraphQL counter
|
|
4023
|
+
// is also due then: external-spend baselines and the protected
|
|
4024
|
+
// one-publication reset contract have to advance together.
|
|
3729
4025
|
const coreResetAt = Number.isFinite(state.budgets.core?.resetMs)
|
|
3730
4026
|
? state.budgets.core.resetMs + BUDGET_RESET_GRACE_MS
|
|
3731
4027
|
: Number.POSITIVE_INFINITY;
|
|
3732
|
-
|
|
3733
|
-
|
|
4028
|
+
// ...but only until it has actually observed since that reset. Left
|
|
4029
|
+
// unqualified, every pane keeps qualifying for as long as core has not
|
|
4030
|
+
// published its new epoch, so a reset draws a stampede of GraphQL observers
|
|
4031
|
+
// instead of the one the shared epoch needs. The publication used to close
|
|
4032
|
+
// that window as a side effect of being read first; saying it here means it
|
|
4033
|
+
// does not depend on which resource a refresh happens to reach first.
|
|
4034
|
+
const dueAtCoreReset = resource === "graphql" &&
|
|
4035
|
+
state.observers.graphql.at < coreResetAt
|
|
4036
|
+
? coreResetAt
|
|
3734
4037
|
: Number.POSITIVE_INFINITY;
|
|
3735
|
-
const
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
graphql: Math.min(
|
|
3741
|
-
state.probeOutcome.nextAt,
|
|
3742
|
-
graphqlResetAt,
|
|
3743
|
-
// A core reset starts a new shared accounting epoch. Refresh the free
|
|
3744
|
-
// GraphQL counter in that same nonce so external-spend baselines and
|
|
3745
|
-
// the protected one-publication reset contract advance together.
|
|
3746
|
-
coreResetAt,
|
|
3747
|
-
),
|
|
3748
|
-
};
|
|
3749
|
-
const nextAt = Math.min(...Object.values(dueAt));
|
|
4038
|
+
const nextAt = Math.min(
|
|
4039
|
+
state.observers[resource].nextAt,
|
|
4040
|
+
resetAt,
|
|
4041
|
+
dueAtCoreReset,
|
|
4042
|
+
);
|
|
3750
4043
|
if (nextAt > at) return { value: { status: "waiting", nextAt } };
|
|
3751
|
-
// One nonce owns the control-plane transition, but only the sources whose
|
|
3752
|
-
// persisted clocks are due may be read. In particular, a GraphQL minute
|
|
3753
|
-
// sample cannot spend or retry the held core observer before its reset.
|
|
3754
|
-
const resources = RATE_RESOURCES.filter((resource) => dueAt[resource] <= at);
|
|
3755
4044
|
const nonce = randomUUID();
|
|
3756
4045
|
const startedReservationIds = Object.entries(state.reservations)
|
|
3757
4046
|
// An expired owner can no longer settle its request. Keep that uncertain
|
|
3758
4047
|
// cost charged, but do not make every later probe wait for it to finish.
|
|
4048
|
+
// Only work charging this resource is drained -- an unrelated lane's
|
|
4049
|
+
// outstanding request says nothing about this counter.
|
|
3759
4050
|
.filter(([, reservation]) => reservation.status === "started" &&
|
|
3760
|
-
state.leases[reservation.leaseId]?.expiresAt > at
|
|
4051
|
+
state.leases[reservation.leaseId]?.expiresAt > at &&
|
|
4052
|
+
reservationCost(reservation, resource, state.leases, at) > 0)
|
|
3761
4053
|
.map(([id]) => id);
|
|
3762
|
-
state.
|
|
4054
|
+
state.probeClaims[resource] = {
|
|
3763
4055
|
ownerLeaseId: leaseId,
|
|
3764
4056
|
nonce,
|
|
3765
4057
|
leaseUntil: at + GOVERNOR_PROBE_LEASE_MS,
|
|
3766
4058
|
nextAt: at,
|
|
3767
4059
|
claimAt: at,
|
|
3768
4060
|
startedReservationIds,
|
|
3769
|
-
resources,
|
|
3770
4061
|
};
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
state.observers.core.nextAt = at + GOVERNOR_PROBE_LEASE_MS;
|
|
3775
|
-
}
|
|
3776
|
-
if (resources.includes("graphql")) {
|
|
3777
|
-
state.probeOutcome = { status: "waiting", at, nextAt: at + GOVERNOR_PROBE_LEASE_MS };
|
|
3778
|
-
}
|
|
4062
|
+
state.observers[resource].outcome = "waiting";
|
|
4063
|
+
state.observers[resource].at = at;
|
|
4064
|
+
state.observers[resource].nextAt = at + GOVERNOR_PROBE_LEASE_MS;
|
|
3779
4065
|
return { value: {
|
|
3780
4066
|
status: "claimed",
|
|
3781
4067
|
nonce,
|
|
3782
|
-
|
|
4068
|
+
resource,
|
|
4069
|
+
leaseUntil: state.probeClaims[resource].leaseUntil,
|
|
3783
4070
|
startedReservationIds,
|
|
3784
|
-
|
|
3785
|
-
coreEtag: state.observers.core.etag,
|
|
4071
|
+
coreEtag: state.observers[resource].etag,
|
|
3786
4072
|
} };
|
|
3787
4073
|
});
|
|
3788
4074
|
}
|
|
3789
4075
|
|
|
3790
|
-
function renewProbeClaim(scope, leaseId, nonce, nowMs) {
|
|
4076
|
+
function renewProbeClaim(scope, leaseId, nonce, nowMs, resource) {
|
|
3791
4077
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3792
|
-
const claim = state.
|
|
4078
|
+
const claim = state.probeClaims[resource];
|
|
3793
4079
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
|
|
3794
4080
|
return { ok: false, reason: "stale" };
|
|
3795
4081
|
}
|
|
@@ -3847,7 +4133,13 @@ function budgetFromObservation(raw, previous, nowMs, {
|
|
|
3847
4133
|
blockReason: blockActive ? previous.blockReason : null,
|
|
3848
4134
|
laneNextAt: epochChanged ? nowMs : previous.laneNextAt,
|
|
3849
4135
|
roundRobinCursor: previous?.roundRobinCursor ?? null,
|
|
3850
|
-
|
|
4136
|
+
// A new epoch is a new accounting window, so what other clients did in the
|
|
4137
|
+
// last one is not evidence about this one. The baseline and the local
|
|
4138
|
+
// accumulation below are already reset here; carrying the factor across
|
|
4139
|
+
// kept throttling this window on a ratio measured in a window that is over.
|
|
4140
|
+
// Only a claimed observer can open an epoch, so this cannot be moved by a
|
|
4141
|
+
// stale or reordered response.
|
|
4142
|
+
lastExternalFactor: epochChanged ? 1 : (previous?.lastExternalFactor ?? 1),
|
|
3851
4143
|
epoch,
|
|
3852
4144
|
source,
|
|
3853
4145
|
factorBaseline: epochChanged || !previous?.factorBaseline
|
|
@@ -3858,23 +4150,23 @@ function budgetFromObservation(raw, previous, nowMs, {
|
|
|
3858
4150
|
return { status: "accepted", budget: next, epochChanged };
|
|
3859
4151
|
}
|
|
3860
4152
|
|
|
3861
|
-
function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
4153
|
+
function publishProbe(scope, leaseId, nonce, budgets, nowMs, resource) {
|
|
3862
4154
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3863
|
-
const claim = state.
|
|
4155
|
+
const claim = state.probeClaims[resource];
|
|
3864
4156
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
|
|
3865
4157
|
return { ok: false, reason: "stale" };
|
|
3866
4158
|
}
|
|
3867
4159
|
const nextBudgets = { ...state.budgets };
|
|
3868
4160
|
const nextEpochs = { ...state.epochs };
|
|
4161
|
+
// Captured before the publication overwrites it: the reset this epoch
|
|
4162
|
+
// change supersedes is the boundary the GraphQL observer is measured against.
|
|
4163
|
+
const supersededCoreResetMs = state.budgets.core?.resetMs;
|
|
3869
4164
|
const changedResources = [];
|
|
3870
4165
|
const resetResources = [];
|
|
3871
|
-
if (!
|
|
3872
|
-
return { ok: false, reason: "corrupt" };
|
|
3873
|
-
}
|
|
4166
|
+
if (!budgets?.[resource]) return { ok: false, reason: "corrupt" };
|
|
3874
4167
|
const observerCosts = {};
|
|
3875
|
-
|
|
3876
|
-
const supplied = budgets
|
|
3877
|
-
if (!supplied) continue;
|
|
4168
|
+
{
|
|
4169
|
+
const supplied = budgets[resource];
|
|
3878
4170
|
const raw = supplied.budget ?? supplied;
|
|
3879
4171
|
// The owner source names what actually established the number. Calling a
|
|
3880
4172
|
// GraphQL budget a "rate-limit-probe" was accurate when /rate_limit supplied
|
|
@@ -3890,19 +4182,20 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3890
4182
|
allowEpochChange: true,
|
|
3891
4183
|
});
|
|
3892
4184
|
if (observed.status === "invalid") return { ok: false, reason: "corrupt" };
|
|
3893
|
-
if (observed.status
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
4185
|
+
if (observed.status !== "ignored") {
|
|
4186
|
+
// The observer's own cost is local spend. Left out, the external-factor
|
|
4187
|
+
// reconciliation below attributes it to other clients, and the governor
|
|
4188
|
+
// throttles the user's real work to make room for its own probing.
|
|
4189
|
+
observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
|
|
4190
|
+
nextBudgets[resource] = observed.budget;
|
|
4191
|
+
if (resource === "core" && supplied.blocked === true) {
|
|
4192
|
+
nextBudgets[resource].blockUntil = observed.budget.resetMs;
|
|
4193
|
+
nextBudgets[resource].blockReason = "rate-limit";
|
|
4194
|
+
}
|
|
4195
|
+
nextEpochs[resource] = observed.budget.epoch;
|
|
4196
|
+
changedResources.push(resource);
|
|
4197
|
+
if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
|
|
3902
4198
|
}
|
|
3903
|
-
nextEpochs[resource] = observed.budget.epoch;
|
|
3904
|
-
changedResources.push(resource);
|
|
3905
|
-
if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
|
|
3906
4199
|
}
|
|
3907
4200
|
if (changedResources.length === 0) return { ok: false, reason: "corrupt" };
|
|
3908
4201
|
const completedBeforeClaim = Object.entries(state.reservations)
|
|
@@ -3920,19 +4213,32 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3920
4213
|
total + (reservation.completedAt > baseline.observedAt
|
|
3921
4214
|
? reservationCost(reservation, resource, state.leases, at)
|
|
3922
4215
|
: 0), 0);
|
|
4216
|
+
const globalUsedDelta = nextBudgets[resource].used - baseline.used;
|
|
4217
|
+
const sharedCompletedDelta = previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0);
|
|
3923
4218
|
const factor = nextExternalFactor({
|
|
3924
4219
|
lastExternalFactor: previous.lastExternalFactor,
|
|
3925
|
-
globalUsedDelta
|
|
3926
|
-
sharedCompletedDelta
|
|
4220
|
+
globalUsedDelta,
|
|
4221
|
+
sharedCompletedDelta,
|
|
3927
4222
|
});
|
|
3928
4223
|
if (factor === null) return { ok: false, reason: "corrupt" };
|
|
3929
4224
|
nextBudgets[resource].lastExternalFactor = factor;
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
4225
|
+
// Closing the window costs the evidence in it, so only a window that
|
|
4226
|
+
// actually reconciled may close. Four local units observed twice are
|
|
4227
|
+
// eight, and eight are enough; discarding each four because neither
|
|
4228
|
+
// reached five on its own is what kept a factor of seven alive through
|
|
4229
|
+
// any amount of purely local spend.
|
|
4230
|
+
if (externalSampleIsUsable({ globalUsedDelta, sharedCompletedDelta })) {
|
|
4231
|
+
nextBudgets[resource].factorBaseline = {
|
|
4232
|
+
epoch: nextBudgets[resource].epoch,
|
|
4233
|
+
used: nextBudgets[resource].used,
|
|
4234
|
+
observedAt: nextBudgets[resource].observedAt,
|
|
4235
|
+
};
|
|
4236
|
+
nextBudgets[resource].knownLocalUsed = 0;
|
|
4237
|
+
}
|
|
4238
|
+
// Otherwise both are left exactly as the observation produced them, which
|
|
4239
|
+
// is the previous baseline and the previous local total. Rewriting them
|
|
4240
|
+
// here would count the same completed reservations twice: they are still
|
|
4241
|
+
// in the ledger precisely because the baseline did not advance past them.
|
|
3936
4242
|
}
|
|
3937
4243
|
for (const [id, reservation] of completedBeforeClaim) {
|
|
3938
4244
|
const accountedByEveryResource = RATE_RESOURCES.every((resource) =>
|
|
@@ -3942,24 +4248,41 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3942
4248
|
}
|
|
3943
4249
|
state.budgets = nextBudgets;
|
|
3944
4250
|
state.epochs = nextEpochs;
|
|
3945
|
-
state.
|
|
3946
|
-
|
|
3947
|
-
const
|
|
3948
|
-
state.observers
|
|
3949
|
-
etag: typeof budgets.
|
|
4251
|
+
state.probeClaims[resource] = null;
|
|
4252
|
+
{
|
|
4253
|
+
const published = nextBudgets[resource];
|
|
4254
|
+
state.observers[resource] = {
|
|
4255
|
+
etag: typeof budgets[resource].etag === "string" ? budgets[resource].etag : state.observers[resource].etag,
|
|
3950
4256
|
outcome: "healthy",
|
|
3951
4257
|
at,
|
|
3952
|
-
|
|
3953
|
-
|
|
4258
|
+
// A resource observed as empty is due again at its own reset, not at the
|
|
4259
|
+
// ordinary cadence: probing an exhausted counter to be told it is still
|
|
4260
|
+
// exhausted spends against it for nothing.
|
|
4261
|
+
nextAt: published.remaining === 0
|
|
4262
|
+
? published.resetMs + BUDGET_RESET_GRACE_MS
|
|
3954
4263
|
: at + BUDGET_PROBE_MS,
|
|
3955
4264
|
};
|
|
3956
4265
|
}
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
4266
|
+
// A core reset opens a new shared accounting epoch, so the GraphQL counter
|
|
4267
|
+
// is due with it: external-spend baselines and the protected
|
|
4268
|
+
// one-publication reset contract have to advance together. A refresh that
|
|
4269
|
+
// reads GraphQL before core has already done this, because core's old reset
|
|
4270
|
+
// time still said GraphQL was due when that claim was evaluated. What
|
|
4271
|
+
// cannot see it is a pane whose GraphQL observer last ran before the reset
|
|
4272
|
+
// -- another pane published this epoch first, moving the reset out of
|
|
4273
|
+
// reach. Only that case still owes an observation, so only that case is
|
|
4274
|
+
// pulled forward; marking it unconditionally spends a second point to
|
|
4275
|
+
// re-read a counter this same cycle has just read. An exhausted GraphQL
|
|
4276
|
+
// counter waits for its own reset either way.
|
|
4277
|
+
if (resource === "core" && resetResources.includes("core")) {
|
|
4278
|
+
const graphql = nextBudgets.graphql;
|
|
4279
|
+
const heldToReset = graphql && graphql.remaining === 0 &&
|
|
4280
|
+
at < graphql.resetMs + BUDGET_RESET_GRACE_MS;
|
|
4281
|
+
const observedSinceReset = Number.isFinite(supersededCoreResetMs) &&
|
|
4282
|
+
state.observers.graphql.at >= supersededCoreResetMs;
|
|
4283
|
+
if (!heldToReset && !observedSinceReset) {
|
|
4284
|
+
state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
|
|
4285
|
+
}
|
|
3963
4286
|
}
|
|
3964
4287
|
if (resetResources.length > 0) state.manualProbe = null;
|
|
3965
4288
|
else if (state.manualProbe &&
|
|
@@ -3976,24 +4299,22 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3976
4299
|
});
|
|
3977
4300
|
}
|
|
3978
4301
|
|
|
3979
|
-
function failProbeClaim(scope, leaseId, nonce, nowMs) {
|
|
4302
|
+
function failProbeClaim(scope, leaseId, nonce, nowMs, resource) {
|
|
3980
4303
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3981
|
-
const claim = state.
|
|
4304
|
+
const claim = state.probeClaims[resource];
|
|
3982
4305
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce) {
|
|
3983
4306
|
return { ok: false, reason: "stale" };
|
|
3984
4307
|
}
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
state.observers
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
};
|
|
3996
|
-
}
|
|
4308
|
+
// Only this resource's readiness is affected. A failed GraphQL observer that
|
|
4309
|
+
// also marked core failed would erase authority core had legitimately
|
|
4310
|
+
// established, and stall a lane with nothing wrong with it.
|
|
4311
|
+
state.probeClaims[resource] = null;
|
|
4312
|
+
state.observers[resource] = {
|
|
4313
|
+
...state.observers[resource],
|
|
4314
|
+
outcome: "failed",
|
|
4315
|
+
at,
|
|
4316
|
+
nextAt: at + BUDGET_PROBE_MS,
|
|
4317
|
+
};
|
|
3997
4318
|
return { value: { retryAt: at + BUDGET_PROBE_MS } };
|
|
3998
4319
|
});
|
|
3999
4320
|
}
|
|
@@ -4015,7 +4336,7 @@ function requestManualProbe(scope, leaseId, epoch, observedAt, nowMs) {
|
|
|
4015
4336
|
: Math.min(state.observers.core.nextAt || at, at);
|
|
4016
4337
|
}
|
|
4017
4338
|
if (state.epochs.graphql === epoch) {
|
|
4018
|
-
state.
|
|
4339
|
+
state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
|
|
4019
4340
|
}
|
|
4020
4341
|
return { value: { status: "pending", ...state.manualProbe } };
|
|
4021
4342
|
});
|
|
@@ -4118,6 +4439,10 @@ function cancelIntent(scope, intentId, nowMs) {
|
|
|
4118
4439
|
return { ok: false, reason: "stale" };
|
|
4119
4440
|
}
|
|
4120
4441
|
delete state.reservations[reservationId];
|
|
4442
|
+
// The request never started, so the whole slot it was paced into is free.
|
|
4443
|
+
for (const resource of RATE_RESOURCES) {
|
|
4444
|
+
returnPacingCredit(state, resource, reservation.costs[resource], nowMs ?? Date.now());
|
|
4445
|
+
}
|
|
4121
4446
|
return { value: { status: "cancelled", intentId, reservationId } };
|
|
4122
4447
|
});
|
|
4123
4448
|
}
|
|
@@ -4131,8 +4456,14 @@ function startReservation(scope, reservationId, nowMs) {
|
|
|
4131
4456
|
return { ok: false, reason: "stale", write: Boolean(reservation) };
|
|
4132
4457
|
}
|
|
4133
4458
|
if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
|
|
4134
|
-
|
|
4135
|
-
|
|
4459
|
+
// Any live observer claim defers a start: the claim owns the shared HTTP
|
|
4460
|
+
// permit for its window, so starting here would contend for it.
|
|
4461
|
+
const liveClaim = RATE_RESOURCES
|
|
4462
|
+
.map((resource) => state.probeClaims[resource])
|
|
4463
|
+
.filter((claim) => claim && claim.leaseUntil > at)
|
|
4464
|
+
.sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
|
|
4465
|
+
if (liveClaim) {
|
|
4466
|
+
return { value: { status: "waiting", reason: "probe", notBefore: liveClaim.leaseUntil } };
|
|
4136
4467
|
}
|
|
4137
4468
|
for (const resource of RATE_RESOURCES.filter((name) => reservation.costs[name] > 0)) {
|
|
4138
4469
|
const budget = state.budgets[resource];
|
|
@@ -4176,6 +4507,15 @@ function completeReservation(scope, reservationId, completion, nowMs) {
|
|
|
4176
4507
|
reservation.outcome = completion.outcome;
|
|
4177
4508
|
reservation.actualCosts = { ...measured };
|
|
4178
4509
|
reservation.accountedCosts = { core: 0, graphql: 0 };
|
|
4510
|
+
// The same rule as the observing settlement path: this one narrows the
|
|
4511
|
+
// charge in exactly the same way, so it releases the pacing in exactly the
|
|
4512
|
+
// same way. Wiring it into only one of the two settlement paths left every
|
|
4513
|
+
// doctor probe and manual operation paying for capacity it did not use.
|
|
4514
|
+
if (completion.outcome === "measured-success") {
|
|
4515
|
+
for (const resource of RATE_RESOURCES) {
|
|
4516
|
+
returnPacingCredit(state, resource, reservation.costs[resource] - measured[resource], at);
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
4179
4519
|
return { value: { status: "completed", actualCosts: reservation.actualCosts } };
|
|
4180
4520
|
});
|
|
4181
4521
|
}
|
|
@@ -4267,6 +4607,15 @@ function settleReservationWithBudgetObservations(
|
|
|
4267
4607
|
acceptedAfterBaseline[resource],
|
|
4268
4608
|
);
|
|
4269
4609
|
}
|
|
4610
|
+
// Only a measured outcome knows what it spent. A timeout, abort or process
|
|
4611
|
+
// loss proves nothing, so its worst case stays charged and its pacing stays
|
|
4612
|
+
// spent -- refunding there would let a run of timeouts pace as though
|
|
4613
|
+
// nothing had been sent.
|
|
4614
|
+
if (completion.outcome === "measured-success") {
|
|
4615
|
+
for (const resource of RATE_RESOURCES) {
|
|
4616
|
+
returnPacingCredit(state, resource, reservation.costs[resource] - measured[resource], at);
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4270
4619
|
scheduleGovernorState(state, at);
|
|
4271
4620
|
return { value: {
|
|
4272
4621
|
status: "completed",
|
|
@@ -4289,8 +4638,8 @@ function recordResourceBlock(scope, resource, resetMs, reason) {
|
|
|
4289
4638
|
resetMs + BUDGET_RESET_GRACE_MS,
|
|
4290
4639
|
);
|
|
4291
4640
|
} else {
|
|
4292
|
-
state.
|
|
4293
|
-
state.
|
|
4641
|
+
state.observers.graphql.nextAt = Math.min(
|
|
4642
|
+
state.observers.graphql.nextAt || resetMs + BUDGET_RESET_GRACE_MS,
|
|
4294
4643
|
resetMs + BUDGET_RESET_GRACE_MS,
|
|
4295
4644
|
);
|
|
4296
4645
|
}
|
|
@@ -4326,7 +4675,7 @@ function governorHealth(result, nowMs = Date.now()) {
|
|
|
4326
4675
|
if (!result?.ok) return { status: "unavailable", leases: 0, resources: {} };
|
|
4327
4676
|
const state = result.value;
|
|
4328
4677
|
let status = "healthy";
|
|
4329
|
-
if (state.
|
|
4678
|
+
if (RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > nowMs)) status = "waiting for probe";
|
|
4330
4679
|
else if (RATE_RESOURCES.some((resource) => !state.budgets[resource] ||
|
|
4331
4680
|
nowMs - state.budgets[resource].observedAt > budgetSnapshotTtl(resource))) status = "stale";
|
|
4332
4681
|
else if (RATE_RESOURCES.some((resource) => state.budgets[resource].blockUntil > nowMs)) status = "blocked";
|
|
@@ -4354,7 +4703,40 @@ async function retryGovernorMutation(run, { now, wait, deadline, signal }) {
|
|
|
4354
4703
|
return result;
|
|
4355
4704
|
}
|
|
4356
4705
|
|
|
4357
|
-
|
|
4706
|
+
// Each resource refreshes on its own claim. A slow or failing observer for one
|
|
4707
|
+
// must not delay or invalidate the other's readiness -- nothing about the
|
|
4708
|
+
// resources couples them. What still couples them is the shared HTTP permit and
|
|
4709
|
+
// any account-wide secondary hold, and those are enforced where they belong.
|
|
4710
|
+
// Core is refreshed last because its publication is what reopens data
|
|
4711
|
+
// admission. Finishing the cycle's control work before that happens stops a
|
|
4712
|
+
// pane that merely published the reset from getting a head start, over the
|
|
4713
|
+
// shared permit, on higher-priority work waiting for the same lane. It is also
|
|
4714
|
+
// the order readSharedBudgetSources reads its sources in, and the order the
|
|
4715
|
+
// single claim this replaced already used, so the sequence of actual HTTP
|
|
4716
|
+
// calls is unchanged.
|
|
4717
|
+
const BUDGET_REFRESH_ORDER = ["graphql", "core"];
|
|
4718
|
+
|
|
4719
|
+
async function refreshSharedBudget(scope, leaseId, signal, options = {}) {
|
|
4720
|
+
const outcomes = [];
|
|
4721
|
+
for (const resource of BUDGET_REFRESH_ORDER) {
|
|
4722
|
+
outcomes.push([resource, await refreshResourceBudget(scope, leaseId, signal, resource, options)]);
|
|
4723
|
+
}
|
|
4724
|
+
const published = outcomes.filter(([, result]) => result.ok);
|
|
4725
|
+
if (published.length === 0) return outcomes[0][1];
|
|
4726
|
+
const inspect = options.inspect ?? inspectGovernor;
|
|
4727
|
+
const now = options.now ?? (() => scopeNow(scope));
|
|
4728
|
+
const snapshot = inspect(scope, now());
|
|
4729
|
+
return {
|
|
4730
|
+
ok: true,
|
|
4731
|
+
value: {
|
|
4732
|
+
status: "published",
|
|
4733
|
+
budgets: snapshot.ok ? snapshot.value.budgets : {},
|
|
4734
|
+
resources: published.map(([resource]) => resource),
|
|
4735
|
+
},
|
|
4736
|
+
};
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
async function refreshResourceBudget(scope, leaseId, signal, resource, {
|
|
4358
4740
|
readBudgets = readSharedBudgetSources,
|
|
4359
4741
|
now = () => scopeNow(scope),
|
|
4360
4742
|
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -4363,7 +4745,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4363
4745
|
publish = publishProbe,
|
|
4364
4746
|
fail = failProbeClaim,
|
|
4365
4747
|
} = {}) {
|
|
4366
|
-
let claim = claimProbe(scope, leaseId, now());
|
|
4748
|
+
let claim = claimProbe(scope, leaseId, now(), resource);
|
|
4367
4749
|
if (!claim.ok) return claim;
|
|
4368
4750
|
if (claim.value.status !== "claimed") {
|
|
4369
4751
|
const startedAt = now();
|
|
@@ -4376,32 +4758,33 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4376
4758
|
inspections += 1;
|
|
4377
4759
|
const snapshot = inspect(scope, now());
|
|
4378
4760
|
if (!snapshot.ok) return snapshot;
|
|
4761
|
+
// Only this resource's freshness matters here. Waiting on the other's
|
|
4762
|
+
// observer is what made one failing lane stall an unrelated healthy one.
|
|
4379
4763
|
if (
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
) && snapshot.value.probeOutcome.status === "healthy"
|
|
4764
|
+
snapshot.value.budgets[resource] &&
|
|
4765
|
+
now() - snapshot.value.budgets[resource].observedAt <= budgetSnapshotTtl(resource) &&
|
|
4766
|
+
snapshot.value.observers[resource].outcome === "healthy"
|
|
4384
4767
|
) {
|
|
4385
4768
|
return { ok: true, value: { status: "published", budgets: snapshot.value.budgets } };
|
|
4386
4769
|
}
|
|
4387
4770
|
if (now() >= deadline || inspections >= 10) break;
|
|
4388
4771
|
await wait(Math.min(100, Math.max(1, deadline - now())));
|
|
4389
4772
|
}
|
|
4390
|
-
claim = claimProbe(scope, leaseId, now());
|
|
4773
|
+
claim = claimProbe(scope, leaseId, now(), resource);
|
|
4391
4774
|
if (!claim.ok || claim.value.status !== "claimed") return claim;
|
|
4392
4775
|
}
|
|
4393
|
-
const { nonce, startedReservationIds,
|
|
4776
|
+
const { nonce, startedReservationIds, coreEtag = null } = claim.value;
|
|
4394
4777
|
const drainUntil = now() + GOVERNOR_PROBE_DRAIN_MS;
|
|
4395
4778
|
while (startedReservationIds.length > 0 && now() < drainUntil) {
|
|
4396
4779
|
const snapshot = inspect(scope, now());
|
|
4397
4780
|
if (!snapshot.ok) {
|
|
4398
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4781
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4399
4782
|
return snapshot;
|
|
4400
4783
|
}
|
|
4401
4784
|
const stillStarted = startedReservationIds.some((id) => snapshot.value.reservations[id]?.status === "started");
|
|
4402
4785
|
if (!stillStarted) break;
|
|
4403
4786
|
if (signal?.aborted) {
|
|
4404
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4787
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4405
4788
|
return { ok: false, reason: "stale" };
|
|
4406
4789
|
}
|
|
4407
4790
|
await wait(Math.min(100, Math.max(1, drainUntil - now())));
|
|
@@ -4412,11 +4795,11 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4412
4795
|
renewalStartedAt + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4413
4796
|
);
|
|
4414
4797
|
const renewed = await retryGovernorMutation(
|
|
4415
|
-
() => renew(scope, leaseId, nonce, now()),
|
|
4798
|
+
() => renew(scope, leaseId, nonce, now(), resource),
|
|
4416
4799
|
{ now, wait, deadline: renewalDeadline, signal },
|
|
4417
4800
|
);
|
|
4418
4801
|
if (!renewed.ok) {
|
|
4419
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4802
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4420
4803
|
return renewed;
|
|
4421
4804
|
}
|
|
4422
4805
|
// Known exhaustion waits for its actual reset. The GraphQL observer costs a
|
|
@@ -4424,24 +4807,23 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4424
4807
|
// the hole deeper -- the /rate_limit read this replaced was free and could
|
|
4425
4808
|
// afford to be unconditional.
|
|
4426
4809
|
const beforeRead = inspect(scope, now());
|
|
4427
|
-
const
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4810
|
+
const exhausted = resource === "graphql" && beforeRead.ok &&
|
|
4811
|
+
beforeRead.value.budgets.graphql &&
|
|
4812
|
+
beforeRead.value.budgets.graphql.remaining <= 0 &&
|
|
4813
|
+
now() < beforeRead.value.budgets.graphql.resetMs + BUDGET_RESET_GRACE_MS;
|
|
4814
|
+
if (exhausted) {
|
|
4815
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4434
4816
|
return { ok: false, reason: "budget-reset" };
|
|
4435
4817
|
}
|
|
4436
4818
|
let budgets;
|
|
4437
4819
|
try {
|
|
4438
4820
|
budgets = await requestIdentityStorage.run(scope, () => readBudgets(signal, scope.host, {
|
|
4439
|
-
resources:
|
|
4821
|
+
resources: [resource],
|
|
4440
4822
|
coreEtag,
|
|
4441
4823
|
renewClaim: async () => {
|
|
4442
4824
|
const startedAt = now();
|
|
4443
4825
|
const extended = await retryGovernorMutation(
|
|
4444
|
-
() => renew(scope, leaseId, nonce, now()),
|
|
4826
|
+
() => renew(scope, leaseId, nonce, now(), resource),
|
|
4445
4827
|
{
|
|
4446
4828
|
now,
|
|
4447
4829
|
wait,
|
|
@@ -4462,7 +4844,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4462
4844
|
now() + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4463
4845
|
);
|
|
4464
4846
|
await retryGovernorMutation(
|
|
4465
|
-
() => fail(scope, leaseId, nonce, now()),
|
|
4847
|
+
() => fail(scope, leaseId, nonce, now(), resource),
|
|
4466
4848
|
{ now, wait, deadline: failureDeadline, signal },
|
|
4467
4849
|
);
|
|
4468
4850
|
return { ok: false, reason: "stale" };
|
|
@@ -4472,12 +4854,12 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4472
4854
|
now() + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4473
4855
|
);
|
|
4474
4856
|
const published = await retryGovernorMutation(
|
|
4475
|
-
() => publish(scope, leaseId, nonce, budgets, now()),
|
|
4857
|
+
() => publish(scope, leaseId, nonce, budgets, now(), resource),
|
|
4476
4858
|
{ now, wait, deadline: transitionDeadline, signal },
|
|
4477
4859
|
);
|
|
4478
4860
|
if (!published.ok) {
|
|
4479
4861
|
await retryGovernorMutation(
|
|
4480
|
-
() => fail(scope, leaseId, nonce, now()),
|
|
4862
|
+
() => fail(scope, leaseId, nonce, now(), resource),
|
|
4481
4863
|
{ now, wait, deadline: transitionDeadline, signal },
|
|
4482
4864
|
);
|
|
4483
4865
|
}
|
|
@@ -7499,14 +7881,20 @@ function retryPollAfterAdmissionFailure({
|
|
|
7499
7881
|
|
|
7500
7882
|
function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
7501
7883
|
const budgets = Object.values(state?.budgets ?? {});
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7884
|
+
// A failed observer's own retry, not its stale sample's cadence, says when
|
|
7885
|
+
// that resource is next worth reading. Suppress the cadence per resource: a
|
|
7886
|
+
// global suppression let one failure silence a healthy lane's wake, and no
|
|
7887
|
+
// suppression let the failed lane spin on the sample it already knows is old.
|
|
7888
|
+
const observedCandidates = RATE_RESOURCES.flatMap((resource) => {
|
|
7889
|
+
const budget = state?.budgets?.[resource];
|
|
7890
|
+
if (!budget || state?.observers?.[resource]?.outcome === "failed") return [];
|
|
7891
|
+
return [budget.observedAt + BUDGET_PROBE_MS];
|
|
7892
|
+
});
|
|
7505
7893
|
const controlAt = Math.min(
|
|
7506
7894
|
...observedCandidates,
|
|
7507
7895
|
...budgets.map((budget) => budget.resetMs + BUDGET_RESET_GRACE_MS),
|
|
7508
|
-
state?.
|
|
7509
|
-
state?.
|
|
7896
|
+
...RATE_RESOURCES.map((resource) => state?.probeClaims?.[resource]?.leaseUntil ?? Number.POSITIVE_INFINITY),
|
|
7897
|
+
...RATE_RESOURCES.map((resource) => state?.observers?.[resource]?.nextAt ?? Number.POSITIVE_INFINITY),
|
|
7510
7898
|
);
|
|
7511
7899
|
const reservationAt = Object.values(state?.reservations ?? {})
|
|
7512
7900
|
.filter((reservation) => reservation.status === "scheduled" &&
|
|
@@ -7521,7 +7909,8 @@ function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
|
7521
7909
|
function governorProtocolReady(refreshResult, snapshot, nowMs) {
|
|
7522
7910
|
if (!refreshResult?.ok || !snapshot?.ok) return false;
|
|
7523
7911
|
if (["waiting", "paused", "probe"].includes(refreshResult.value?.status)) return false;
|
|
7524
|
-
if (
|
|
7912
|
+
if (RATE_RESOURCES.some((resource) => snapshot.value.probeClaims[resource]) ||
|
|
7913
|
+
RATE_RESOURCES.some((resource) => snapshot.value.observers[resource]?.outcome !== "healthy")) return false;
|
|
7525
7914
|
return RATE_RESOURCES.every((resource) => {
|
|
7526
7915
|
const budget = snapshot.value.budgets[resource];
|
|
7527
7916
|
return budget && nowMs - budget.observedAt <= budgetSnapshotTtl(resource);
|
|
@@ -7691,7 +8080,7 @@ function rateLimitBlockDecision(results, resetMs) {
|
|
|
7691
8080
|
const IDENTITY_COORDINATION_REASONS = [
|
|
7692
8081
|
"restart-required", "migration-hold", "legacy-unresolved", "legacy-corrupt",
|
|
7693
8082
|
"credential-unavailable", "identity-backoff", "identity-busy",
|
|
7694
|
-
"identity-unavailable", "identity-capacity", "transport-busy",
|
|
8083
|
+
"identity-unavailable", "identity-capacity", "transport-busy", "throttle-paused",
|
|
7695
8084
|
];
|
|
7696
8085
|
|
|
7697
8086
|
function coordinationNotice(reason) {
|
|
@@ -10776,6 +11165,7 @@ export {
|
|
|
10776
11165
|
governorPhaseOffset,
|
|
10777
11166
|
scheduleIntents,
|
|
10778
11167
|
GOVERNOR_STATE_VERSION,
|
|
11168
|
+
RATE_RESOURCES,
|
|
10779
11169
|
GOVERNOR_MAX_LEASES,
|
|
10780
11170
|
GOVERNOR_MAX_INTENTS,
|
|
10781
11171
|
GOVERNOR_MAX_RESERVATIONS,
|
|
@@ -10801,6 +11191,16 @@ export {
|
|
|
10801
11191
|
heartbeatLease,
|
|
10802
11192
|
maintainControlLease,
|
|
10803
11193
|
claimProbe,
|
|
11194
|
+
classifyThrottle,
|
|
11195
|
+
applyTransportCooldown,
|
|
11196
|
+
clearTransportThrottle,
|
|
11197
|
+
emptyTransportThrottle,
|
|
11198
|
+
throttleLadderMs,
|
|
11199
|
+
transportCooldownDeadline,
|
|
11200
|
+
retryThrottledTransport,
|
|
11201
|
+
THROTTLE_LADDER_MS,
|
|
11202
|
+
THROTTLE_PAUSE_AFTER,
|
|
11203
|
+
MANUAL_GRANT_STREAK_LIMIT,
|
|
10804
11204
|
renewProbeClaim,
|
|
10805
11205
|
publishProbe,
|
|
10806
11206
|
failProbeClaim,
|