@edgehero/pi-dispatch 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/package.json +7 -2
- package/src/backend-conformance.mjs +262 -0
- package/src/backend-local.mjs +222 -0
- package/src/backend-registry.mjs +157 -0
- package/src/backends.mjs +595 -0
- package/src/config.mjs +63 -1
- package/src/container-spec.mjs +226 -0
- package/src/docker-run.mjs +100 -106
- package/src/doctor.mjs +118 -0
- package/src/index.mjs +136 -12
- package/src/outbox.mjs +8 -0
- package/src/packages.mjs +7 -4
- package/src/processor.mjs +48 -11
- package/src/queue.mjs +14 -2
- package/src/sandbox-cli.mjs +36 -0
- package/src/schedules.mjs +1 -1
- package/src/start.mjs +118 -80
- package/src/triggers.mjs +120 -5
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reap several backends and combine the tri-state CONSERVATIVELY.
|
|
3
|
+
*
|
|
4
|
+
* `reaped` is true only when EVERY backend enumerated successfully. `makeScopeClaimSweeper` reads it as
|
|
5
|
+
* "this host has established that it holds no job containers", and that claim is only as strong as its
|
|
6
|
+
* weakest venue: one backend that could not list its containers leaves the host unable to prove it holds
|
|
7
|
+
* none, whatever the others managed. Sweeping on a partial answer would free scope slots for containers
|
|
8
|
+
* that may still be running and let another host start more alongside them -- a spend overrun rather than a
|
|
9
|
+
* tidy-up, which is the reason the tri-state exists at all.
|
|
10
|
+
*
|
|
11
|
+
* Every reaper runs even after one fails, because the sweep is best-effort cleanup and a second venue's
|
|
12
|
+
* strays are worth clearing whether or not the first venue answered.
|
|
13
|
+
*
|
|
14
|
+
* A FREE FUNCTION rather than a method on the registry, and the reason is a defect this replaced: the boot
|
|
15
|
+
* sweep runs far earlier in `startWorker` than any backend BUNDLE can be built -- the bundles need the log
|
|
16
|
+
* sink, the package resolver and the image preflight, all of which are constructed later. A first attempt
|
|
17
|
+
* called `registry.reap()` at the boot sweep, which is a temporal dead zone; the boot try/catch swallowed
|
|
18
|
+
* the ReferenceError and the reaper silently stopped running. So the combination lives where both callers
|
|
19
|
+
* can reach it, and `startWorker` holds its reapers in one map that the bundles then read from by name.
|
|
20
|
+
*/
|
|
21
|
+
export async function reapAll(reaps = [], { log = () => {} } = {}) {
|
|
22
|
+
let reaped = true;
|
|
23
|
+
for (const reap of reaps) {
|
|
24
|
+
try {
|
|
25
|
+
if ((await reap())?.reaped !== true) reaped = false;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
// A reaper that THREW is the same unproven state as one that returned false -- but it is NOT the
|
|
28
|
+
// same silence. Each backend's own reap logs the faults it catches; one that escapes its own
|
|
29
|
+
// catch reached the caller before this function existed, and the caller logged it. Swallowing it
|
|
30
|
+
// here without a word would delete an operator-visible signal about a venue that could not be
|
|
31
|
+
// swept, so the log seam is threaded through rather than assumed to be somebody else's job.
|
|
32
|
+
log("reaper_skipped", { reason: err?.message });
|
|
33
|
+
reaped = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { reaped };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* WHICH backend runs THIS job, and the one place that decides (issue #227).
|
|
41
|
+
*
|
|
42
|
+
* The three earlier slices built a table, taught the deployment to read it, and let a trigger name a venue.
|
|
43
|
+
* None of them dispatched: `start.mjs` built the local bundle and passed its functions straight into the
|
|
44
|
+
* processor, so `run.backend` was a validated, gated LABEL and every job ran on `local` whatever it said.
|
|
45
|
+
* This module is what makes the label mean something.
|
|
46
|
+
*
|
|
47
|
+
* IT IS DELIBERATELY NOT A `switch`. Every per-job function a backend owns is dispatched through the SAME
|
|
48
|
+
* resolution, so a future function cannot be added on one path and forgotten on another -- which is exactly
|
|
49
|
+
* how `stopContainer` came to be hard-wired in `index.mjs` while `runContainer` was injectable, and how the
|
|
50
|
+
* abort path ended up unable to reach a backend at all.
|
|
51
|
+
*
|
|
52
|
+
* RESOLUTION IS TOTAL AND FAIL-CLOSED-BY-CONSTRUCTION. `backendFor` returns the DEFAULT bundle for a job
|
|
53
|
+
* that names nothing, and for a name the registry does not hold it returns... nothing, and throws. That is
|
|
54
|
+
* not a policy decision made here: the processor already REFUSES a job naming an unblessed backend
|
|
55
|
+
* pre-spend, and the loader already refuses a name this build does not know, so a job reaching this point
|
|
56
|
+
* with an unknown name means one of those two gates was bypassed. Throwing is right for a state the design
|
|
57
|
+
* says is unreachable -- a silent fallback to the default would run the job somewhere the operator did not
|
|
58
|
+
* choose, which is the believed-in control this whole issue exists to prevent, and it would hide the
|
|
59
|
+
* bypassed gate rather than surface it.
|
|
60
|
+
*
|
|
61
|
+
* @param bundles the blessed backend bundles (each from a `make<Name>Backend`), keyed by their own `name`
|
|
62
|
+
* @param defaultName the venue a job that names none runs in -- `PI_BACKENDS[0]`
|
|
63
|
+
*/
|
|
64
|
+
export function makeBackendRegistry({ bundles = [], defaultName, blessed = null, reaps = null } = {}) {
|
|
65
|
+
const byName = new Map();
|
|
66
|
+
for (const bundle of bundles) {
|
|
67
|
+
if (!bundle?.name) throw new Error("backend registry: every bundle must carry its own name");
|
|
68
|
+
if (byName.has(bundle.name)) throw new Error(`backend registry: ${JSON.stringify(bundle.name)} is registered twice`);
|
|
69
|
+
// SHAPE, at boot. `makeLocalBackend` enforces this for its own bundle and the registry never calls
|
|
70
|
+
// it, so a hollow bundle used to build fine and fail at the first PICKUP -- as a plain TypeError
|
|
71
|
+
// after the budget reserve, which is not an InfraRetry, so the slot was never refunded. The
|
|
72
|
+
// "refuse at boot rather than at first pickup" property this constructor already claims for names
|
|
73
|
+
// and duplicates has to hold for the functions it is going to call.
|
|
74
|
+
// `containerName` is in this list because THIS MODULE calls it: `registry.containerName(job)` builds
|
|
75
|
+
// the name the abort then stops. Omitting it built fine and threw at the first pickup, which is the
|
|
76
|
+
// property this constructor claims to have and did not.
|
|
77
|
+
for (const fn of ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap", "containerName"]) {
|
|
78
|
+
if (typeof bundle[fn] !== "function") throw new Error(`backend registry: ${JSON.stringify(bundle.name)} has no ${fn}()`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(bundle.neverStartedExits) || !bundle.neverStartedExits.every(Number.isInteger)) {
|
|
81
|
+
// INTEGERS, checked: the processor compares against the container's numeric exit code, so `["125"]`
|
|
82
|
+
// would pass a bare Array.isArray and then never match -- silently keeping the budget slot on
|
|
83
|
+
// exactly the case the list exists to refund.
|
|
84
|
+
throw new Error(`backend registry: ${JSON.stringify(bundle.name)} must declare neverStartedExits as an array of integers ([] if it normalises to container-never-started itself)`);
|
|
85
|
+
}
|
|
86
|
+
byName.set(bundle.name, bundle);
|
|
87
|
+
}
|
|
88
|
+
if (byName.size === 0) throw new Error("backend registry: at least one backend must be registered");
|
|
89
|
+
// The default has to BE one of them. Without this, `defaultName` could name a venue nothing implements
|
|
90
|
+
// and every unflagged job -- which is nearly all of them -- would throw at pickup rather than at boot.
|
|
91
|
+
if (!byName.has(defaultName)) {
|
|
92
|
+
throw new Error(`backend registry: the default ${JSON.stringify(defaultName)} is not among the registered backends (${[...byName.keys()].join(", ")})`);
|
|
93
|
+
}
|
|
94
|
+
// A name can be BLESSED AND UNBUILT, and no other gate catches it. The loader refuses a name this build
|
|
95
|
+
// does not know and the processor refuses one `PI_BACKENDS` does not bless -- but the registry's own set
|
|
96
|
+
// is a third set neither compares against, so a blessed name with no bundle passes both and then throws
|
|
97
|
+
// at the first pickup, as a non-InfraRetry that becomes a permanently failed job blaming the operator
|
|
98
|
+
// for a deployment they configured correctly. The header's "reaching here means a gate was bypassed" is
|
|
99
|
+
// only true once this check exists, which is why it does.
|
|
100
|
+
for (const name of blessed ?? []) {
|
|
101
|
+
if (!byName.has(name)) {
|
|
102
|
+
throw new Error(`backend registry: PI_BACKENDS blesses ${JSON.stringify(name)} but no backend by that name is built (built: ${[...byName.keys()].join(", ")})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// The boot sweep is handed a list of reapers rather than the registry (see `reapAll`), so the two sets
|
|
106
|
+
// can drift -- and a missing reaper is INVISIBLE, because `reapAll` is conservative over the reapers it
|
|
107
|
+
// receives, not over the venues that exist. A forgotten entry would report `{reaped: true}` while a
|
|
108
|
+
// venue went unswept, and the scope sweep would then free slots for containers that may still be
|
|
109
|
+
// running: the exact spend overrun the tri-state exists to prevent, arriving through the one seam its
|
|
110
|
+
// conservatism does not cover.
|
|
111
|
+
if (reaps) {
|
|
112
|
+
const missing = [...byName.keys()].filter((n) => typeof reaps[n] !== "function");
|
|
113
|
+
if (missing.length > 0) throw new Error(`backend registry: no boot reaper for ${missing.join(", ")} -- an unswept venue would still report the host as proven clean`);
|
|
114
|
+
const extra = Object.keys(reaps).filter((n) => !byName.has(n));
|
|
115
|
+
if (extra.length > 0) throw new Error(`backend registry: a boot reaper for unregistered backend(s) ${extra.join(", ")}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The bundle this job runs in. Throws for a name no gate should have let through. */
|
|
119
|
+
function backendFor(job) {
|
|
120
|
+
const name = job?.backend ?? defaultName;
|
|
121
|
+
const bundle = byName.get(name);
|
|
122
|
+
if (!bundle) {
|
|
123
|
+
throw new Error(`backend registry: no backend named ${JSON.stringify(name)} is registered (have: ${[...byName.keys()].join(", ")})`);
|
|
124
|
+
}
|
|
125
|
+
return bundle;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
backendFor,
|
|
130
|
+
names: [...byName.keys()],
|
|
131
|
+
defaultName,
|
|
132
|
+
/**
|
|
133
|
+
* The per-job functions, each resolving the venue from the job it was handed. These are what the
|
|
134
|
+
* processor and the abort path receive, so neither of them needs to know a registry exists.
|
|
135
|
+
*
|
|
136
|
+
* `stopContainer` takes the JOB as well as the name, and that second argument is the whole reason
|
|
137
|
+
* the abort path had to change: the container's NAME is not enough to find the runtime that holds
|
|
138
|
+
* it once there is more than one, and `index.mjs` had only the name.
|
|
139
|
+
*/
|
|
140
|
+
runContainer: (args) => backendFor(args?.job).runContainer(args),
|
|
141
|
+
imagePreflight: (job) => backendFor(job).imagePreflight(job),
|
|
142
|
+
egressPreflight: (job) => backendFor(job).egressPreflight(job),
|
|
143
|
+
stopContainer: (name, job) => backendFor(job).stopContainer(name, job),
|
|
144
|
+
// ON THE SURFACE, not reached for through `backendFor` by a call site. Both are per-job backend
|
|
145
|
+
// FACTS rather than functions, and an earlier draft left them off: the wiring rebuilt
|
|
146
|
+
// `neverStartedExits` at the call site and `index.mjs` imported `jobContainerName` from the local
|
|
147
|
+
// adapter directly. That is precisely the "dispatched on one path, hardcoded on another" shape this
|
|
148
|
+
// module's header says is structurally impossible -- and the container NAME is the argument the
|
|
149
|
+
// abort's `stopContainer` receives, so building it locally while resolving the venue per job was the
|
|
150
|
+
// same defect in the one call the slice exists to make dispatchable.
|
|
151
|
+
neverStartedExits: (job) => backendFor(job).neverStartedExits,
|
|
152
|
+
containerName: (job) => backendFor(job).containerName(job?.id),
|
|
153
|
+
// The reaper map, validated above against the registered set, so the boot sweep can be handed
|
|
154
|
+
// something that cannot silently under-enumerate.
|
|
155
|
+
reaps: reaps ? Object.values(reaps) : [],
|
|
156
|
+
};
|
|
157
|
+
}
|