gh-glance 0.12.0 → 0.13.0
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 +55 -1
- package/index.mjs +545 -183
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Upgrading requires restarting older panes, and may wait for a quota reset.**
|
|
15
|
+
The coordination protocol moved from version 3 to 5. A pane still running an
|
|
16
|
+
older build holds a lease this one refuses to coordinate against, so it must
|
|
17
|
+
be closed; if that pane left API charges it never settled, admission waits for
|
|
18
|
+
the affected quota window to reset before spending against a counter it cannot
|
|
19
|
+
account for. Both are conservative on purpose, and both clear on their own.
|
|
20
|
+
- **A slow or failed budget observer no longer holds up the other resource.**
|
|
21
|
+
Core and GraphQL are claimed, published and retried independently. What still
|
|
22
|
+
couples them is the shared transport permit and any account-wide hold, which
|
|
23
|
+
are enforced where they belong.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Secondary rate limits back off on a bounded ladder.** A `Retry-After` the
|
|
28
|
+
server supplies is honoured exactly, in seconds or as an HTTP date, and
|
|
29
|
+
concurrent holds merge by maximum so a shorter error cannot shorten a longer
|
|
30
|
+
hold. Without a supplied deadline the client waits 60, 120, 240, 480 then 900
|
|
31
|
+
seconds by consecutive throttle; that cap applies only to the delay gh-glance
|
|
32
|
+
chooses, never to one the server asked for. After five consecutive throttles
|
|
33
|
+
it stops choosing and waits for an explicit refresh.
|
|
34
|
+
- **A run of manual refreshes yields a turn to a waiting tab.** Manual work
|
|
35
|
+
still outranks background polling, but after three consecutive grants an
|
|
36
|
+
active tab that has been waiting goes next, so holding `r` can no longer
|
|
37
|
+
starve the rest of the dashboard.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **A permission error no longer pauses every pane.** A 403 carrying no
|
|
42
|
+
rate-limit headers and no secondary-limit marker is a permission failure, not
|
|
43
|
+
a throttle. It previously held the shared transport, so one repository the
|
|
44
|
+
token could not read stalled every pane using that account.
|
|
45
|
+
- **The external-use estimator can come back down.** When other clients were
|
|
46
|
+
busy, gh-glance would raise its estimate of their share and then keep pacing
|
|
47
|
+
against it: each sampling window closed on publication even when the sample
|
|
48
|
+
was too small to reconcile, discarding the local accumulation that would
|
|
49
|
+
eventually have made one. A window now closes only when it actually
|
|
50
|
+
reconciles, and a new quota window starts from no assumed external use rather
|
|
51
|
+
than inheriting the previous window's ratio.
|
|
52
|
+
- **A request that cost nothing no longer paces as though it had.** Work is
|
|
53
|
+
paced by the cost it reserves, which is a worst case. A conditional request
|
|
54
|
+
answered `304` spends nothing, and cancelled work spends nothing at all -- the
|
|
55
|
+
unused pacing is now returned instead of leaving the next request waiting out
|
|
56
|
+
a slot nobody used.
|
|
57
|
+
- **Older coordination files are readable again.** The version 3 to 4 change
|
|
58
|
+
altered the file's shape, and the legacy reader recognised only the version
|
|
59
|
+
number, so every file an older pane had actually written was rejected as
|
|
60
|
+
corrupt -- exactly the file the restart boundary has to read to see that an
|
|
61
|
+
older pane is still running.
|
|
62
|
+
|
|
10
63
|
## [0.12.0] - 2026-09-06
|
|
11
64
|
|
|
12
65
|
### Added
|
|
@@ -851,7 +904,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
851
904
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
852
905
|
but importing it took over the terminal or exited the host process.
|
|
853
906
|
|
|
854
|
-
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.
|
|
907
|
+
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.13.0...HEAD
|
|
908
|
+
[0.13.0]: https://github.com/juan294/gh-glance/compare/v0.12.0...v0.13.0
|
|
855
909
|
[0.12.0]: https://github.com/juan294/gh-glance/compare/v0.11.2...v0.12.0
|
|
856
910
|
[0.11.2]: https://github.com/juan294/gh-glance/compare/v0.11.1...v0.11.2
|
|
857
911
|
[0.11.1]: https://github.com/juan294/gh-glance/compare/v0.11.0...v0.11.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,75 @@ 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
|
|
|
3297
3513
|
function migrateGovernorState(raw, nowMs) {
|
|
3298
3514
|
if (!exactKeys(raw, [
|
|
3299
|
-
"version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases",
|
|
3515
|
+
"version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases", // v1 shape
|
|
3300
3516
|
"intents", "reservations", "manualProbe",
|
|
3301
3517
|
]) || raw.version !== 1 || !isRecord(raw.budgets) || !isRecord(raw.reservations)) return null;
|
|
3302
3518
|
const migrated = structuredClone(raw);
|
|
@@ -3318,7 +3534,16 @@ function migrateGovernorState(raw, nowMs) {
|
|
|
3318
3534
|
for (const reservation of Object.values(migrated.reservations)) {
|
|
3319
3535
|
reservation.accountedCosts = { core: 0, graphql: 0 };
|
|
3320
3536
|
}
|
|
3321
|
-
|
|
3537
|
+
// v1 carried one claim for both resources; the new shape has one per resource
|
|
3538
|
+
// and a migrated claim belongs to neither, so it is dropped rather than
|
|
3539
|
+
// guessed at. The next probe re-claims what is actually due.
|
|
3540
|
+
delete migrated.probeClaim;
|
|
3541
|
+
delete migrated.probeOutcome;
|
|
3542
|
+
migrated.observers = Object.fromEntries(RATE_RESOURCES.map((resource) => [
|
|
3543
|
+
resource, { etag: null, outcome: "idle", at: 0, nextAt: 0 },
|
|
3544
|
+
]));
|
|
3545
|
+
migrated.probeClaims = Object.fromEntries(RATE_RESOURCES.map((resource) => [resource, null]));
|
|
3546
|
+
migrated.fairness = { manualStreak: 0 };
|
|
3322
3547
|
return normalizeGovernorState(migrated, nowMs);
|
|
3323
3548
|
}
|
|
3324
3549
|
|
|
@@ -3601,6 +3826,33 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
3601
3826
|
});
|
|
3602
3827
|
}
|
|
3603
3828
|
|
|
3829
|
+
// The largest single operation the governor will ever admit. Returned pacing is
|
|
3830
|
+
// capped at one of these so an idle stretch cannot accumulate into a burst: at
|
|
3831
|
+
// most one operation's worth of the lane is ever given back at a time.
|
|
3832
|
+
const GOVERNOR_MAX_ATOMIC_COST = Math.max(...Object.values(OPERATION_COSTS)
|
|
3833
|
+
.flatMap((costs) => RATE_RESOURCES.map((resource) => costs?.[resource] ?? 0)));
|
|
3834
|
+
|
|
3835
|
+
// A grant paces the lane by what it *reserved*, which is a worst case. When the
|
|
3836
|
+
// request costs less than that -- a 304 costs nothing -- or never happens at
|
|
3837
|
+
// all, the difference is capacity paced away for nobody, and the next request
|
|
3838
|
+
// waits out a slot no one used.
|
|
3839
|
+
//
|
|
3840
|
+
// The lane is never pulled earlier than the transport gap, which is what keeps
|
|
3841
|
+
// this from becoming a burst. Recomputing the rate here can differ from the
|
|
3842
|
+
// rate at grant time if capacity has since changed, so the returned credit is
|
|
3843
|
+
// an estimate rather than an exact reversal; that is safe because admission
|
|
3844
|
+
// still re-checks affordability against the reserve before anything starts.
|
|
3845
|
+
// Pacing decides when work may go, never whether it may.
|
|
3846
|
+
function returnPacingCredit(state, resource, unusedCost, nowMs) {
|
|
3847
|
+
const budget = state.budgets[resource];
|
|
3848
|
+
if (!budget || !(unusedCost > 0)) return;
|
|
3849
|
+
const decision = resourceDecision({ budget, resource, nowMs, cost: 0, chargedCost: 0 });
|
|
3850
|
+
const callsPerMs = decision?.callsPerMs;
|
|
3851
|
+
if (!Number.isFinite(callsPerMs) || callsPerMs <= 0) return;
|
|
3852
|
+
const credit = Math.min(unusedCost, GOVERNOR_MAX_ATOMIC_COST) / callsPerMs;
|
|
3853
|
+
budget.laneNextAt = Math.max(nowMs + HTTP_START_GAP_MS, budget.laneNextAt - credit);
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3604
3856
|
function scheduleGovernorState(state, nowMs) {
|
|
3605
3857
|
if (Object.keys(state.intents).length === 0) return { grants: [], denied: [] };
|
|
3606
3858
|
let reservationCount = Object.keys(state.reservations).length;
|
|
@@ -3612,7 +3864,7 @@ function scheduleGovernorState(state, nowMs) {
|
|
|
3612
3864
|
? [[resource, state.budgets[resource].roundRobinCursor]]
|
|
3613
3865
|
: [],
|
|
3614
3866
|
));
|
|
3615
|
-
const deferredBackground = state.
|
|
3867
|
+
const deferredBackground = state.observers.graphql.outcome === "failed"
|
|
3616
3868
|
? Object.entries(state.intents)
|
|
3617
3869
|
.filter(([, intent]) => intentPriority(intent) === REQUEST_PRIORITIES.background)
|
|
3618
3870
|
.map(([id]) => id)
|
|
@@ -3633,7 +3885,9 @@ function scheduleGovernorState(state, nowMs) {
|
|
|
3633
3885
|
cursors,
|
|
3634
3886
|
nowMs,
|
|
3635
3887
|
maxGrants: GOVERNOR_MAX_RESERVATIONS - reservationCount,
|
|
3888
|
+
manualStreak: state.fairness.manualStreak,
|
|
3636
3889
|
});
|
|
3890
|
+
state.fairness.manualStreak = result.manualStreak;
|
|
3637
3891
|
result.denied.push(...deferredBackground.map((intentId) => ({
|
|
3638
3892
|
intentId,
|
|
3639
3893
|
mode: "paused",
|
|
@@ -3720,76 +3974,79 @@ function maintainControlLease(scope, leaseId, floorMs, activeTab, nowMs) {
|
|
|
3720
3974
|
: registered;
|
|
3721
3975
|
}
|
|
3722
3976
|
|
|
3723
|
-
|
|
3977
|
+
// Claims the observer for one resource. Independence is the point: a slow or
|
|
3978
|
+
// failed GraphQL observer must not hold up core readiness, because nothing about
|
|
3979
|
+
// the resources themselves couples them. What still couples them is the shared
|
|
3980
|
+
// HTTP permit and any account-wide secondary hold, and those are enforced
|
|
3981
|
+
// elsewhere -- not by making one claim stand for both.
|
|
3982
|
+
function claimProbe(scope, leaseId, nowMs, resource) {
|
|
3983
|
+
if (!RATE_RESOURCES.includes(resource)) return { ok: false, reason: "corrupt" };
|
|
3724
3984
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3725
3985
|
if (!state.leases[leaseId]) return { ok: false, reason: "stale" };
|
|
3726
|
-
|
|
3727
|
-
|
|
3986
|
+
const held = state.probeClaims[resource];
|
|
3987
|
+
if (held && held.leaseUntil > at) {
|
|
3988
|
+
return { value: { status: "waiting", leaseUntil: held.leaseUntil } };
|
|
3728
3989
|
}
|
|
3990
|
+
const resetAt = Number.isFinite(state.budgets[resource]?.resetMs)
|
|
3991
|
+
? state.budgets[resource].resetMs + BUDGET_RESET_GRACE_MS
|
|
3992
|
+
: Number.POSITIVE_INFINITY;
|
|
3993
|
+
// A core reset starts a new shared accounting epoch, so the GraphQL counter
|
|
3994
|
+
// is also due then: external-spend baselines and the protected
|
|
3995
|
+
// one-publication reset contract have to advance together.
|
|
3729
3996
|
const coreResetAt = Number.isFinite(state.budgets.core?.resetMs)
|
|
3730
3997
|
? state.budgets.core.resetMs + BUDGET_RESET_GRACE_MS
|
|
3731
3998
|
: Number.POSITIVE_INFINITY;
|
|
3732
|
-
|
|
3733
|
-
|
|
3999
|
+
// ...but only until it has actually observed since that reset. Left
|
|
4000
|
+
// unqualified, every pane keeps qualifying for as long as core has not
|
|
4001
|
+
// published its new epoch, so a reset draws a stampede of GraphQL observers
|
|
4002
|
+
// instead of the one the shared epoch needs. The publication used to close
|
|
4003
|
+
// that window as a side effect of being read first; saying it here means it
|
|
4004
|
+
// does not depend on which resource a refresh happens to reach first.
|
|
4005
|
+
const dueAtCoreReset = resource === "graphql" &&
|
|
4006
|
+
state.observers.graphql.at < coreResetAt
|
|
4007
|
+
? coreResetAt
|
|
3734
4008
|
: 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));
|
|
4009
|
+
const nextAt = Math.min(
|
|
4010
|
+
state.observers[resource].nextAt,
|
|
4011
|
+
resetAt,
|
|
4012
|
+
dueAtCoreReset,
|
|
4013
|
+
);
|
|
3750
4014
|
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
4015
|
const nonce = randomUUID();
|
|
3756
4016
|
const startedReservationIds = Object.entries(state.reservations)
|
|
3757
4017
|
// An expired owner can no longer settle its request. Keep that uncertain
|
|
3758
4018
|
// cost charged, but do not make every later probe wait for it to finish.
|
|
4019
|
+
// Only work charging this resource is drained -- an unrelated lane's
|
|
4020
|
+
// outstanding request says nothing about this counter.
|
|
3759
4021
|
.filter(([, reservation]) => reservation.status === "started" &&
|
|
3760
|
-
state.leases[reservation.leaseId]?.expiresAt > at
|
|
4022
|
+
state.leases[reservation.leaseId]?.expiresAt > at &&
|
|
4023
|
+
reservationCost(reservation, resource, state.leases, at) > 0)
|
|
3761
4024
|
.map(([id]) => id);
|
|
3762
|
-
state.
|
|
4025
|
+
state.probeClaims[resource] = {
|
|
3763
4026
|
ownerLeaseId: leaseId,
|
|
3764
4027
|
nonce,
|
|
3765
4028
|
leaseUntil: at + GOVERNOR_PROBE_LEASE_MS,
|
|
3766
4029
|
nextAt: at,
|
|
3767
4030
|
claimAt: at,
|
|
3768
4031
|
startedReservationIds,
|
|
3769
|
-
resources,
|
|
3770
4032
|
};
|
|
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
|
-
}
|
|
4033
|
+
state.observers[resource].outcome = "waiting";
|
|
4034
|
+
state.observers[resource].at = at;
|
|
4035
|
+
state.observers[resource].nextAt = at + GOVERNOR_PROBE_LEASE_MS;
|
|
3779
4036
|
return { value: {
|
|
3780
4037
|
status: "claimed",
|
|
3781
4038
|
nonce,
|
|
3782
|
-
|
|
4039
|
+
resource,
|
|
4040
|
+
leaseUntil: state.probeClaims[resource].leaseUntil,
|
|
3783
4041
|
startedReservationIds,
|
|
3784
|
-
|
|
3785
|
-
coreEtag: state.observers.core.etag,
|
|
4042
|
+
coreEtag: state.observers[resource].etag,
|
|
3786
4043
|
} };
|
|
3787
4044
|
});
|
|
3788
4045
|
}
|
|
3789
4046
|
|
|
3790
|
-
function renewProbeClaim(scope, leaseId, nonce, nowMs) {
|
|
4047
|
+
function renewProbeClaim(scope, leaseId, nonce, nowMs, resource) {
|
|
3791
4048
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3792
|
-
const claim = state.
|
|
4049
|
+
const claim = state.probeClaims[resource];
|
|
3793
4050
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
|
|
3794
4051
|
return { ok: false, reason: "stale" };
|
|
3795
4052
|
}
|
|
@@ -3847,7 +4104,13 @@ function budgetFromObservation(raw, previous, nowMs, {
|
|
|
3847
4104
|
blockReason: blockActive ? previous.blockReason : null,
|
|
3848
4105
|
laneNextAt: epochChanged ? nowMs : previous.laneNextAt,
|
|
3849
4106
|
roundRobinCursor: previous?.roundRobinCursor ?? null,
|
|
3850
|
-
|
|
4107
|
+
// A new epoch is a new accounting window, so what other clients did in the
|
|
4108
|
+
// last one is not evidence about this one. The baseline and the local
|
|
4109
|
+
// accumulation below are already reset here; carrying the factor across
|
|
4110
|
+
// kept throttling this window on a ratio measured in a window that is over.
|
|
4111
|
+
// Only a claimed observer can open an epoch, so this cannot be moved by a
|
|
4112
|
+
// stale or reordered response.
|
|
4113
|
+
lastExternalFactor: epochChanged ? 1 : (previous?.lastExternalFactor ?? 1),
|
|
3851
4114
|
epoch,
|
|
3852
4115
|
source,
|
|
3853
4116
|
factorBaseline: epochChanged || !previous?.factorBaseline
|
|
@@ -3858,23 +4121,23 @@ function budgetFromObservation(raw, previous, nowMs, {
|
|
|
3858
4121
|
return { status: "accepted", budget: next, epochChanged };
|
|
3859
4122
|
}
|
|
3860
4123
|
|
|
3861
|
-
function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
4124
|
+
function publishProbe(scope, leaseId, nonce, budgets, nowMs, resource) {
|
|
3862
4125
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3863
|
-
const claim = state.
|
|
4126
|
+
const claim = state.probeClaims[resource];
|
|
3864
4127
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
|
|
3865
4128
|
return { ok: false, reason: "stale" };
|
|
3866
4129
|
}
|
|
3867
4130
|
const nextBudgets = { ...state.budgets };
|
|
3868
4131
|
const nextEpochs = { ...state.epochs };
|
|
4132
|
+
// Captured before the publication overwrites it: the reset this epoch
|
|
4133
|
+
// change supersedes is the boundary the GraphQL observer is measured against.
|
|
4134
|
+
const supersededCoreResetMs = state.budgets.core?.resetMs;
|
|
3869
4135
|
const changedResources = [];
|
|
3870
4136
|
const resetResources = [];
|
|
3871
|
-
if (!
|
|
3872
|
-
return { ok: false, reason: "corrupt" };
|
|
3873
|
-
}
|
|
4137
|
+
if (!budgets?.[resource]) return { ok: false, reason: "corrupt" };
|
|
3874
4138
|
const observerCosts = {};
|
|
3875
|
-
|
|
3876
|
-
const supplied = budgets
|
|
3877
|
-
if (!supplied) continue;
|
|
4139
|
+
{
|
|
4140
|
+
const supplied = budgets[resource];
|
|
3878
4141
|
const raw = supplied.budget ?? supplied;
|
|
3879
4142
|
// The owner source names what actually established the number. Calling a
|
|
3880
4143
|
// GraphQL budget a "rate-limit-probe" was accurate when /rate_limit supplied
|
|
@@ -3890,19 +4153,20 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3890
4153
|
allowEpochChange: true,
|
|
3891
4154
|
});
|
|
3892
4155
|
if (observed.status === "invalid") return { ok: false, reason: "corrupt" };
|
|
3893
|
-
if (observed.status
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
4156
|
+
if (observed.status !== "ignored") {
|
|
4157
|
+
// The observer's own cost is local spend. Left out, the external-factor
|
|
4158
|
+
// reconciliation below attributes it to other clients, and the governor
|
|
4159
|
+
// throttles the user's real work to make room for its own probing.
|
|
4160
|
+
observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
|
|
4161
|
+
nextBudgets[resource] = observed.budget;
|
|
4162
|
+
if (resource === "core" && supplied.blocked === true) {
|
|
4163
|
+
nextBudgets[resource].blockUntil = observed.budget.resetMs;
|
|
4164
|
+
nextBudgets[resource].blockReason = "rate-limit";
|
|
4165
|
+
}
|
|
4166
|
+
nextEpochs[resource] = observed.budget.epoch;
|
|
4167
|
+
changedResources.push(resource);
|
|
4168
|
+
if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
|
|
3902
4169
|
}
|
|
3903
|
-
nextEpochs[resource] = observed.budget.epoch;
|
|
3904
|
-
changedResources.push(resource);
|
|
3905
|
-
if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
|
|
3906
4170
|
}
|
|
3907
4171
|
if (changedResources.length === 0) return { ok: false, reason: "corrupt" };
|
|
3908
4172
|
const completedBeforeClaim = Object.entries(state.reservations)
|
|
@@ -3920,19 +4184,32 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3920
4184
|
total + (reservation.completedAt > baseline.observedAt
|
|
3921
4185
|
? reservationCost(reservation, resource, state.leases, at)
|
|
3922
4186
|
: 0), 0);
|
|
4187
|
+
const globalUsedDelta = nextBudgets[resource].used - baseline.used;
|
|
4188
|
+
const sharedCompletedDelta = previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0);
|
|
3923
4189
|
const factor = nextExternalFactor({
|
|
3924
4190
|
lastExternalFactor: previous.lastExternalFactor,
|
|
3925
|
-
globalUsedDelta
|
|
3926
|
-
sharedCompletedDelta
|
|
4191
|
+
globalUsedDelta,
|
|
4192
|
+
sharedCompletedDelta,
|
|
3927
4193
|
});
|
|
3928
4194
|
if (factor === null) return { ok: false, reason: "corrupt" };
|
|
3929
4195
|
nextBudgets[resource].lastExternalFactor = factor;
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
4196
|
+
// Closing the window costs the evidence in it, so only a window that
|
|
4197
|
+
// actually reconciled may close. Four local units observed twice are
|
|
4198
|
+
// eight, and eight are enough; discarding each four because neither
|
|
4199
|
+
// reached five on its own is what kept a factor of seven alive through
|
|
4200
|
+
// any amount of purely local spend.
|
|
4201
|
+
if (externalSampleIsUsable({ globalUsedDelta, sharedCompletedDelta })) {
|
|
4202
|
+
nextBudgets[resource].factorBaseline = {
|
|
4203
|
+
epoch: nextBudgets[resource].epoch,
|
|
4204
|
+
used: nextBudgets[resource].used,
|
|
4205
|
+
observedAt: nextBudgets[resource].observedAt,
|
|
4206
|
+
};
|
|
4207
|
+
nextBudgets[resource].knownLocalUsed = 0;
|
|
4208
|
+
}
|
|
4209
|
+
// Otherwise both are left exactly as the observation produced them, which
|
|
4210
|
+
// is the previous baseline and the previous local total. Rewriting them
|
|
4211
|
+
// here would count the same completed reservations twice: they are still
|
|
4212
|
+
// in the ledger precisely because the baseline did not advance past them.
|
|
3936
4213
|
}
|
|
3937
4214
|
for (const [id, reservation] of completedBeforeClaim) {
|
|
3938
4215
|
const accountedByEveryResource = RATE_RESOURCES.every((resource) =>
|
|
@@ -3942,24 +4219,41 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3942
4219
|
}
|
|
3943
4220
|
state.budgets = nextBudgets;
|
|
3944
4221
|
state.epochs = nextEpochs;
|
|
3945
|
-
state.
|
|
3946
|
-
|
|
3947
|
-
const
|
|
3948
|
-
state.observers
|
|
3949
|
-
etag: typeof budgets.
|
|
4222
|
+
state.probeClaims[resource] = null;
|
|
4223
|
+
{
|
|
4224
|
+
const published = nextBudgets[resource];
|
|
4225
|
+
state.observers[resource] = {
|
|
4226
|
+
etag: typeof budgets[resource].etag === "string" ? budgets[resource].etag : state.observers[resource].etag,
|
|
3950
4227
|
outcome: "healthy",
|
|
3951
4228
|
at,
|
|
3952
|
-
|
|
3953
|
-
|
|
4229
|
+
// A resource observed as empty is due again at its own reset, not at the
|
|
4230
|
+
// ordinary cadence: probing an exhausted counter to be told it is still
|
|
4231
|
+
// exhausted spends against it for nothing.
|
|
4232
|
+
nextAt: published.remaining === 0
|
|
4233
|
+
? published.resetMs + BUDGET_RESET_GRACE_MS
|
|
3954
4234
|
: at + BUDGET_PROBE_MS,
|
|
3955
4235
|
};
|
|
3956
4236
|
}
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
4237
|
+
// A core reset opens a new shared accounting epoch, so the GraphQL counter
|
|
4238
|
+
// is due with it: external-spend baselines and the protected
|
|
4239
|
+
// one-publication reset contract have to advance together. A refresh that
|
|
4240
|
+
// reads GraphQL before core has already done this, because core's old reset
|
|
4241
|
+
// time still said GraphQL was due when that claim was evaluated. What
|
|
4242
|
+
// cannot see it is a pane whose GraphQL observer last ran before the reset
|
|
4243
|
+
// -- another pane published this epoch first, moving the reset out of
|
|
4244
|
+
// reach. Only that case still owes an observation, so only that case is
|
|
4245
|
+
// pulled forward; marking it unconditionally spends a second point to
|
|
4246
|
+
// re-read a counter this same cycle has just read. An exhausted GraphQL
|
|
4247
|
+
// counter waits for its own reset either way.
|
|
4248
|
+
if (resource === "core" && resetResources.includes("core")) {
|
|
4249
|
+
const graphql = nextBudgets.graphql;
|
|
4250
|
+
const heldToReset = graphql && graphql.remaining === 0 &&
|
|
4251
|
+
at < graphql.resetMs + BUDGET_RESET_GRACE_MS;
|
|
4252
|
+
const observedSinceReset = Number.isFinite(supersededCoreResetMs) &&
|
|
4253
|
+
state.observers.graphql.at >= supersededCoreResetMs;
|
|
4254
|
+
if (!heldToReset && !observedSinceReset) {
|
|
4255
|
+
state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
|
|
4256
|
+
}
|
|
3963
4257
|
}
|
|
3964
4258
|
if (resetResources.length > 0) state.manualProbe = null;
|
|
3965
4259
|
else if (state.manualProbe &&
|
|
@@ -3976,24 +4270,22 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
3976
4270
|
});
|
|
3977
4271
|
}
|
|
3978
4272
|
|
|
3979
|
-
function failProbeClaim(scope, leaseId, nonce, nowMs) {
|
|
4273
|
+
function failProbeClaim(scope, leaseId, nonce, nowMs, resource) {
|
|
3980
4274
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
3981
|
-
const claim = state.
|
|
4275
|
+
const claim = state.probeClaims[resource];
|
|
3982
4276
|
if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce) {
|
|
3983
4277
|
return { ok: false, reason: "stale" };
|
|
3984
4278
|
}
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
state.observers
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
};
|
|
3996
|
-
}
|
|
4279
|
+
// Only this resource's readiness is affected. A failed GraphQL observer that
|
|
4280
|
+
// also marked core failed would erase authority core had legitimately
|
|
4281
|
+
// established, and stall a lane with nothing wrong with it.
|
|
4282
|
+
state.probeClaims[resource] = null;
|
|
4283
|
+
state.observers[resource] = {
|
|
4284
|
+
...state.observers[resource],
|
|
4285
|
+
outcome: "failed",
|
|
4286
|
+
at,
|
|
4287
|
+
nextAt: at + BUDGET_PROBE_MS,
|
|
4288
|
+
};
|
|
3997
4289
|
return { value: { retryAt: at + BUDGET_PROBE_MS } };
|
|
3998
4290
|
});
|
|
3999
4291
|
}
|
|
@@ -4015,7 +4307,7 @@ function requestManualProbe(scope, leaseId, epoch, observedAt, nowMs) {
|
|
|
4015
4307
|
: Math.min(state.observers.core.nextAt || at, at);
|
|
4016
4308
|
}
|
|
4017
4309
|
if (state.epochs.graphql === epoch) {
|
|
4018
|
-
state.
|
|
4310
|
+
state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
|
|
4019
4311
|
}
|
|
4020
4312
|
return { value: { status: "pending", ...state.manualProbe } };
|
|
4021
4313
|
});
|
|
@@ -4118,6 +4410,10 @@ function cancelIntent(scope, intentId, nowMs) {
|
|
|
4118
4410
|
return { ok: false, reason: "stale" };
|
|
4119
4411
|
}
|
|
4120
4412
|
delete state.reservations[reservationId];
|
|
4413
|
+
// The request never started, so the whole slot it was paced into is free.
|
|
4414
|
+
for (const resource of RATE_RESOURCES) {
|
|
4415
|
+
returnPacingCredit(state, resource, reservation.costs[resource], nowMs ?? Date.now());
|
|
4416
|
+
}
|
|
4121
4417
|
return { value: { status: "cancelled", intentId, reservationId } };
|
|
4122
4418
|
});
|
|
4123
4419
|
}
|
|
@@ -4131,8 +4427,14 @@ function startReservation(scope, reservationId, nowMs) {
|
|
|
4131
4427
|
return { ok: false, reason: "stale", write: Boolean(reservation) };
|
|
4132
4428
|
}
|
|
4133
4429
|
if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
|
|
4134
|
-
|
|
4135
|
-
|
|
4430
|
+
// Any live observer claim defers a start: the claim owns the shared HTTP
|
|
4431
|
+
// permit for its window, so starting here would contend for it.
|
|
4432
|
+
const liveClaim = RATE_RESOURCES
|
|
4433
|
+
.map((resource) => state.probeClaims[resource])
|
|
4434
|
+
.filter((claim) => claim && claim.leaseUntil > at)
|
|
4435
|
+
.sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
|
|
4436
|
+
if (liveClaim) {
|
|
4437
|
+
return { value: { status: "waiting", reason: "probe", notBefore: liveClaim.leaseUntil } };
|
|
4136
4438
|
}
|
|
4137
4439
|
for (const resource of RATE_RESOURCES.filter((name) => reservation.costs[name] > 0)) {
|
|
4138
4440
|
const budget = state.budgets[resource];
|
|
@@ -4267,6 +4569,15 @@ function settleReservationWithBudgetObservations(
|
|
|
4267
4569
|
acceptedAfterBaseline[resource],
|
|
4268
4570
|
);
|
|
4269
4571
|
}
|
|
4572
|
+
// Only a measured outcome knows what it spent. A timeout, abort or process
|
|
4573
|
+
// loss proves nothing, so its worst case stays charged and its pacing stays
|
|
4574
|
+
// spent -- refunding there would let a run of timeouts pace as though
|
|
4575
|
+
// nothing had been sent.
|
|
4576
|
+
if (completion.outcome === "measured-success") {
|
|
4577
|
+
for (const resource of RATE_RESOURCES) {
|
|
4578
|
+
returnPacingCredit(state, resource, reservation.costs[resource] - measured[resource], at);
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4270
4581
|
scheduleGovernorState(state, at);
|
|
4271
4582
|
return { value: {
|
|
4272
4583
|
status: "completed",
|
|
@@ -4289,8 +4600,8 @@ function recordResourceBlock(scope, resource, resetMs, reason) {
|
|
|
4289
4600
|
resetMs + BUDGET_RESET_GRACE_MS,
|
|
4290
4601
|
);
|
|
4291
4602
|
} else {
|
|
4292
|
-
state.
|
|
4293
|
-
state.
|
|
4603
|
+
state.observers.graphql.nextAt = Math.min(
|
|
4604
|
+
state.observers.graphql.nextAt || resetMs + BUDGET_RESET_GRACE_MS,
|
|
4294
4605
|
resetMs + BUDGET_RESET_GRACE_MS,
|
|
4295
4606
|
);
|
|
4296
4607
|
}
|
|
@@ -4326,7 +4637,7 @@ function governorHealth(result, nowMs = Date.now()) {
|
|
|
4326
4637
|
if (!result?.ok) return { status: "unavailable", leases: 0, resources: {} };
|
|
4327
4638
|
const state = result.value;
|
|
4328
4639
|
let status = "healthy";
|
|
4329
|
-
if (state.
|
|
4640
|
+
if (RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > nowMs)) status = "waiting for probe";
|
|
4330
4641
|
else if (RATE_RESOURCES.some((resource) => !state.budgets[resource] ||
|
|
4331
4642
|
nowMs - state.budgets[resource].observedAt > budgetSnapshotTtl(resource))) status = "stale";
|
|
4332
4643
|
else if (RATE_RESOURCES.some((resource) => state.budgets[resource].blockUntil > nowMs)) status = "blocked";
|
|
@@ -4354,7 +4665,40 @@ async function retryGovernorMutation(run, { now, wait, deadline, signal }) {
|
|
|
4354
4665
|
return result;
|
|
4355
4666
|
}
|
|
4356
4667
|
|
|
4357
|
-
|
|
4668
|
+
// Each resource refreshes on its own claim. A slow or failing observer for one
|
|
4669
|
+
// must not delay or invalidate the other's readiness -- nothing about the
|
|
4670
|
+
// resources couples them. What still couples them is the shared HTTP permit and
|
|
4671
|
+
// any account-wide secondary hold, and those are enforced where they belong.
|
|
4672
|
+
// Core is refreshed last because its publication is what reopens data
|
|
4673
|
+
// admission. Finishing the cycle's control work before that happens stops a
|
|
4674
|
+
// pane that merely published the reset from getting a head start, over the
|
|
4675
|
+
// shared permit, on higher-priority work waiting for the same lane. It is also
|
|
4676
|
+
// the order readSharedBudgetSources reads its sources in, and the order the
|
|
4677
|
+
// single claim this replaced already used, so the sequence of actual HTTP
|
|
4678
|
+
// calls is unchanged.
|
|
4679
|
+
const BUDGET_REFRESH_ORDER = ["graphql", "core"];
|
|
4680
|
+
|
|
4681
|
+
async function refreshSharedBudget(scope, leaseId, signal, options = {}) {
|
|
4682
|
+
const outcomes = [];
|
|
4683
|
+
for (const resource of BUDGET_REFRESH_ORDER) {
|
|
4684
|
+
outcomes.push([resource, await refreshResourceBudget(scope, leaseId, signal, resource, options)]);
|
|
4685
|
+
}
|
|
4686
|
+
const published = outcomes.filter(([, result]) => result.ok);
|
|
4687
|
+
if (published.length === 0) return outcomes[0][1];
|
|
4688
|
+
const inspect = options.inspect ?? inspectGovernor;
|
|
4689
|
+
const now = options.now ?? (() => scopeNow(scope));
|
|
4690
|
+
const snapshot = inspect(scope, now());
|
|
4691
|
+
return {
|
|
4692
|
+
ok: true,
|
|
4693
|
+
value: {
|
|
4694
|
+
status: "published",
|
|
4695
|
+
budgets: snapshot.ok ? snapshot.value.budgets : {},
|
|
4696
|
+
resources: published.map(([resource]) => resource),
|
|
4697
|
+
},
|
|
4698
|
+
};
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4701
|
+
async function refreshResourceBudget(scope, leaseId, signal, resource, {
|
|
4358
4702
|
readBudgets = readSharedBudgetSources,
|
|
4359
4703
|
now = () => scopeNow(scope),
|
|
4360
4704
|
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -4363,7 +4707,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4363
4707
|
publish = publishProbe,
|
|
4364
4708
|
fail = failProbeClaim,
|
|
4365
4709
|
} = {}) {
|
|
4366
|
-
let claim = claimProbe(scope, leaseId, now());
|
|
4710
|
+
let claim = claimProbe(scope, leaseId, now(), resource);
|
|
4367
4711
|
if (!claim.ok) return claim;
|
|
4368
4712
|
if (claim.value.status !== "claimed") {
|
|
4369
4713
|
const startedAt = now();
|
|
@@ -4376,32 +4720,33 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4376
4720
|
inspections += 1;
|
|
4377
4721
|
const snapshot = inspect(scope, now());
|
|
4378
4722
|
if (!snapshot.ok) return snapshot;
|
|
4723
|
+
// Only this resource's freshness matters here. Waiting on the other's
|
|
4724
|
+
// observer is what made one failing lane stall an unrelated healthy one.
|
|
4379
4725
|
if (
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
) && snapshot.value.probeOutcome.status === "healthy"
|
|
4726
|
+
snapshot.value.budgets[resource] &&
|
|
4727
|
+
now() - snapshot.value.budgets[resource].observedAt <= budgetSnapshotTtl(resource) &&
|
|
4728
|
+
snapshot.value.observers[resource].outcome === "healthy"
|
|
4384
4729
|
) {
|
|
4385
4730
|
return { ok: true, value: { status: "published", budgets: snapshot.value.budgets } };
|
|
4386
4731
|
}
|
|
4387
4732
|
if (now() >= deadline || inspections >= 10) break;
|
|
4388
4733
|
await wait(Math.min(100, Math.max(1, deadline - now())));
|
|
4389
4734
|
}
|
|
4390
|
-
claim = claimProbe(scope, leaseId, now());
|
|
4735
|
+
claim = claimProbe(scope, leaseId, now(), resource);
|
|
4391
4736
|
if (!claim.ok || claim.value.status !== "claimed") return claim;
|
|
4392
4737
|
}
|
|
4393
|
-
const { nonce, startedReservationIds,
|
|
4738
|
+
const { nonce, startedReservationIds, coreEtag = null } = claim.value;
|
|
4394
4739
|
const drainUntil = now() + GOVERNOR_PROBE_DRAIN_MS;
|
|
4395
4740
|
while (startedReservationIds.length > 0 && now() < drainUntil) {
|
|
4396
4741
|
const snapshot = inspect(scope, now());
|
|
4397
4742
|
if (!snapshot.ok) {
|
|
4398
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4743
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4399
4744
|
return snapshot;
|
|
4400
4745
|
}
|
|
4401
4746
|
const stillStarted = startedReservationIds.some((id) => snapshot.value.reservations[id]?.status === "started");
|
|
4402
4747
|
if (!stillStarted) break;
|
|
4403
4748
|
if (signal?.aborted) {
|
|
4404
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4749
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4405
4750
|
return { ok: false, reason: "stale" };
|
|
4406
4751
|
}
|
|
4407
4752
|
await wait(Math.min(100, Math.max(1, drainUntil - now())));
|
|
@@ -4412,11 +4757,11 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4412
4757
|
renewalStartedAt + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4413
4758
|
);
|
|
4414
4759
|
const renewed = await retryGovernorMutation(
|
|
4415
|
-
() => renew(scope, leaseId, nonce, now()),
|
|
4760
|
+
() => renew(scope, leaseId, nonce, now(), resource),
|
|
4416
4761
|
{ now, wait, deadline: renewalDeadline, signal },
|
|
4417
4762
|
);
|
|
4418
4763
|
if (!renewed.ok) {
|
|
4419
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4764
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4420
4765
|
return renewed;
|
|
4421
4766
|
}
|
|
4422
4767
|
// Known exhaustion waits for its actual reset. The GraphQL observer costs a
|
|
@@ -4424,24 +4769,23 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4424
4769
|
// the hole deeper -- the /rate_limit read this replaced was free and could
|
|
4425
4770
|
// afford to be unconditional.
|
|
4426
4771
|
const beforeRead = inspect(scope, now());
|
|
4427
|
-
const
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
failProbeClaim(scope, leaseId, nonce, now());
|
|
4772
|
+
const exhausted = resource === "graphql" && beforeRead.ok &&
|
|
4773
|
+
beforeRead.value.budgets.graphql &&
|
|
4774
|
+
beforeRead.value.budgets.graphql.remaining <= 0 &&
|
|
4775
|
+
now() < beforeRead.value.budgets.graphql.resetMs + BUDGET_RESET_GRACE_MS;
|
|
4776
|
+
if (exhausted) {
|
|
4777
|
+
failProbeClaim(scope, leaseId, nonce, now(), resource);
|
|
4434
4778
|
return { ok: false, reason: "budget-reset" };
|
|
4435
4779
|
}
|
|
4436
4780
|
let budgets;
|
|
4437
4781
|
try {
|
|
4438
4782
|
budgets = await requestIdentityStorage.run(scope, () => readBudgets(signal, scope.host, {
|
|
4439
|
-
resources:
|
|
4783
|
+
resources: [resource],
|
|
4440
4784
|
coreEtag,
|
|
4441
4785
|
renewClaim: async () => {
|
|
4442
4786
|
const startedAt = now();
|
|
4443
4787
|
const extended = await retryGovernorMutation(
|
|
4444
|
-
() => renew(scope, leaseId, nonce, now()),
|
|
4788
|
+
() => renew(scope, leaseId, nonce, now(), resource),
|
|
4445
4789
|
{
|
|
4446
4790
|
now,
|
|
4447
4791
|
wait,
|
|
@@ -4462,7 +4806,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4462
4806
|
now() + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4463
4807
|
);
|
|
4464
4808
|
await retryGovernorMutation(
|
|
4465
|
-
() => fail(scope, leaseId, nonce, now()),
|
|
4809
|
+
() => fail(scope, leaseId, nonce, now(), resource),
|
|
4466
4810
|
{ now, wait, deadline: failureDeadline, signal },
|
|
4467
4811
|
);
|
|
4468
4812
|
return { ok: false, reason: "stale" };
|
|
@@ -4472,12 +4816,12 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
4472
4816
|
now() + GOVERNOR_PROBE_TRANSITION_MS,
|
|
4473
4817
|
);
|
|
4474
4818
|
const published = await retryGovernorMutation(
|
|
4475
|
-
() => publish(scope, leaseId, nonce, budgets, now()),
|
|
4819
|
+
() => publish(scope, leaseId, nonce, budgets, now(), resource),
|
|
4476
4820
|
{ now, wait, deadline: transitionDeadline, signal },
|
|
4477
4821
|
);
|
|
4478
4822
|
if (!published.ok) {
|
|
4479
4823
|
await retryGovernorMutation(
|
|
4480
|
-
() => fail(scope, leaseId, nonce, now()),
|
|
4824
|
+
() => fail(scope, leaseId, nonce, now(), resource),
|
|
4481
4825
|
{ now, wait, deadline: transitionDeadline, signal },
|
|
4482
4826
|
);
|
|
4483
4827
|
}
|
|
@@ -7499,14 +7843,20 @@ function retryPollAfterAdmissionFailure({
|
|
|
7499
7843
|
|
|
7500
7844
|
function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
7501
7845
|
const budgets = Object.values(state?.budgets ?? {});
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7846
|
+
// A failed observer's own retry, not its stale sample's cadence, says when
|
|
7847
|
+
// that resource is next worth reading. Suppress the cadence per resource: a
|
|
7848
|
+
// global suppression let one failure silence a healthy lane's wake, and no
|
|
7849
|
+
// suppression let the failed lane spin on the sample it already knows is old.
|
|
7850
|
+
const observedCandidates = RATE_RESOURCES.flatMap((resource) => {
|
|
7851
|
+
const budget = state?.budgets?.[resource];
|
|
7852
|
+
if (!budget || state?.observers?.[resource]?.outcome === "failed") return [];
|
|
7853
|
+
return [budget.observedAt + BUDGET_PROBE_MS];
|
|
7854
|
+
});
|
|
7505
7855
|
const controlAt = Math.min(
|
|
7506
7856
|
...observedCandidates,
|
|
7507
7857
|
...budgets.map((budget) => budget.resetMs + BUDGET_RESET_GRACE_MS),
|
|
7508
|
-
state?.
|
|
7509
|
-
state?.
|
|
7858
|
+
...RATE_RESOURCES.map((resource) => state?.probeClaims?.[resource]?.leaseUntil ?? Number.POSITIVE_INFINITY),
|
|
7859
|
+
...RATE_RESOURCES.map((resource) => state?.observers?.[resource]?.nextAt ?? Number.POSITIVE_INFINITY),
|
|
7510
7860
|
);
|
|
7511
7861
|
const reservationAt = Object.values(state?.reservations ?? {})
|
|
7512
7862
|
.filter((reservation) => reservation.status === "scheduled" &&
|
|
@@ -7521,7 +7871,8 @@ function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
|
7521
7871
|
function governorProtocolReady(refreshResult, snapshot, nowMs) {
|
|
7522
7872
|
if (!refreshResult?.ok || !snapshot?.ok) return false;
|
|
7523
7873
|
if (["waiting", "paused", "probe"].includes(refreshResult.value?.status)) return false;
|
|
7524
|
-
if (
|
|
7874
|
+
if (RATE_RESOURCES.some((resource) => snapshot.value.probeClaims[resource]) ||
|
|
7875
|
+
RATE_RESOURCES.some((resource) => snapshot.value.observers[resource]?.outcome !== "healthy")) return false;
|
|
7525
7876
|
return RATE_RESOURCES.every((resource) => {
|
|
7526
7877
|
const budget = snapshot.value.budgets[resource];
|
|
7527
7878
|
return budget && nowMs - budget.observedAt <= budgetSnapshotTtl(resource);
|
|
@@ -7691,7 +8042,7 @@ function rateLimitBlockDecision(results, resetMs) {
|
|
|
7691
8042
|
const IDENTITY_COORDINATION_REASONS = [
|
|
7692
8043
|
"restart-required", "migration-hold", "legacy-unresolved", "legacy-corrupt",
|
|
7693
8044
|
"credential-unavailable", "identity-backoff", "identity-busy",
|
|
7694
|
-
"identity-unavailable", "identity-capacity", "transport-busy",
|
|
8045
|
+
"identity-unavailable", "identity-capacity", "transport-busy", "throttle-paused",
|
|
7695
8046
|
];
|
|
7696
8047
|
|
|
7697
8048
|
function coordinationNotice(reason) {
|
|
@@ -10776,6 +11127,7 @@ export {
|
|
|
10776
11127
|
governorPhaseOffset,
|
|
10777
11128
|
scheduleIntents,
|
|
10778
11129
|
GOVERNOR_STATE_VERSION,
|
|
11130
|
+
RATE_RESOURCES,
|
|
10779
11131
|
GOVERNOR_MAX_LEASES,
|
|
10780
11132
|
GOVERNOR_MAX_INTENTS,
|
|
10781
11133
|
GOVERNOR_MAX_RESERVATIONS,
|
|
@@ -10801,6 +11153,16 @@ export {
|
|
|
10801
11153
|
heartbeatLease,
|
|
10802
11154
|
maintainControlLease,
|
|
10803
11155
|
claimProbe,
|
|
11156
|
+
classifyThrottle,
|
|
11157
|
+
applyTransportCooldown,
|
|
11158
|
+
clearTransportThrottle,
|
|
11159
|
+
emptyTransportThrottle,
|
|
11160
|
+
throttleLadderMs,
|
|
11161
|
+
transportCooldownDeadline,
|
|
11162
|
+
retryThrottledTransport,
|
|
11163
|
+
THROTTLE_LADDER_MS,
|
|
11164
|
+
THROTTLE_PAUSE_AFTER,
|
|
11165
|
+
MANUAL_GRANT_STREAK_LIMIT,
|
|
10804
11166
|
renewProbeClaim,
|
|
10805
11167
|
publishProbe,
|
|
10806
11168
|
failProbeClaim,
|
package/package.json
CHANGED