Retire only after ten minutes as well as five passes, and never through remove

Five passes are 25 s, shorter than a real hiccup. And a provider's remove can
destroy — a ledger file, a public DNS record — so an adapter without a retire
of its own is mark-only: the consumer keeps its access, is marked and said, and
remove is reached only by a person's delete (hq ADR 0230).
This commit is contained in:
jochen
2026-10-06 14:30:39 +02:00
parent e0453316b8
commit 0704908d0b
4 changed files with 196 additions and 65 deletions
+91 -35
View File
@@ -47,11 +47,13 @@ export interface Provision {
* the credential is the mesh's, already delivered to the consumer. */
export interface Adapter {
create(p: Provision): Promise<void>;
/** Retire what `create` made for this consumer when the adapter has no `retire`: disable, never destroy (issue 241). `derived` is what the mesh last derived for
* it, remembered by the harness: the mesh has already dropped the consumer from the
* contributions file by the time this is called, so it cannot be read again, and a provider
* that recomputed it would be keeping the second copy of a rule this removed
* (novox/hq ADR 0201). */
/** Remove what `create` made for this consumer. **Called only to DELETE a retired consumer, on a
* person's approved `cleanup delete`, and only when the adapter has no `delete`** (novox/hq ADR
* 0230): many providers' remove destroys something — a ledger file, a public DNS record, a user —
* and nothing is destroyed without a person. Retiring never calls it. `derived` is what the mesh
* last derived for it, remembered by the harness: the mesh has already dropped the consumer from
* the contributions file by then, so it cannot be read again, and a provider that recomputed it
* would be keeping the second copy of a rule this removed (novox/hq ADR 0201). */
remove(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
/** Optional: whether the backend still holds this consumer's credential exactly as `p` says.
* Asked of every consumer already applied, every `verifyEveryMs`. `false` makes the harness apply
@@ -60,17 +62,22 @@ export interface Adapter {
* of being trusted from memory (novox/hq issue 120). An adapter without it is trusted from memory,
* as before. It must only read: it is asked often, and must never change the backend. */
holds?(p: Provision): Promise<boolean>;
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — disable its
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — DISABLE its
* access, reversibly, and mark it in the backend "to delete" with when and why; delete nothing.
* `create` must re-enable what this disabled and clear the mark, so a consumer asked for again is
* back as it was. An adapter without it is retired through `remove`, which since issue 241 already
* disables rather than destroys. */
* Asked for again, `reenable` (when given) and then `create` bring it back as it was.
*
* **An adapter without it is MARK-ONLY**: retiring a consumer then changes nothing in the backend —
* its access stays as it was — and it is only marked, in this harness, "retired, access kept, needs
* deletion", said loudly and announced. Only a person's `cleanup delete` then removes it. */
retire?(p: {
readonly as: string;
readonly derived: Readonly<Record<string, unknown>>;
readonly why: string;
readonly at: Date;
}): Promise<void>;
/** Optional, with `retire`: undo what `retire` disabled, before `create` runs for a consumer asked
* for again. An adapter whose `create` already re-enables needs none. */
reenable?(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
/** Optional: every consumer the backend holds retired — what `cleanup list` shows. */
retired?(): Promise<RetiredConsumer[]>;
/** Optional: DELETE one retired consumer and its data. Only ever called for a consumer this
@@ -92,6 +99,9 @@ export interface RetiredConsumer {
readonly size_bytes: number;
/** "consumer", or a backend's own word for something else it keeps retired. */
readonly kind: string;
/** "disabled" when the adapter retired it; "kept" for a mark-only provider, whose consumer keeps its
* access until a person deletes it (novox/hq ADR 0230). */
readonly access?: "disabled" | "kept";
}
/** What `provisioner_retirement` answers: the provider's retirement state (novox/hq ADR 0230). */
@@ -100,6 +110,9 @@ export interface Retirement {
readonly node: string;
readonly held: string[];
readonly stable_passes: number;
readonly stable_for_seconds: number;
/** "disable" when the adapter can retire; "mark-only" when it cannot (novox/hq ADR 0230). */
readonly retires_by: "disable" | "mark-only";
readonly bound: string;
readonly waiting: { consumers: { consumer: string; node?: string }[]; since: string; held: number } | null;
readonly rejected: { consumers: { consumer: string; node?: string }[]; by: string; why: string; at: string } | null;
@@ -140,9 +153,13 @@ export interface ProvisionerOptions {
* loop is sequential, so a check that hangs would otherwise stop every consumer's provisioning. */
holdsTimeoutMs?: number;
/** How many consecutive passes that read the file must name the same set of consumers no longer
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5: at the default interval a
* retirement comes 20 s after the first pass that saw it. Additions and changes are never delayed. */
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5. Additions and changes are
* never delayed. */
stablePasses?: number;
/** And how long, in ms, that same set must have held since the first pass that saw it. Defaults to
* STABLE_FOR_MS, ten minutes: five passes alone are 25 s, shorter than a controller restart, a store
* reconnecting or a file half written. Both must hold. */
stableForMs?: number;
/** A stable set larger than this is not retired without a person. Defaults to 3. */
retireAtOnce?: number;
/** Nor a set larger than this share of the consumers held, where more than one is held. Defaults
@@ -160,6 +177,9 @@ export interface ProvisionerOptions {
module?: string;
}
/** The least time the same unasked set must hold before it is retired (novox/hq ADR 0230). */
export const STABLE_FOR_MS = 10 * 60_000;
/** The longest a consumer whose `create` keeps failing to satisfy `holds` waits between checks. */
const MAX_BACKOFF_MS = 60 * 60_000;
@@ -190,8 +210,9 @@ interface Contribution {
*
* **A consumer the mesh stops asking for is retired, never withdrawn whole (novox/hq ADR 0230).**
* ACTIVE → RETIRED once the same set of consumers has gone unasked in `stablePasses` consecutive
* passes that read the file; RETIRED is access disabled and marked, nothing deleted, and asked for
* again it is re-enabled at once. A stable set of more than `retireAtOnce`, or more than
* passes that read the file AND for `stableForMs` (ten minutes); RETIRED is access disabled and
* marked — or, for an adapter with no `retire`, only marked here with its access kept — nothing
* deleted, and asked for again it is re-enabled at once. `remove` is never called to retire. A stable set of more than `retireAtOnce`, or more than
* `retireFraction` of those held where more than one is held, is not retired without a person: the
* provider waits for approve or reject. RETIRED → DELETED only through `provisioner_delete`.
*/
@@ -201,6 +222,8 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
const verifyEveryMs = opts.verifyEveryMs ?? 60_000;
const holdsTimeoutMs = opts.holdsTimeoutMs ?? 30_000;
const stablePasses = opts.stablePasses ?? 5;
const stableForMs = opts.stableForMs ?? STABLE_FOR_MS;
const retiresBy: "disable" | "mark-only" = adapter.retire ? "disable" : "mark-only";
const retireAtOnce = opts.retireAtOnce ?? 3;
const retireFraction = opts.retireFraction ?? 0.5;
const sayAgainEveryMs = opts.sayAgainEveryMs ?? 15 * 60_000;
@@ -209,10 +232,12 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// Retirement (novox/hq ADR 0230). The unasked set last seen and in how many consecutive readable
// passes; a set too large, waiting for a person; a set a person rejected; and every consumer known
// retired, by name.
let stable = { set: "", count: 0 };
let stable = { set: "", count: 0, since: 0 };
let pending: { consumers: string[]; since: number; held: number; saidAt: number } | null = null;
let rejected: { consumers: string[]; by: string; why: string; at: number } | null = null;
const retired = new Map<string, { node?: string; at: number; why: string }>();
const retired = new Map<string, {
node?: string; at: number; why: string; derived?: Readonly<Record<string, unknown>>; access: "disabled" | "kept";
}>();
let seeded = false;
let verifiedAt = 0;
// Consumers the backend reported lost, and how many times in a row. A `create` that succeeded and
@@ -246,7 +271,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// waits for the next one.
if (given === null) {
// Not a result: the passes that make a retirement stable must each have read the file.
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return;
}
if (!seeded) await seed();
@@ -326,6 +351,10 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
}
try {
const was = retired.get(g.as);
if (was && was.access === "disabled" && adapter.reenable) {
await adapter.reenable({ as: g.as, derived: was.derived ?? p.derived });
}
await adapter.create(p);
applied.set(g.as, { hash: h, derived: p.derived, node: g.node });
if (retired.has(g.as)) {
@@ -369,11 +398,13 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
rejected = null;
}
if (unasked.length === 0) {
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return;
}
stable = stable.set === key ? { set: key, count: stable.count + 1 } : { set: key, count: 1 };
if (stable.count < stablePasses) return;
stable = stable.set === key
? { set: key, count: stable.count + 1, since: stable.since }
: { set: key, count: 1, since: Date.now() };
if (stable.count < stablePasses || Date.now() - stable.since < stableForMs) return;
if (rejected) return; // a person said keep them, and the mesh still says the same
const held = applied.size;
if (overTheBound(unasked.length, held)) {
@@ -392,11 +423,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
return;
}
const why = `the mesh stopped asking for it: ${stablePasses} passes from ${iso(Date.now() - (stablePasses - 1) * everyMs)}`;
const why = `the mesh stopped asking for it: the same answer in ${stable.count} passes over ` +
`${Math.round((Date.now() - stable.since) / 1000)}s, from ${iso(stable.since)}`;
const done = await retireAll(unasked, why);
if (done.length > 0) await tell("retired", { consumers: done.map((as) => retiredBody(as)), held, why });
}
function retiresByAccess(): "disabled" | "kept" {
return adapter.retire ? "disabled" : "kept";
}
function overTheBound(n: number, held: number): boolean {
return n > retireAtOnce || (held > 1 && n > retireFraction * held);
}
@@ -405,27 +441,36 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
return `more than ${retireAtOnce}, or more than ${Math.round(retireFraction * 100)}% of the ${held} held`;
}
// Retire each, as it is said: disabled and marked by the adapter, nothing deleted. Answers those
// that went; one that failed stays active and is asked again by the next stable pass.
// Retire each, as it is said: disabled and marked by the adapter — or, where the adapter cannot
// disable, only marked here, its access kept. Nothing is deleted, and `remove` is never called: it
// is the delete of a provider without one. Answers those that went; one that failed stays active and
// is asked again once its set is stable again.
async function retireAll(names: readonly string[], why: string): Promise<string[]> {
const done: string[] = [];
for (const as of names) {
const was = applied.get(as);
if (was === undefined) continue;
say(`${as}: no longer in ${receives}; retiring it (access disabled, nothing deleted): ${why}`);
try {
const at = new Date();
if (adapter.retire) await adapter.retire({ as, derived: was.derived, why, at });
else await adapter.remove({ as, derived: was.derived });
if (adapter.retire) {
say(`${as}: no longer in ${receives}; RETIRED — access disabled, nothing deleted: ${why}`);
await adapter.retire({ as, derived: was.derived, why, at });
} else {
say(
`${as}: no longer in ${receives}; RETIRED, MARK ONLY — this provider cannot disable a consumer ` +
`(its adapter has no retire), so ${as} KEEPS ITS ACCESS and its data until a person deletes it ` +
`with \`cleanup delete\`. Nothing was changed in the backend (novox/hq ADR 0230): ${why}`,
);
}
applied.delete(as);
lost.delete(as);
retired.set(as, { node: was.node, at: at.getTime(), why });
retired.set(as, { node: was.node, at: at.getTime(), why, derived: was.derived, access: adapter.retire ? "disabled" : "kept" });
done.push(as);
} catch (err) {
say(`${as}: retiring failed, will retry: ${err}`);
}
}
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return done;
}
@@ -435,7 +480,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
function retiredBody(as: string): Record<string, unknown> {
const r = retired.get(as);
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "" };
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "", access: r?.access ?? retiresByAccess() };
}
// Said on the bus, never failing the act it reports (the journal still has it).
@@ -459,7 +504,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
for (const r of (await adapter.retired?.()) ?? []) {
if (r.kind === "consumer" && !retired.has(r.consumer)) {
retired.set(r.consumer, { node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why });
retired.set(r.consumer, {
node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why, access: r.access ?? retiresByAccess(),
});
}
}
seeded = true;
@@ -484,11 +531,15 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
const list: RetiredConsumer[] = [...fromBackend];
for (const [as, r] of retired) {
if (!list.some((x) => x.consumer === as && x.kind === "consumer")) {
list.push({ consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer" });
list.push({
consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer",
access: r.access,
});
}
}
return {
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses, bound: bound(applied.size),
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses,
stable_for_seconds: Math.round(stableForMs / 1000), retires_by: retiresBy, bound: bound(applied.size),
waiting: pending && { consumers: named(pending.consumers), since: iso(pending.since), held: pending.held },
rejected: rejected && { consumers: named(rejected.consumers), by: rejected.by, why: rejected.why, at: iso(rejected.at) },
retired: list,
@@ -546,11 +597,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
if (applied.has(consumer)) throw new Error(`delete: ${consumer} is active, not retired — only a retired consumer is deleted`);
const known = retired.has(consumer) || ((await adapter.retired?.()) ?? []).some((r) => r.consumer === consumer);
if (!known) throw new Error(`delete: ${consumer} is not retired here`);
if (!adapter.delete) throw new Error(`delete: this provider (${resource}) cannot delete; its adapter has no delete`);
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
const r = await adapter.delete({ as: consumer, why: act.why });
const freed = (r && typeof r.freedBytes === "number") ? r.freedBytes : -1;
const was = retired.get(consumer);
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
let freed = -1;
if (adapter.delete) {
const r = await adapter.delete({ as: consumer, why: act.why });
if (r && typeof r.freedBytes === "number") freed = r.freedBytes;
} else {
// The one path to `remove`: a person's approved deletion (novox/hq ADR 0230).
await adapter.remove({ as: consumer, derived: was?.derived ?? {} });
}
retired.delete(consumer);
await tell("deleted", {
consumers: [{ consumer, node: was?.node, size_bytes: freed }], why: act.why, by: act.by ?? "", via: act.via ?? "",