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:
+91
-35
@@ -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 ?? "",
|
||||
|
||||
Reference in New Issue
Block a user