Files
mesh-catalog/modules/mesh-catalog/index.ts
T

157 lines
6.2 KiB
TypeScript

// mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072).
//
// The build-machine role announces what it built; this places it in the graph and announces what that means.
// The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so
// it never has to interpret an artifact or ask this module anything.
//
// **Ordering is not computed here.** When a registration makes something else stale, the modules
// whose own dependencies are all current are announced as needing a rebuild; the rest stay stale
// and appear the next time round, once whatever they were waiting for is registered. A chain and a
// diamond need no special handling, and nothing holds a plan.
import { emit } from "@novox/mesh-sdk/events";
import { broker } from "@novox/mesh-sdk/messaging";
import { invokeTool } from "@novox/mesh-sdk/tools";
import { state } from "@novox/mesh-sdk/state";
import { Graph } from "./store.js";
import {
answerOf, builtFrom, placeable, BUILDER, BUILDS_SINCE_TOOL, BUILDS_STATE, MANIFESTS_STATE,
type Built, type BuildsSince, type LogEntry,
} from "./heard.js";
const graph = Graph.fromEnv();
// The schema is not brought up here. The mesh prepares this module's state before it starts this
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` in heard.ts.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = (event.body ?? {}) as Built;
const placing = placeable(body);
if (placing.placed === "no") {
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
// that names no module or commit is a fault in the builder, said rather than dropped, so it does
// not look like a missing event.
if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body);
return;
}
const { module, commit } = placing;
const { upgraded, previous } = await graph.register({
module,
commit,
repository: body.repository ?? "",
path: body.path ?? "",
ref: body.ref ?? "",
manifest: body.manifest ?? {},
}, body.made ?? [], body.against ?? []);
// **A replay is registered and announced to nobody.** See `replay` above: the graph gains what
// it was missing, and the mesh is told nothing happened, because nothing did.
if (body.replay) return;
await emit("registered", {
module, commit, upgraded,
});
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
// through modules that did not change, forever (ADR 0072).
if (!upgraded) return;
await emit("upgraded", {
module, commit, previous,
});
// What can be built now — stale, and waiting on nothing that is itself stale.
for (const next of await graph.buildable()) {
await emit("rebuild-needed", {
module: next.module,
builtAt: next.commit,
because: next.because,
});
}
};
// **From the builder's log** (novox/hq ADR 0571 clauses 1 and 9). The builder keeps every build's end in a log of its
// own and answers the builds that ended since a time by its tool. This module asks at its start for what it missed (all
// the log holds, taken in as history: registered, announced to nobody), and again whenever the builder's state of done
// keys changes, for what ended since the last build it took in. Registering a build already held changes nothing, and a
// build is taken in once per run of this module.
const taken = new Set<string>();
let since = new Date(0).toISOString();
let caughtUp = false;
let asking: Promise<void> | undefined;
let askAgain = false;
const manifestOf = async (digest: string | undefined): Promise<unknown> => {
if (!digest) return {};
const held = await state<{ manifest?: string }>(MANIFESTS_STATE).get(digest);
if (!held?.value.manifest) return {};
return JSON.parse(held.value.manifest);
};
const takeIn = async (e: LogEntry, replay: boolean): Promise<void> => {
const key = `${e.module}/${e.fp}`;
if (taken.has(key)) return;
const built = builtFrom(e, await manifestOf(e.manifest), replay);
if (!built) return;
await placeTheBuild({ body: built });
taken.add(key);
};
const catchUp = async (): Promise<void> => {
for (;;) {
const answer = answerOf<BuildsSince>(
await invokeTool(broker(), BUILDER, BUILDS_SINCE_TOOL, { since }),
);
for (const e of answer.builds ?? []) {
await takeIn(e, !caughtUp);
if (e.at > since) since = e.at;
}
if (!answer.more) break;
}
caughtUp = true;
};
/** One ask at a time; a change heard while asking is asked for again once, so none is missed. */
const ask = (): Promise<void> => {
if (asking) {
askAgain = true;
return asking;
}
asking = (async () => {
try {
do {
askAgain = false;
await catchUp();
} while (askAgain);
} catch (err) {
console.error("mesh-catalog: the builder's builds since", since, "could not be taken in:", err);
} finally {
asking = undefined;
}
})();
return asking;
};
// Asked AFTER watching begins, so a build ending while the first ask runs asks again rather than being missed: the watch
// hands every current key first (the start's ask), then every change.
await state(BUILDS_STATE).watch(async (change) => {
if (!change.current) await ask();
});
await ask();
// **And say that this catalogue has caught up** (novox/hq 04-ISSUES/050).
await emit("catching-up", {});