@edgehero/pi-dispatch 2.1.0 → 3.0.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/.env.example +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +8 -1
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2261 -203
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +107 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +135 -7
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegated allocation, the stateful half (issue #504 part B; DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE,
|
|
3
|
+
* REQ-DELEGATED-ALLOCATION). `priorities.mjs` is the arithmetic and `envelope.mjs` the operator's file; this module
|
|
4
|
+
* holds the APPLIED split in Valkey, applies a plan under the ladder, writes the audit, and turns the split into the
|
|
5
|
+
* dollar ledgers a job reserves in.
|
|
6
|
+
*
|
|
7
|
+
* Valkey keys:
|
|
8
|
+
* - `alloc:plan` (`ALLOC_PLAN_KEY`): the applied state as JSON, the effective micro-dollar vector a job reserves
|
|
9
|
+
* against and the "current" of the next step. Always present once any host has looked: the neutral state (the
|
|
10
|
+
* envelope's default weights, no plan) is PERSISTED with `SET NX`, writer `default`, so a reference always exists.
|
|
11
|
+
* - `alloc:lock` (`ALLOC_LOCK_KEY`): `SET NX PX 5000`, the `fleet-lease.mjs` idiom, released only by its holder. It
|
|
12
|
+
* exists ONLY so a concurrent apply refuses as `plan-busy`; it is not what keeps two writes apart.
|
|
13
|
+
* - `alloc:envelope:expected` (`ALLOC_EXPECTED_KEY`): the envelope digest the operator's admin writer stores before
|
|
14
|
+
* it writes the file (issue #504 part C). A host re-bases the fleet only when its own digest equals it.
|
|
15
|
+
* - `alloc:log` (`ALLOC_LOG_KEY`): a VIEW, `LPUSH` then `LTRIM` to `ALLOC_LOG_MAX`, of the audit rows without reasons.
|
|
16
|
+
*
|
|
17
|
+
* EVERY change of `alloc:plan` is a compare-and-set (`CAS_SCRIPT`): a Lua script that compares the stored plan id and
|
|
18
|
+
* envelope digest with the ones the writer read, and writes in the same step. A lock alone would not do: a lock that
|
|
19
|
+
* expires mid-write lets a second writer overwrite the first with no error, while a CAS makes the write itself the
|
|
20
|
+
* check. Expiry (writer `expiry`), a re-base (`envelope-change`) and a revert (`operator-revert`) go through it too.
|
|
21
|
+
*
|
|
22
|
+
* The audit FILE is the record (`PI_LOGS_DIR/allocations/YYYY-MM.jsonl` on the host that acted, appended with
|
|
23
|
+
* O_APPEND). For a plan or a revert the row is written FIRST, then the CAS, and a CAS that fails then adds an
|
|
24
|
+
* `apply-failed` row: a writer's change never exists without its row, and a row whose change did not happen is
|
|
25
|
+
* followed by the row that says so (unless the CAS itself throws, when that second row is not written: a named gap).
|
|
26
|
+
* For the system's own changes (the neutral seed, expiry, a re-base) only the CAS winner writes, after it won, so two
|
|
27
|
+
* hosts that see one expired plan write exactly one `expired` row; a row the file then refuses is lost (the change is
|
|
28
|
+
* done and a retry cannot write it back), logged as `allocation_audit_row_lost` and still pushed to `alloc:log`.
|
|
29
|
+
*
|
|
30
|
+
* Reasons are agent text. They live in `alloc:plan` and the audit file only, never in `alloc:log`, a tool result, a
|
|
31
|
+
* run record or a log line.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import * as nodeFs from "node:fs";
|
|
36
|
+
import { isAbsolute, join } from "node:path";
|
|
37
|
+
import { envelopeDigest } from "./envelope.mjs";
|
|
38
|
+
import { RELEASE_IF_MINE } from "./fleet-lease.mjs";
|
|
39
|
+
import { OTHER, allocate, envelopeEntries, neutralAllocation, parsePlan, planRefusal, rebase, repoShares, scopeRef } from "./priorities.mjs";
|
|
40
|
+
import { dollarCapsFor, scopeDollarKeyPrefix } from "./scoped-limits.mjs";
|
|
41
|
+
|
|
42
|
+
export const ALLOC_PLAN_KEY = "alloc:plan";
|
|
43
|
+
export const ALLOC_LOCK_KEY = "alloc:lock";
|
|
44
|
+
export const ALLOC_LOG_KEY = "alloc:log";
|
|
45
|
+
export const ALLOC_EXPECTED_KEY = "alloc:envelope:expected";
|
|
46
|
+
/** How many outcomes `alloc:log` keeps, newest first. A view; the files keep everything their retention allows. */
|
|
47
|
+
export const ALLOC_LOG_MAX = 500;
|
|
48
|
+
/** The lock's life. Long enough for one apply's GET, compute, file append and CAS; short enough that a crash frees it. */
|
|
49
|
+
export const ALLOC_LOCK_MS = 5000;
|
|
50
|
+
/** The state record's shape version. */
|
|
51
|
+
export const ALLOC_STATE_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a host publishes as `fpEnvelope` (INT-HOST-REGISTRY-CONTRACT) with no envelope: a name, never empty, so doctor can
|
|
55
|
+
* tell a host without an envelope from a worker too old to publish the field.
|
|
56
|
+
*/
|
|
57
|
+
export const NO_ENVELOPE_FINGERPRINT = "none";
|
|
58
|
+
|
|
59
|
+
/** The refusal reason of a full window whose binding cap came from the allocation (or the envelope total). */
|
|
60
|
+
export const ALLOCATION_CAP_REASON = "allocation-cap";
|
|
61
|
+
/** The free pre-spend refusal of a host whose envelope digest is not the applied plan's. */
|
|
62
|
+
export const ENVELOPE_MISMATCH_REASON = "envelope-mismatch";
|
|
63
|
+
|
|
64
|
+
/** Who changed the state: the two plan writers, the operator's revert, and the system's own three. */
|
|
65
|
+
export const STATE_WRITERS = Object.freeze(["default", "expiry", "envelope-change", "operator-revert", "operator-session", "portfolio-job"]);
|
|
66
|
+
/** What an audit row says happened. */
|
|
67
|
+
export const AUDIT_OUTCOMES = Object.freeze(["applied", "duplicate", "refused", "apply-failed", "neutral", "expired", "rebased", "reverted", "envelope-changed-externally"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The compare-and-set. KEYS[1] `alloc:plan`; ARGV[1] the plan id the writer read (`""` for none), ARGV[2] the envelope
|
|
71
|
+
* digest it read, ARGV[3] the new state. Writes and returns 1 only when the stored state still has both; else 0. A
|
|
72
|
+
* stored value that does not decode is never overwritten here (`SEED_SCRIPT` is for that).
|
|
73
|
+
*/
|
|
74
|
+
export const CAS_SCRIPT = `local cur = redis.call('GET', KEYS[1])
|
|
75
|
+
if not cur then return 0 end
|
|
76
|
+
local ok, s = pcall(cjson.decode, cur)
|
|
77
|
+
if not ok or type(s) ~= 'table' then return 0 end
|
|
78
|
+
local id = s['planId']
|
|
79
|
+
if type(id) ~= 'string' then id = '' end
|
|
80
|
+
if id ~= ARGV[1] then return 0 end
|
|
81
|
+
if s['envelopeDigest'] ~= ARGV[2] then return 0 end
|
|
82
|
+
redis.call('SET', KEYS[1], ARGV[3])
|
|
83
|
+
return 1`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The neutral seed and its expected digest, in ONE step. KEYS[1] `alloc:plan`, KEYS[2] `alloc:envelope:expected`;
|
|
87
|
+
* ARGV[1] the seed, ARGV[2] its envelope digest, ARGV[3] `nx` (no state: `SET NX`) or `eq` (a state that does not
|
|
88
|
+
* decode: replace it only while it is still exactly ARGV[4]). A FRESH seed (`nx`) sets `expected` to its digest
|
|
89
|
+
* UNCONDITIONALLY: a stale `expected` left by an earlier split (deleted with `alloc:plan` alone) must not survive a new
|
|
90
|
+
* seed, or a host carrying that old envelope would re-base the fleet onto it. A REPLACEMENT (`eq`) sets it only when it
|
|
91
|
+
* is absent: the plan was there and unreadable, so the `expected` beside it still names the fleet's envelope, and a
|
|
92
|
+
* stale host that happens to reconcile first must not take the fleet. Returns 1 when the seed landed, else 0.
|
|
93
|
+
*/
|
|
94
|
+
export const SEED_SCRIPT = `if ARGV[3] == 'nx' then
|
|
95
|
+
if redis.call('SET', KEYS[1], ARGV[1], 'NX') then redis.call('SET', KEYS[2], ARGV[2]) return 1 end
|
|
96
|
+
return 0
|
|
97
|
+
end
|
|
98
|
+
if redis.call('GET', KEYS[1]) == ARGV[4] then
|
|
99
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
100
|
+
redis.call('SET', KEYS[2], ARGV[2], 'NX')
|
|
101
|
+
return 1
|
|
102
|
+
end
|
|
103
|
+
return 0`;
|
|
104
|
+
|
|
105
|
+
function iso(now) {
|
|
106
|
+
return new Date(now instanceof Date ? now.getTime() : now).toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nowMs(now) {
|
|
110
|
+
return now instanceof Date ? now.getTime() : now;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A state's vector, as `allocate` and `rebase` take it. */
|
|
114
|
+
function vectorOf(state) {
|
|
115
|
+
return { allocations: { ...(state?.allocations ?? {}) }, unallocated: state?.unallocated ?? 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The micro-dollars of a state, for an audit row's `before` and `after`: amounts only, never a reason. */
|
|
119
|
+
function amountsOf(state) {
|
|
120
|
+
if (!state) return null;
|
|
121
|
+
return { allocations: { ...(state.allocations ?? {}) }, unallocated: state.unallocated ?? 0, repos: state.repos ?? {} };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The neutral state of an envelope: its default weights, no step, no plan. `writer` is `default` for the seed and
|
|
126
|
+
* `expiry` or `envelope-change` when a plan gives way to it. `lastPlanAt` carries over from the state it replaces
|
|
127
|
+
* (null for the seed), so neutral never starts an interval of its own.
|
|
128
|
+
*/
|
|
129
|
+
export function neutralState(envelope, digest, now, { writer = "default", lastPlanAt = null } = {}) {
|
|
130
|
+
const n = neutralAllocation(envelope);
|
|
131
|
+
return {
|
|
132
|
+
version: ALLOC_STATE_VERSION,
|
|
133
|
+
planId: null,
|
|
134
|
+
basis: null,
|
|
135
|
+
writer,
|
|
136
|
+
jobId: null,
|
|
137
|
+
triggerId: null,
|
|
138
|
+
appliedAt: iso(now),
|
|
139
|
+
lastPlanAt,
|
|
140
|
+
validUntil: null,
|
|
141
|
+
envelopeDigest: digest,
|
|
142
|
+
weights: { ...envelope.defaultWeights },
|
|
143
|
+
repoWeights: {},
|
|
144
|
+
reasons: {},
|
|
145
|
+
allocations: n.allocations,
|
|
146
|
+
unallocated: n.unallocated,
|
|
147
|
+
repos: {},
|
|
148
|
+
clamped: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A safe, non-negative integer of micro-dollars: the only amount a state may hold. */
|
|
153
|
+
function isMicros(value) {
|
|
154
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every amount of a state is micro-dollars: the allocations, the unallocated money and each repo share. */
|
|
158
|
+
function amountsAreMicros(s) {
|
|
159
|
+
const values = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.values(o) : null);
|
|
160
|
+
const allocations = values(s.allocations);
|
|
161
|
+
// Every vector carries `_other` (envelope.mjs always adds it), so an allocations object without it is no vector.
|
|
162
|
+
if (!allocations || !allocations.every(isMicros) || !Object.hasOwn(s.allocations, OTHER)) return false;
|
|
163
|
+
if (!isMicros(s.unallocated ?? 0)) return false;
|
|
164
|
+
const repos = values(s.repos ?? {});
|
|
165
|
+
return repos !== null && repos.every((r) => values(r)?.every(isMicros) === true);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a stored state. Null when absent; the state when it is this build's version with a digest and amounts that
|
|
170
|
+
* are all safe non-negative integers of micro-dollars; `{ newer: true }` for a state a NEWER build wrote (version above
|
|
171
|
+
* `ALLOC_STATE_VERSION`), which this build neither trusts nor overwrites; else `{ corrupt: text }`, which the next
|
|
172
|
+
* reconcile replaces with the neutral split. A string, a negative, a fraction or a missing allocation is corrupt: an
|
|
173
|
+
* amount the reserve would compare against as a number it is not.
|
|
174
|
+
*/
|
|
175
|
+
export function parseState(text) {
|
|
176
|
+
if (text === null || text === undefined) return null;
|
|
177
|
+
try {
|
|
178
|
+
const s = JSON.parse(text);
|
|
179
|
+
if (s && typeof s === "object" && Number.isInteger(s.version) && s.version > ALLOC_STATE_VERSION) return { newer: true };
|
|
180
|
+
if (s && typeof s === "object" && s.version === ALLOC_STATE_VERSION && typeof s.envelopeDigest === "string" && amountsAreMicros(s)) return s;
|
|
181
|
+
} catch {}
|
|
182
|
+
return { corrupt: String(text) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The audit row shared by the file and `alloc:log`. `reasons` is added for the file alone. */
|
|
186
|
+
function auditRow({ at, host, writer, outcome, reason = null, field = null, rule = null, planId = null, basis = null, weights = null, repoWeights = null, before = null, after = null, clamped = null, envelopeDigest = null }) {
|
|
187
|
+
return {
|
|
188
|
+
v: 1,
|
|
189
|
+
at,
|
|
190
|
+
host,
|
|
191
|
+
writer: writer?.kind ?? null,
|
|
192
|
+
jobId: writer?.jobId ?? null,
|
|
193
|
+
triggerId: writer?.triggerId ?? null,
|
|
194
|
+
outcome,
|
|
195
|
+
reason,
|
|
196
|
+
...(field ? { field, rule } : {}),
|
|
197
|
+
planId,
|
|
198
|
+
basis,
|
|
199
|
+
weights,
|
|
200
|
+
repoWeights,
|
|
201
|
+
before,
|
|
202
|
+
after,
|
|
203
|
+
clamped,
|
|
204
|
+
envelopeDigest,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The audit file writer (`PI_LOGS_DIR/allocations/YYYY-MM.jsonl`, the UTC month of the row). Appended with the `a`
|
|
210
|
+
* flag, which is O_APPEND: one row is one write, and rows from two processes on one host never interleave inside a
|
|
211
|
+
* line. Created 0700 and 0600, like the run history. `append` THROWS on a failure: the file is the record, so a plan
|
|
212
|
+
* whose row cannot be written does not apply.
|
|
213
|
+
*/
|
|
214
|
+
export function makeAllocationAudit({ logsDir, fs = nodeFs }) {
|
|
215
|
+
const dir = join(logsDir, "allocations");
|
|
216
|
+
return {
|
|
217
|
+
dir,
|
|
218
|
+
append(row) {
|
|
219
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
220
|
+
fs.appendFileSync(join(dir, `${String(row.at).slice(0, 7)}.jsonl`), `${JSON.stringify(row)}\n`, { flag: "a", mode: 0o600 });
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The reaper of the audit files (`allocations/YYYY-MM.jsonl`), on `PI_LOG_RETENTION_DAYS` like the run history: a file
|
|
227
|
+
* whose last write (mtime) is older than the retention goes. 0 keeps everything. A sibling of `makeLogReaper`, which
|
|
228
|
+
* reaps only the top-level `.log` and `.json` files and never descends. Only names of the `YYYY-MM.jsonl` shape are
|
|
229
|
+
* touched, so nothing an operator put there is removed. Never throws.
|
|
230
|
+
*/
|
|
231
|
+
export function makeAllocationLogReaper({ logsDir, retentionDays, fs = nodeFs, log = () => {}, now = () => Date.now() }) {
|
|
232
|
+
const dir = join(logsDir, "allocations");
|
|
233
|
+
return function reapAllocationLogs() {
|
|
234
|
+
if (retentionDays === 0) return;
|
|
235
|
+
let names;
|
|
236
|
+
try {
|
|
237
|
+
names = fs.readdirSync(dir);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err?.code === "ENOENT") return;
|
|
240
|
+
log("allocation_log_reaper_skipped", { reason: err?.code ?? "error" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const cutoff = now() - retentionDays * 86400000;
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
if (!/^\d{4}-\d{2}\.jsonl$/.test(name)) continue;
|
|
246
|
+
try {
|
|
247
|
+
const st = fs.lstatSync(join(dir, name));
|
|
248
|
+
if (st.isFile() && st.mtimeMs < cutoff) {
|
|
249
|
+
fs.unlinkSync(join(dir, name));
|
|
250
|
+
log("reaped_allocation_log", { file: name });
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log("allocation_log_reaper_skipped", { file: name, reason: err?.code ?? "error" });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The applied split, shared by every host through Valkey. One per worker process. `audit` is
|
|
261
|
+
* `makeAllocationAudit(...)` (or a test's), `host` this worker's name for the rows. Every method takes the host's
|
|
262
|
+
* live `envelope` (parsed, never null here: with no envelope nothing in this module runs) and its `digest`, and an
|
|
263
|
+
* injected `now`.
|
|
264
|
+
*/
|
|
265
|
+
export function makeAllocationState({ redis, host = "", audit, log = () => {}, lockMs = ALLOC_LOCK_MS, token = () => randomUUID(), prefix = null }) {
|
|
266
|
+
// The four keys. `prefix` exists for the live Valkey tests alone, so a test never touches a deployment's `alloc:*`.
|
|
267
|
+
const PLAN = prefix ? `${prefix}:plan` : ALLOC_PLAN_KEY;
|
|
268
|
+
const LOCK = prefix ? `${prefix}:lock` : ALLOC_LOCK_KEY;
|
|
269
|
+
const LOG = prefix ? `${prefix}:log` : ALLOC_LOG_KEY;
|
|
270
|
+
const EXPECTED = prefix ? `${prefix}:envelope:expected` : ALLOC_EXPECTED_KEY;
|
|
271
|
+
// The digests this host already wrote an `envelope-changed-externally` row for: one row per digest per process, not
|
|
272
|
+
// one per pickup. A restart writes it again, once.
|
|
273
|
+
const reportedExternal = new Set();
|
|
274
|
+
let reportedNewer = false;
|
|
275
|
+
let reportedUngoverned = false;
|
|
276
|
+
|
|
277
|
+
async function pushLog(row) {
|
|
278
|
+
try {
|
|
279
|
+
await redis.lpush(LOG, JSON.stringify(row));
|
|
280
|
+
await redis.ltrim(LOG, 0, ALLOC_LOG_MAX - 1);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// A view: losing a row here loses nothing the file does not hold.
|
|
283
|
+
log("allocation_log_push_failed", { code: typeof err?.code === "string" ? err.code : "error" });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** File row (with the reasons when given), then the `alloc:log` view without them. */
|
|
288
|
+
async function record(row, reasons = null) {
|
|
289
|
+
audit.append(reasons && Object.keys(reasons).length > 0 ? { ...row, reasons } : row);
|
|
290
|
+
await pushLog(row);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The row of a SYSTEM change (the seed, expiry, a re-base) that has already happened: the CAS or the seed landed, so
|
|
295
|
+
* a file that refuses the row cannot undo it and a retry cannot write it back. Never throws: the loss is logged with
|
|
296
|
+
* the outcome, the plan id and both digests (never a reason), and the `alloc:log` view still gets the row.
|
|
297
|
+
*/
|
|
298
|
+
async function recordSystem(row, fromDigest) {
|
|
299
|
+
try {
|
|
300
|
+
audit.append(row);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
log("allocation_audit_row_lost", { outcome: row.outcome, planId: row.planId ?? null, from: fromDigest ?? null, to: row.envelopeDigest ?? null, code: typeof err?.code === "string" ? err.code : "error" });
|
|
303
|
+
}
|
|
304
|
+
await pushLog(row);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function cas(current, next) {
|
|
308
|
+
const r = await redis.eval(CAS_SCRIPT, 1, PLAN, current.planId ?? "", current.envelopeDigest, JSON.stringify(next));
|
|
309
|
+
return Number(r) === 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The stored state, seeding the neutral one when there is none (and replacing one that does not decode). */
|
|
313
|
+
async function ensure(envelope, digest, now) {
|
|
314
|
+
const text = await redis.get(PLAN);
|
|
315
|
+
const state = parseState(text);
|
|
316
|
+
if (state?.newer) return state;
|
|
317
|
+
if (state && state.corrupt === undefined) return state;
|
|
318
|
+
const seed = neutralState(envelope, digest, now);
|
|
319
|
+
const body = JSON.stringify(seed);
|
|
320
|
+
// The seed's digest becomes the expected one in the same script: the host that wrote the split names the envelope it
|
|
321
|
+
// was made for, so a host with another envelope, or a hand edit, meets a mismatch rather than re-basing.
|
|
322
|
+
const corrupt = state?.corrupt !== undefined;
|
|
323
|
+
const won = Number(await redis.eval(SEED_SCRIPT, 2, PLAN, EXPECTED, body, digest, corrupt ? "eq" : "nx", corrupt ? state.corrupt : "")) === 1;
|
|
324
|
+
if (won) {
|
|
325
|
+
if (corrupt) log("allocation_state_unreadable_replaced", {});
|
|
326
|
+
await recordSystem(auditRow({ at: iso(now), host, writer: { kind: "default" }, outcome: "neutral", weights: seed.weights, after: amountsOf(seed), clamped: false, envelopeDigest: digest }), null);
|
|
327
|
+
return seed;
|
|
328
|
+
}
|
|
329
|
+
const again = parseState(await redis.get(PLAN));
|
|
330
|
+
return again && again.corrupt === undefined ? again : seed;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `alloc:envelope:expected`, seeded with `seedDigest` (the APPLIED state's digest, never a differing host's) when absent. */
|
|
334
|
+
async function expectedDigest(seedDigest) {
|
|
335
|
+
const expected = await redis.get(EXPECTED);
|
|
336
|
+
if (expected !== null && expected !== undefined) return expected;
|
|
337
|
+
await redis.set(EXPECTED, seedDigest, "NX");
|
|
338
|
+
return redis.get(EXPECTED);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Bring the stored state in line with this host's envelope, and say whether the host may enforce it. Called at boot,
|
|
343
|
+
* on every envelope reload, and at each pickup (cheap: one GET, and a write only when something is due). Returns
|
|
344
|
+
* `{ state, mismatch }`: `mismatch` true means this host's digest is not the applied state's, and its governed jobs
|
|
345
|
+
* refuse as `envelope-mismatch` until it is.
|
|
346
|
+
*
|
|
347
|
+
* 1. No state (a first boot, a flush): the neutral one, `SET NX`.
|
|
348
|
+
* 2. Another digest: a RE-BASE when this host's digest equals `alloc:envelope:expected` (an absent expected is
|
|
349
|
+
* seeded with this host's digest, silently), CAS from the old digest to the new; otherwise a mismatch, and one
|
|
350
|
+
* `envelope-changed-externally` row. So a hand edit on one host cannot re-base the fleet.
|
|
351
|
+
* 3. Delegation off with a plan applied: neutral at once (`envelope-change`).
|
|
352
|
+
* 4. A plan past its `validUntil`: neutral (`expiry`).
|
|
353
|
+
* Each change is a CAS; the loser re-reads and goes round again (at most a few times: each round is someone's win).
|
|
354
|
+
*/
|
|
355
|
+
async function reconcile({ envelope, digest = envelopeDigest(envelope), now }) {
|
|
356
|
+
for (let round = 0; round < 4; round++) {
|
|
357
|
+
const state = await ensure(envelope, digest, now);
|
|
358
|
+
if (state.newer) {
|
|
359
|
+
// A newer build wrote it: its fields may bound what this build cannot read, so governed jobs refuse here
|
|
360
|
+
// (money-safe) and the state is left exactly as it is for the build that understands it.
|
|
361
|
+
if (!reportedNewer) {
|
|
362
|
+
reportedNewer = true;
|
|
363
|
+
log("allocation_state_newer", { reason: "alloc:plan was written by a newer pi-dispatch; upgrade this worker" });
|
|
364
|
+
}
|
|
365
|
+
return { state: null, mismatch: true };
|
|
366
|
+
}
|
|
367
|
+
if (state.envelopeDigest !== digest) {
|
|
368
|
+
// Absent (a flush of the key alone, or a state from before the key existed): seeded with the APPLIED digest, so
|
|
369
|
+
// the differing host is the one that mismatches, never the fleet that agrees with the split.
|
|
370
|
+
const expected = await expectedDigest(state.envelopeDigest);
|
|
371
|
+
if (expected !== digest) {
|
|
372
|
+
if (!reportedExternal.has(digest)) {
|
|
373
|
+
await record(auditRow({ at: iso(now), host, writer: { kind: "envelope-change" }, outcome: "envelope-changed-externally", reason: ENVELOPE_MISMATCH_REASON, planId: state.planId, before: amountsOf(state), envelopeDigest: digest }));
|
|
374
|
+
// After the row: a row that failed to write is tried again at the next reconcile, not forgotten.
|
|
375
|
+
reportedExternal.add(digest);
|
|
376
|
+
log("envelope_changed_externally", { digest, applied: state.envelopeDigest });
|
|
377
|
+
}
|
|
378
|
+
return { state, mismatch: true };
|
|
379
|
+
}
|
|
380
|
+
const next = rebased(state, envelope, digest, now);
|
|
381
|
+
if (await cas(state, next)) {
|
|
382
|
+
await recordSystem(auditRow({ at: iso(now), host, writer: { kind: "envelope-change" }, outcome: "rebased", planId: next.planId, weights: next.weights, repoWeights: next.repoWeights, before: amountsOf(state), after: amountsOf(next), clamped: false, envelopeDigest: digest }), state.envelopeDigest);
|
|
383
|
+
log("allocation_rebased", { from: state.envelopeDigest, to: digest });
|
|
384
|
+
return { state: next, mismatch: false };
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// The state agrees with this host: its digest is the expected one, so seed the key if it is absent.
|
|
389
|
+
await expectedDigest(digest);
|
|
390
|
+
if (state.planId !== null && !envelope.delegation?.enabled) {
|
|
391
|
+
const next = neutralState(envelope, digest, now, { writer: "envelope-change", lastPlanAt: state.lastPlanAt ?? null });
|
|
392
|
+
if (await cas(state, next)) {
|
|
393
|
+
await recordSystem(auditRow({ at: iso(now), host, writer: { kind: "envelope-change" }, outcome: "neutral", reason: "delegation-off", planId: null, weights: next.weights, before: amountsOf(state), after: amountsOf(next), clamped: false, envelopeDigest: digest }), state.envelopeDigest);
|
|
394
|
+
return { state: next, mismatch: false };
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (state.validUntil && Date.parse(state.validUntil) <= nowMs(now)) {
|
|
399
|
+
const next = neutralState(envelope, digest, now, { writer: "expiry", lastPlanAt: state.lastPlanAt ?? null });
|
|
400
|
+
if (await cas(state, next)) {
|
|
401
|
+
await recordSystem(auditRow({ at: iso(now), host, writer: { kind: "expiry" }, outcome: "expired", planId: state.planId, weights: next.weights, before: amountsOf(state), after: amountsOf(next), clamped: false, envelopeDigest: digest }), state.envelopeDigest);
|
|
402
|
+
log("allocation_expired", {});
|
|
403
|
+
return { state: next, mismatch: false };
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
return { state, mismatch: false };
|
|
408
|
+
}
|
|
409
|
+
// Four lost rounds in a row: other hosts are changing the state as fast as this one reads it. Read once more and
|
|
410
|
+
// answer from that; the next pickup reconciles again.
|
|
411
|
+
const state = await ensure(envelope, digest, now);
|
|
412
|
+
if (state.newer) return { state: null, mismatch: true };
|
|
413
|
+
return { state, mismatch: state.envelopeDigest !== digest };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* For a host WITHOUT an envelope: is the fleet governed (an applied split exists in `alloc:plan`)? One `EXISTS`. Such
|
|
418
|
+
* a host would otherwise reserve against the operator's caps alone beside hosts that enforce the split, so its jobs
|
|
419
|
+
* refuse as `envelope-mismatch` until the envelope is installed here, or delegation is turned off fleet-wide by
|
|
420
|
+
* removing the envelope from every host AND deleting `alloc:plan` and `alloc:envelope:expected`. Said loudly once per process, at the first true:
|
|
421
|
+
* the first pickup it refuses. Doctor names such a host before any job (`appliedSplitChecks`).
|
|
422
|
+
*/
|
|
423
|
+
async function fleetGoverned() {
|
|
424
|
+
const governed = Number(await redis.exists(PLAN)) > 0;
|
|
425
|
+
if (governed && !reportedUngoverned) {
|
|
426
|
+
reportedUngoverned = true;
|
|
427
|
+
log("envelope_absent_fleet_governed", { fix: "install the fleet's envelope here (PI_ENVELOPE_FILE), or remove it from every host and DEL alloc:plan alloc:envelope:expected" });
|
|
428
|
+
}
|
|
429
|
+
return governed;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The state projected onto a changed envelope: neutral for a neutral state or with delegation off, else `rebase`. */
|
|
433
|
+
function rebased(state, envelope, digest, now) {
|
|
434
|
+
if (state.planId === null || !envelope.delegation?.enabled) {
|
|
435
|
+
return neutralState(envelope, digest, now, { writer: "envelope-change", lastPlanAt: state.lastPlanAt ?? null });
|
|
436
|
+
}
|
|
437
|
+
const v = rebase(vectorOf(state), envelope);
|
|
438
|
+
const entries = envelopeEntries(envelope);
|
|
439
|
+
const keep = (obj) => Object.fromEntries(Object.entries(obj ?? {}).filter(([k]) => entries.includes(k)));
|
|
440
|
+
const repoWeights = keep(state.repoWeights);
|
|
441
|
+
const weights = {};
|
|
442
|
+
for (const id of entries) weights[id] = state.weights?.[id] ?? 0;
|
|
443
|
+
return {
|
|
444
|
+
...state,
|
|
445
|
+
writer: "envelope-change",
|
|
446
|
+
jobId: null,
|
|
447
|
+
triggerId: null,
|
|
448
|
+
appliedAt: iso(now),
|
|
449
|
+
envelopeDigest: digest,
|
|
450
|
+
weights,
|
|
451
|
+
repoWeights,
|
|
452
|
+
reasons: keep(state.reasons),
|
|
453
|
+
allocations: v.allocations,
|
|
454
|
+
unallocated: v.unallocated,
|
|
455
|
+
repos: repoShares(v.allocations, repoWeights),
|
|
456
|
+
clamped: false,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function takeLock() {
|
|
461
|
+
const mine = `${host}#${token()}`;
|
|
462
|
+
const got = await redis.set(LOCK, mine, "PX", lockMs, "NX");
|
|
463
|
+
return got === null || got === undefined ? null : mine;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function releaseLock(mine) {
|
|
467
|
+
try {
|
|
468
|
+
// Only while the value is still ours: a lock that expired and was taken by another apply is theirs.
|
|
469
|
+
await redis.eval(RELEASE_IF_MINE, 1, LOCK, mine);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
log("allocation_lock_release_failed", { code: typeof err?.code === "string" ? err.code : "error" });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Apply a priorities plan (`text`, agent-authored) for `writer` `{ kind, jobId?, triggerId? }`. Returns
|
|
477
|
+
* `{ outcome, reason, planId?, field?, rule? }` with `outcome` one of `applied`, `duplicate`, `refused`,
|
|
478
|
+
* `apply-failed`. Every outcome is recorded in the file and in `alloc:log`. Never returns reason text.
|
|
479
|
+
*
|
|
480
|
+
* Order: a plan that is not well formed (`plan-invalid`, naming one field), then the ladder: `delegation-off`,
|
|
481
|
+
* `writer-not-allowed`, `envelope-mismatch` (this host's envelope is not the applied state's), `plan-duplicate` (a
|
|
482
|
+
* no-op), `plan-stale`, `plan-too-soon`, `plan-incomplete`, `plan-busy` (the lock). Then the step is computed
|
|
483
|
+
* against the APPLIED vector, the row is written, and the CAS compares the id and digest read; a lost CAS adds an
|
|
484
|
+
* `apply-failed` row and answers `plan-stale`. The lock is released only while it is still ours.
|
|
485
|
+
*
|
|
486
|
+
* Throws only on infrastructure (Valkey, or the audit file): such a plan has not applied.
|
|
487
|
+
*/
|
|
488
|
+
async function applyPlan({ envelope, digest = envelope ? envelopeDigest(envelope) : null, projects = null, text, writer, now }) {
|
|
489
|
+
const at = iso(now);
|
|
490
|
+
const kind = writer?.kind ?? null;
|
|
491
|
+
const parsed = parsePlan(text, { envelope, projects, now: nowMs(now) });
|
|
492
|
+
const refused = async (reason, extra = {}, state = null) => {
|
|
493
|
+
await record(auditRow({ at, host, writer, outcome: reason === "plan-duplicate" ? "duplicate" : "refused", reason, ...extra, before: amountsOf(state), envelopeDigest: digest ?? null }));
|
|
494
|
+
return { outcome: reason === "plan-duplicate" ? "duplicate" : "refused", reason, ...(extra.planId ? { planId: extra.planId } : {}), ...(extra.field ? { field: extra.field, rule: extra.rule } : {}) };
|
|
495
|
+
};
|
|
496
|
+
if (!parsed.ok && parsed.reason !== "plan-incomplete") return refused(parsed.reason, { field: parsed.field, rule: parsed.rule });
|
|
497
|
+
if (!envelope) return refused("delegation-off", { planId: parsed.id, basis: parsed.plan.basis });
|
|
498
|
+
const ids = { planId: parsed.id, basis: parsed.plan.basis };
|
|
499
|
+
const first = planRefusal({ envelope, writer: kind, parsed, current: null, now: nowMs(now) });
|
|
500
|
+
if (first === "delegation-off" || first === "writer-not-allowed") return refused(first, ids);
|
|
501
|
+
const { state, mismatch } = await reconcile({ envelope, digest, now });
|
|
502
|
+
if (mismatch) return refused(ENVELOPE_MISMATCH_REASON, ids, state);
|
|
503
|
+
const rung = planRefusal({ envelope, writer: kind, parsed, current: { planId: state.planId, lastPlanAt: state.lastPlanAt }, now: nowMs(now) });
|
|
504
|
+
if (rung) return refused(rung, ids, state);
|
|
505
|
+
const mine = await takeLock();
|
|
506
|
+
if (mine === null) return refused("plan-busy", ids, state);
|
|
507
|
+
try {
|
|
508
|
+
const weights = {};
|
|
509
|
+
const repoWeights = {};
|
|
510
|
+
const reasons = {};
|
|
511
|
+
for (const p of parsed.plan.projects) {
|
|
512
|
+
weights[p.id] = p.weight;
|
|
513
|
+
if (p.repos) repoWeights[p.id] = Object.fromEntries(p.repos.map((r) => [r.ref, r.weight]));
|
|
514
|
+
if (p.reason !== undefined) reasons[p.id] = p.reason;
|
|
515
|
+
}
|
|
516
|
+
const result = allocate({ envelope, weights, current: vectorOf(state), repos: repoWeights });
|
|
517
|
+
const next = {
|
|
518
|
+
version: ALLOC_STATE_VERSION,
|
|
519
|
+
planId: parsed.id,
|
|
520
|
+
basis: parsed.plan.basis,
|
|
521
|
+
writer: kind,
|
|
522
|
+
jobId: writer?.jobId ?? null,
|
|
523
|
+
triggerId: writer?.triggerId ?? null,
|
|
524
|
+
appliedAt: at,
|
|
525
|
+
lastPlanAt: at,
|
|
526
|
+
validUntil: parsed.validUntil,
|
|
527
|
+
envelopeDigest: digest,
|
|
528
|
+
weights,
|
|
529
|
+
repoWeights,
|
|
530
|
+
reasons,
|
|
531
|
+
allocations: result.allocations,
|
|
532
|
+
unallocated: result.unallocated,
|
|
533
|
+
repos: result.repos,
|
|
534
|
+
clamped: result.clamped,
|
|
535
|
+
};
|
|
536
|
+
const row = auditRow({ at, host, writer, outcome: "applied", planId: next.planId, basis: next.basis, weights, repoWeights, before: amountsOf(state), after: amountsOf(next), clamped: next.clamped, envelopeDigest: digest });
|
|
537
|
+
// THE FILE FIRST: a change that happened always has its row. Then the CAS, which is the write.
|
|
538
|
+
audit.append(Object.keys(reasons).length > 0 ? { ...row, reasons } : row);
|
|
539
|
+
if (!(await cas(state, next))) {
|
|
540
|
+
await record(auditRow({ at, host, writer, outcome: "apply-failed", reason: "plan-stale", planId: next.planId, basis: next.basis, before: amountsOf(state), envelopeDigest: digest }));
|
|
541
|
+
return { outcome: "apply-failed", reason: "plan-stale", planId: next.planId };
|
|
542
|
+
}
|
|
543
|
+
await pushLog(row);
|
|
544
|
+
log("allocation_applied", { writer: kind, planId: next.planId, clamped: next.clamped });
|
|
545
|
+
return { outcome: "applied", reason: null, planId: next.planId, clamped: next.clamped };
|
|
546
|
+
} finally {
|
|
547
|
+
await releaseLock(mine);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The operator's revert to an earlier outcome (issue #504 part C calls it; the panel's history reads `alloc:log`).
|
|
553
|
+
* `target` is an `alloc:log` row: its `planId`, `weights` and `repoWeights`. An operator act, so it skips the
|
|
554
|
+
* interval and the step rules: the target weights apply in full against the CURRENT envelope. It is still a CAS
|
|
555
|
+
* on the state it read, written after its row like a plan, and still refused while delegation is off (neutral is
|
|
556
|
+
* then the only state), while this host's envelope is not the applied one, or while another apply holds the lock.
|
|
557
|
+
* Weights that do not cover exactly the envelope's entries (an older envelope's row) refuse as `plan-incomplete`.
|
|
558
|
+
*
|
|
559
|
+
* A revert to a row with NO plan (a neutral, default, re-based-neutral or `expired` row) restores the CURRENT neutral
|
|
560
|
+
* split: this envelope's default weights, no plan id, no expiry (issue #507). The row's own weights are the neutral
|
|
561
|
+
* of the envelope it was written under, and restoring them would label an older envelope's weights "neutral" and
|
|
562
|
+
* keep them for ever, since nothing expires a split with no plan. An `expired` row counts as one: it names the plan
|
|
563
|
+
* that ran out beside the neutral weights that replaced it, and taking that id put "plan X until ..." over neutral
|
|
564
|
+
* weights. The row keeps the id, as the audit file's answer to which plan ran out. A revert to a plan's row is as it
|
|
565
|
+
* was: that plan's weights, its id, a fresh expiry.
|
|
566
|
+
*/
|
|
567
|
+
async function revert({ envelope, digest = envelope ? envelopeDigest(envelope) : null, target, now }) {
|
|
568
|
+
const at = iso(now);
|
|
569
|
+
const writer = { kind: "operator-revert" };
|
|
570
|
+
const weights = target?.weights ?? null;
|
|
571
|
+
const entries = envelope ? envelopeEntries(envelope) : [];
|
|
572
|
+
const refused = async (reason, state = null) => {
|
|
573
|
+
await record(auditRow({ at, host, writer, outcome: "refused", reason, planId: target?.planId ?? null, before: amountsOf(state), envelopeDigest: digest ?? null }));
|
|
574
|
+
return { outcome: "refused", reason };
|
|
575
|
+
};
|
|
576
|
+
if (!envelope?.delegation?.enabled) return refused("delegation-off");
|
|
577
|
+
// Only a real log row: a missing target, or a row with no weights (a refusal), still refuses as plan-incomplete.
|
|
578
|
+
const toNeutral = weights !== null && typeof weights === "object" && (target.outcome === "expired" || typeof target.planId !== "string");
|
|
579
|
+
const keys = Object.keys(weights ?? {}).sort();
|
|
580
|
+
if (!toNeutral && (keys.length !== entries.length || keys.some((k, i) => k !== entries[i]))) return refused("plan-incomplete");
|
|
581
|
+
const { state, mismatch } = await reconcile({ envelope, digest, now });
|
|
582
|
+
if (mismatch) return refused(ENVELOPE_MISMATCH_REASON, state);
|
|
583
|
+
const mine = await takeLock();
|
|
584
|
+
if (mine === null) return refused("plan-busy", state);
|
|
585
|
+
try {
|
|
586
|
+
const repoWeights = toNeutral ? {} : Object.fromEntries(Object.entries(target?.repoWeights ?? {}).filter(([k]) => entries.includes(k) && k !== OTHER));
|
|
587
|
+
const result = toNeutral ? null : allocate({ envelope, weights, current: null, repos: repoWeights });
|
|
588
|
+
const validUntil = new Date(nowMs(now) + envelope.delegation.maxPlanDays * 86400000).toISOString();
|
|
589
|
+
const next = toNeutral ? { ...neutralState(envelope, digest, now, { writer: "operator-revert", lastPlanAt: at }), basis: state.planId } : {
|
|
590
|
+
version: ALLOC_STATE_VERSION,
|
|
591
|
+
planId: target.planId,
|
|
592
|
+
basis: state.planId,
|
|
593
|
+
writer: "operator-revert",
|
|
594
|
+
jobId: null,
|
|
595
|
+
triggerId: null,
|
|
596
|
+
appliedAt: at,
|
|
597
|
+
lastPlanAt: at,
|
|
598
|
+
validUntil,
|
|
599
|
+
envelopeDigest: digest,
|
|
600
|
+
weights: { ...weights },
|
|
601
|
+
repoWeights,
|
|
602
|
+
reasons: {},
|
|
603
|
+
allocations: result.allocations,
|
|
604
|
+
unallocated: result.unallocated,
|
|
605
|
+
repos: result.repos,
|
|
606
|
+
clamped: false,
|
|
607
|
+
};
|
|
608
|
+
const row = auditRow({ at, host, writer, outcome: "reverted", planId: next.planId, basis: next.basis, weights: next.weights, repoWeights, before: amountsOf(state), after: amountsOf(next), clamped: false, envelopeDigest: digest });
|
|
609
|
+
audit.append(row);
|
|
610
|
+
if (!(await cas(state, next))) {
|
|
611
|
+
await record(auditRow({ at, host, writer, outcome: "apply-failed", reason: "plan-stale", planId: next.planId, before: amountsOf(state), envelopeDigest: digest }));
|
|
612
|
+
return { outcome: "apply-failed", reason: "plan-stale" };
|
|
613
|
+
}
|
|
614
|
+
await pushLog(row);
|
|
615
|
+
return { outcome: "reverted", reason: null, planId: next.planId };
|
|
616
|
+
} finally {
|
|
617
|
+
await releaseLock(mine);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* A plan refused BEFORE `applyPlan` could judge it (issue #505): the job-side collector's own rungs
|
|
623
|
+
* (`plan-absent`, `plan-not-portfolio`, `plan-oversize`, `plan-not-regular-file`, `plan-unreadable`,
|
|
624
|
+
* `plan-parse-error`). `plan-absent` (issue #507) is a confirmed portfolio job that wrote no plan at all. One row in
|
|
625
|
+
* the file and one in `alloc:log`, like every refusal `applyPlan` writes, so none of them is silent. The reason is a
|
|
626
|
+
* fixed token and the plan body is never read into it: a refused file may not even be a plan. Throws like `record`
|
|
627
|
+
* on an infrastructure fault; the collector catches it.
|
|
628
|
+
*/
|
|
629
|
+
async function recordRefusal({ writer, reason, field = null, rule = null, digest = null, now }) {
|
|
630
|
+
await record(auditRow({ at: iso(now), host, writer, outcome: "refused", reason, field, rule, envelopeDigest: digest }));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The newest `alloc:log` row a writer of `kind` wrote for the trigger `triggerId` (issue #505, the snapshot's
|
|
635
|
+
* `lastAttempt`), or null. `alloc:log` is newest first and carries no reasons, so the row holds only what a snapshot
|
|
636
|
+
* may show. A row that does not parse is skipped. Throws on a Valkey fault.
|
|
637
|
+
*/
|
|
638
|
+
async function lastAttempt({ kind = "portfolio-job", triggerId }) {
|
|
639
|
+
const rows = await redis.lrange(LOG, 0, ALLOC_LOG_MAX - 1);
|
|
640
|
+
for (const text of Array.isArray(rows) ? rows : []) {
|
|
641
|
+
let row;
|
|
642
|
+
try {
|
|
643
|
+
row = JSON.parse(text);
|
|
644
|
+
} catch {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (row?.writer === kind && row?.triggerId === triggerId) return row;
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { reconcile, applyPlan, revert, fleetGoverned, recordRefusal, lastAttempt };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The job a project member stands for: a folder member is a local job on that folder, a forge member
|
|
657
|
+
* (`<kind>:<repo>`, as projects.json stores it) a job of that kind on that repo. Used to find the scoped-limits row a
|
|
658
|
+
* member's jobs match, by the job matcher itself (`dollarCapsFor`), rather than by comparing strings a second way.
|
|
659
|
+
*/
|
|
660
|
+
export function memberJob(member) {
|
|
661
|
+
if (typeof member !== "string" || member === "") return null;
|
|
662
|
+
if (isAbsolute(member)) return { kind: "local", folder: member };
|
|
663
|
+
const at = member.indexOf(":");
|
|
664
|
+
return at > 0 ? { kind: member.slice(0, at), repo: member.slice(at + 1) } : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* THE key prefix a member's repo share reserves and settles in (issue #505 review): the operator row its jobs match
|
|
669
|
+
* when that row has dollar windows (`dollarCapsFor`, so a bare `acme/web` row is the key of the member
|
|
670
|
+
* `github:acme/web`), else the synthetic ledger keyed by the member scope. `governedDollars` (enforcement) and the
|
|
671
|
+
* portfolio snapshot (what a manager reads) both call this, so the counter read is always the counter written. Pass
|
|
672
|
+
* `row` when the matched ledger is already in hand (null for none), else `limits` to match it here.
|
|
673
|
+
*/
|
|
674
|
+
export function memberDollarKeyPrefix(member, { row, limits = null } = {}) {
|
|
675
|
+
const ledger = row !== undefined ? row : dollarCapsFor(memberJob(member), limits);
|
|
676
|
+
return ledger?.keyPrefix ?? scopeDollarKeyPrefix(member);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The dollar inputs of one governed job (issue #504 part B, `DES-DOLLAR-RESERVE-AND-SETTLE`): the operator's ledgers
|
|
681
|
+
* narrowed by the applied split for the envelope's window. PURE. `operator` is what the job would reserve in with no
|
|
682
|
+
* envelope (`dollarCaps`, `scopedDollars`, `projectDollars`, each possibly null); `member` is the pickup's
|
|
683
|
+
* `{ id, member }` (the project id and `memberScopeOf`), or null for a job in no project. Returns
|
|
684
|
+
* `{ dollarCaps, dollarCapSource, scopedDollars, projectDollars, otherDollars }`.
|
|
685
|
+
*
|
|
686
|
+
* - the deployment: `min(operator cap, envelope total)` for the window; with no operator cap the total alone;
|
|
687
|
+
* - an envelope project: its row's ledger with `min(row, allocation)` for the window, or with no row a SYNTHETIC
|
|
688
|
+
* ledger keyed `scopeDollarKeyPrefix("project:<id>")` (the key a row would have), so it reserves, joins the
|
|
689
|
+
* job-cost ledgers and settles to the job's cost like any project row;
|
|
690
|
+
* - its member's repo share, when the plan gave one: the member's row ledger with `min(row, share)`, or a synthetic
|
|
691
|
+
* one keyed by the member scope;
|
|
692
|
+
* - `_other` (no project, or a project the envelope does not name): a ledger of its own, keyed
|
|
693
|
+
* `scopeDollarKeyPrefix("project:_other")` (`PROJECT_ID_RE` forbids `_`, so no real project shares the key), in
|
|
694
|
+
* the `otherDollars` slot; a real project outside the envelope keeps its own row besides.
|
|
695
|
+
* Every ledger carries `capSource: { day, week, month }`, `operator` or `allocation` for the window that bound it (a
|
|
696
|
+
* tie is `operator`), null for a window it does not have. The processor names a refusal `allocation-cap` from it.
|
|
697
|
+
*/
|
|
698
|
+
export function governedDollars({ envelope, state, member = null, operator = {} }) {
|
|
699
|
+
const w = envelope.window;
|
|
700
|
+
const entries = envelopeEntries(envelope);
|
|
701
|
+
const entry = member && entries.includes(member.id) && member.id !== OTHER ? member.id : OTHER;
|
|
702
|
+
const sourceOf = (caps) => ({ day: caps?.day == null ? null : "operator", week: caps?.week == null ? null : "operator", month: caps?.month == null ? null : "operator" });
|
|
703
|
+
const narrow = (ledger, scope, amount, keyPrefix = scopeDollarKeyPrefix(scope)) => {
|
|
704
|
+
const base = ledger ?? { scope, keyPrefix, caps: { day: null, week: null, month: null } };
|
|
705
|
+
const caps = { day: base.caps?.day ?? null, week: base.caps?.week ?? null, month: base.caps?.month ?? null };
|
|
706
|
+
const capSource = sourceOf(caps);
|
|
707
|
+
if (caps[w] === null || amount < caps[w]) {
|
|
708
|
+
caps[w] = amount;
|
|
709
|
+
capSource[w] = "allocation";
|
|
710
|
+
}
|
|
711
|
+
return { ...base, caps, capSource };
|
|
712
|
+
};
|
|
713
|
+
const withSource = (ledger) => (ledger ? { ...ledger, capSource: sourceOf(ledger.caps) } : null);
|
|
714
|
+
|
|
715
|
+
const deployment = narrow(operator.dollarCaps ? { caps: operator.dollarCaps } : null, "deployment", envelope.totalMicros);
|
|
716
|
+
const out = {
|
|
717
|
+
dollarCaps: deployment.caps,
|
|
718
|
+
dollarCapSource: deployment.capSource,
|
|
719
|
+
scopedDollars: withSource(operator.scopedDollars ?? null),
|
|
720
|
+
projectDollars: withSource(operator.projectDollars ?? null),
|
|
721
|
+
otherDollars: null,
|
|
722
|
+
};
|
|
723
|
+
if (entry === OTHER) {
|
|
724
|
+
out.otherDollars = narrow(null, `project:${OTHER}`, state.allocations[OTHER] ?? 0);
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
out.projectDollars = narrow(operator.projectDollars ?? null, `project:${entry}`, state.allocations[entry] ?? 0);
|
|
728
|
+
const share = state.repos?.[entry]?.[scopeRef(member.member)];
|
|
729
|
+
if (Number.isSafeInteger(share)) out.scopedDollars = narrow(operator.scopedDollars ?? null, member.member, share, memberDollarKeyPrefix(member.member, { row: operator.scopedDollars ?? null }));
|
|
730
|
+
return out;
|
|
731
|
+
}
|