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:
+70
-10
@@ -1,6 +1,7 @@
|
||||
// Retirement (novox/hq ADR 0230): a consumer the mesh stops asking for is retired — access disabled,
|
||||
// data kept — only after the same result in five consecutive passes, and a set too large waits for a
|
||||
// person. Deletion is a person's act, of a retired consumer only.
|
||||
// data kept — only after the same result in five consecutive passes AND ten minutes (shortened here
|
||||
// unless a test is about it), and a set too large waits for a person. An adapter that cannot disable
|
||||
// is mark-only: remove is never called to retire. Deletion is a person's act, of a retired consumer only.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
@@ -28,13 +29,17 @@ interface World {
|
||||
retired: { as: string; why: string }[];
|
||||
removed: string[];
|
||||
deleted: string[];
|
||||
reenabled: string[];
|
||||
events: Record<string, unknown>[];
|
||||
said: string[];
|
||||
p: Provisioner;
|
||||
done(): void;
|
||||
}
|
||||
|
||||
async function world(names: string[], opts: { retire?: boolean; held?: string[] } = {}): Promise<World> {
|
||||
async function world(
|
||||
names: string[],
|
||||
opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number } = {},
|
||||
): Promise<World> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "retire-"));
|
||||
const receives = join(dir, "postgres-database.json");
|
||||
const ask = async (list: string[]) => {
|
||||
@@ -46,18 +51,19 @@ async function world(names: string[], opts: { retire?: boolean; held?: string[]
|
||||
await writeFile(receives, JSON.stringify({ contributions: 1, requirement: "postgres-database", given }));
|
||||
};
|
||||
await ask(names);
|
||||
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], events: [], said: [] } as unknown as World;
|
||||
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], reenabled: [], events: [], said: [] } as unknown as World;
|
||||
const was = console.error;
|
||||
console.error = (...a: unknown[]) => w.said.push(a.join(" "));
|
||||
const adapter = {
|
||||
async create(p: { as: string }) { w.created.push(p.as); },
|
||||
async remove(p: { as: string }) { w.removed.push(p.as); },
|
||||
async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; },
|
||||
...(opts.del === false ? {} : { async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; } }),
|
||||
...(opts.reenable ? { async reenable(p: { as: string }) { w.reenabled.push(p.as); } } : {}),
|
||||
...(opts.retire === false ? {} : { async retire(p: { as: string; why: string }) { w.retired.push({ as: p.as, why: p.why }); } }),
|
||||
...(opts.held ? { async held() { return opts.held!; } } : {}),
|
||||
};
|
||||
w.p = runProvisioner("postgres-database", adapter, {
|
||||
receives, everyMs: EVERY, node: "anchor",
|
||||
receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0,
|
||||
announce: (event, body) => { assert.equal(event, RETIREMENT_EVENT); w.events.push(body); },
|
||||
});
|
||||
// A pass is counted by the file being read: wait wall time for n passes and some slack.
|
||||
@@ -85,7 +91,7 @@ test("a transient empty list for four passes retires nothing; five stable passes
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.retired.length === 1);
|
||||
assert.equal(w.retired[0].as, "a");
|
||||
assert.match(w.retired[0].why, /5 passes/);
|
||||
assert.match(w.retired[0].why, /\d+ passes over/);
|
||||
assert.deepEqual(w.removed, []);
|
||||
assert.deepEqual(w.deleted, []);
|
||||
assert.deepEqual(changes(w), ["retired"]);
|
||||
@@ -144,17 +150,71 @@ test("asked for again, a retired consumer is created again and announced re-enab
|
||||
}
|
||||
});
|
||||
|
||||
test("an adapter without retire is retired through remove, as before", async () => {
|
||||
const w = await world(["a", "b", "c"], { retire: false });
|
||||
test("five passes are not enough: the same set must also hold for stableForMs", async () => {
|
||||
const HOLD = 400;
|
||||
const w = await world(["a", "b", "c"], { stableForMs: HOLD });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.removed.length === 1);
|
||||
const start = Date.now();
|
||||
await sleep(EVERY * 10); // well past five passes, well short of the hold
|
||||
assert.deepEqual(w.retired, [], "retired after five passes, before the set held long enough");
|
||||
await until(() => w.retired.length === 1, 3000);
|
||||
assert.ok(Date.now() - start >= HOLD - 5, `retired after ${Date.now() - start} ms, before ${HOLD} ms`);
|
||||
assert.equal((await w.p.retirement()).stable_for_seconds, 0); // 400 ms rounds to 0 s
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("the default hold is ten minutes", async () => {
|
||||
const { STABLE_FOR_MS } = await import("../dist/provisioner/index.js");
|
||||
assert.equal(STABLE_FOR_MS, 10 * 60_000);
|
||||
});
|
||||
|
||||
test("an adapter without retire is MARK-ONLY: nothing changes in the backend, remove only on delete", async () => {
|
||||
const w = await world(["a", "b", "c"], { retire: false, del: false });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => changes(w).includes("retired"));
|
||||
await sleep(EVERY * 10);
|
||||
assert.deepEqual(w.removed, [], "remove was called to retire");
|
||||
const ev = w.events.find((e) => e.change === "retired")!;
|
||||
assert.equal((ev.consumers as { access: string }[])[0].access, "kept");
|
||||
assert.ok(w.said.some((l) => /MARK ONLY/.test(l) && /KEEPS ITS ACCESS/.test(l)), "mark-only was not said loudly");
|
||||
const r = await w.p.retirement();
|
||||
assert.equal(r.retires_by, "mark-only");
|
||||
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["a", "kept"]]);
|
||||
// Asked again: created again, nothing to undo.
|
||||
await w.ask(["a", "b", "c"]);
|
||||
await until(() => changes(w).includes("reenabled"));
|
||||
assert.deepEqual(w.removed, []);
|
||||
// Gone again, then a person's delete: the one path to remove.
|
||||
await w.ask(["b", "c"]);
|
||||
await until(async () => (await w.p.retirement()).retired.length === 1);
|
||||
await w.p.deleteRetired("a", { why: "gone", confirm: "a" });
|
||||
assert.deepEqual(w.removed, ["a"]);
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("an adapter with retire and reenable: disabled on retire, reenabled before create when asked again", async () => {
|
||||
const w = await world(["a", "b", "c"], { reenable: true });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.retired.length === 1);
|
||||
assert.equal((await w.p.retirement()).retires_by, "disable");
|
||||
assert.equal((await w.p.retirement()).retired[0].access, "disabled");
|
||||
const before = w.created.length;
|
||||
await w.ask(["a", "b", "c"]);
|
||||
await until(() => w.created.length > before);
|
||||
assert.deepEqual(w.reenabled, ["a"]);
|
||||
assert.deepEqual(w.removed, []);
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("four of four waits for a person: approve the exact set retires, another set is refused", async () => {
|
||||
const w = await world(["a", "b", "c", "d"]);
|
||||
try {
|
||||
|
||||
+23
-11
@@ -31,7 +31,7 @@ test("module tools register and collect, a thrower is skipped not fatal", () =>
|
||||
assert.equal(broken?.tools.length, 0);
|
||||
});
|
||||
|
||||
test("provisioner creates each consumer with the mesh's login and password, removes on withdrawal", async () => {
|
||||
test("provisioner creates each consumer with the mesh's login and password; remove waits for a person's delete", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "prov-"));
|
||||
const created: { as: string; password: string; values: unknown }[] = [];
|
||||
const removed: string[] = [];
|
||||
@@ -57,7 +57,7 @@ test("provisioner creates each consumer with the mesh's login and password, remo
|
||||
removed.push(p.as);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
|
||||
await waitFor(() => created.length === 1, 2000);
|
||||
@@ -67,10 +67,18 @@ test("provisioner creates each consumer with the mesh's login and password, remo
|
||||
assert.equal(created[0].password, "minted-pw");
|
||||
assert.deepEqual(created[0].values, { name: "webapp" });
|
||||
|
||||
// Withdraw: the mesh drops the consumer from the file → the harness removes it via the adapter.
|
||||
// The mesh drops the consumer: it is RETIRED, mark only — this adapter cannot disable, so remove is
|
||||
// NOT called (novox/hq ADR 0230); it keeps its access until a person deletes it.
|
||||
await writeFile(receives, doc([]));
|
||||
await waitFor(() => removed.length === 1, 2000);
|
||||
assert.equal(removed[0], "webapp-anchor");
|
||||
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
||||
const r = await stop.retirement();
|
||||
assert.equal(r.retires_by, "mark-only");
|
||||
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["webapp-anchor", "kept"]]);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.deepEqual(removed, [], "remove was called to retire");
|
||||
// Only a person's delete reaches remove.
|
||||
await stop.deleteRetired("webapp-anchor", { why: "gone for good", confirm: "webapp-anchor" });
|
||||
assert.deepEqual(removed, ["webapp-anchor"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
@@ -103,7 +111,7 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
|
||||
removed.push(p.derived);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
|
||||
await waitFor(() => created.length === 1, 2000);
|
||||
@@ -115,10 +123,12 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
|
||||
await waitFor(() => created.length === 2, 2000);
|
||||
assert.deepEqual(created[1], { bucket: "mesh-anchor-files" });
|
||||
|
||||
// Withdrawn: the file no longer says anything about this consumer, and the adapter is still
|
||||
// told which bucket to take away.
|
||||
// Retired, then deleted by a person: the file no longer says anything about this consumer, and the
|
||||
// adapter is still told which bucket to take away.
|
||||
await writeFile(receives, doc([]));
|
||||
await waitFor(() => removed.length === 1, 2000);
|
||||
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(removed, []);
|
||||
await stop.deleteRetired("mesh_anchor_ncloud", { why: "unassigned", confirm: "mesh_anchor_ncloud" });
|
||||
assert.deepEqual(removed[0], { bucket: "mesh-anchor-files" });
|
||||
stop();
|
||||
});
|
||||
@@ -185,7 +195,7 @@ test("provisioner removes nothing while its contributions file cannot be used (n
|
||||
removed.push(p.as);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
await waitFor(() => created.length === 2, 2000);
|
||||
|
||||
@@ -210,7 +220,9 @@ test("provisioner removes nothing while its contributions file cannot be used (n
|
||||
assert.notEqual((await stop.retirement()).waiting, null, "the retirement never waited for a person");
|
||||
assert.deepEqual(removed, [], "two of two consumers were retired without a person");
|
||||
await stop.approve(["mesh_anchor_mail", "mesh_anchor_forge"], { why: "both unassigned on purpose", by: "test" });
|
||||
assert.deepEqual([...removed].sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
|
||||
// Retired mark-only: still nothing removed; that is a deletion, a person's further act.
|
||||
assert.deepEqual(removed, []);
|
||||
assert.deepEqual((await stop.retirement()).retired.map((x) => x.consumer).sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user