242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
// A reader's picture of one state of the indexer or the assigner (novox/hq ADR 0615 §1, §4 to §6; design 25 § 12.2): asked once
|
|
// of the owner's read tool, then kept by the owner's views events alone. It is the TypeScript form of the Go reader
|
|
// (mesh-delivery sdk/views Follower) and follows the same rules:
|
|
//
|
|
// - at its start it asks the tool until it is answered, waiting longer between asks (up to a minute), and past two minutes
|
|
// says so naming the owner and the tool; it never reads the owner's state instead;
|
|
// - an event of another `created` is a gap (the owner's state was made again); one at or below the revision held is already
|
|
// in the picture; one past the next revision is a gap; one with `gap: true` (a value too large to carry) is a gap;
|
|
// - a gap asks the tool once more, said in the log;
|
|
// - events heard while an ask runs are taken again after its answer, so none is lost.
|
|
|
|
/** The answer of the owner's read tool with `values`: one page of the state, at the owner's revision. */
|
|
export interface ViewAnswer {
|
|
v: number;
|
|
owner: string;
|
|
state: string;
|
|
revision: number;
|
|
created: string;
|
|
values: Record<string, unknown>;
|
|
next?: string;
|
|
}
|
|
|
|
/** One change of one key, said on the owner's views family. */
|
|
export interface ViewEvent {
|
|
v: number;
|
|
owner: string;
|
|
state: string;
|
|
key: string;
|
|
revision: number;
|
|
created: string;
|
|
deleted?: boolean;
|
|
value?: unknown;
|
|
gap?: boolean;
|
|
}
|
|
|
|
/** One change of the picture, in the form the module's watch handed before. */
|
|
export interface ViewChange {
|
|
key: string;
|
|
op: "put" | "delete";
|
|
value?: unknown;
|
|
revision?: number;
|
|
current?: boolean;
|
|
}
|
|
|
|
export interface ViewFollowerOptions {
|
|
owner: string;
|
|
state: string;
|
|
/** The owner's read tool as the manifest invokes it, for the log. */
|
|
tool: string;
|
|
/** Asks the tool for the page after `after` (none: the first). */
|
|
ask: (after?: string) => Promise<ViewAnswer>;
|
|
take: (change: ViewChange) => void | Promise<void>;
|
|
say?: (line: string) => void;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
now?: () => number;
|
|
/** How long it asks before it says so; two minutes. */
|
|
boundMs?: number;
|
|
/** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */
|
|
raise?: (asked: Date, error: string) => void | Promise<void>;
|
|
clear?: () => void | Promise<void>;
|
|
}
|
|
|
|
export class ViewFollower {
|
|
readonly values = new Map<string, unknown>();
|
|
revision = 0;
|
|
created = "";
|
|
ready = false;
|
|
asks = 0;
|
|
byHand = 0;
|
|
private asking = false;
|
|
private during: ViewEvent[] = [];
|
|
|
|
private readonly o: ViewFollowerOptions;
|
|
|
|
constructor(o: ViewFollowerOptions) {
|
|
this.o = o;
|
|
}
|
|
|
|
private say(line: string): void {
|
|
(this.o.say ?? ((l) => console.error(`[gitea] ${l}`)))(line);
|
|
}
|
|
|
|
/** Asks until answered: each failed ask waits, doubling to a minute, and past the bound says what waits. */
|
|
async start(): Promise<void> {
|
|
await this.askUntil("start");
|
|
}
|
|
|
|
/** Asks once more by hand, until answered (a hand act, counted). */
|
|
async again(why: string): Promise<void> {
|
|
this.byHand++;
|
|
await this.askUntil(`by hand: ${why}`);
|
|
}
|
|
|
|
private async askUntil(why: string): Promise<void> {
|
|
if (this.asking) return; // one ask at a time; the one running brings the picture to its newest
|
|
this.asking = true;
|
|
this.during = [];
|
|
const sleep = this.o.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
const now = this.o.now ?? Date.now;
|
|
const first = now();
|
|
let said = false;
|
|
try {
|
|
for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) {
|
|
try {
|
|
await this.askOnce();
|
|
if (said) {
|
|
this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`);
|
|
await this.o.clear?.();
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
if (!said && now() - first >= (this.o.boundMs ?? 120_000)) {
|
|
said = true;
|
|
await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err));
|
|
this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` +
|
|
`(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`);
|
|
}
|
|
await sleep(wait);
|
|
}
|
|
}
|
|
} finally {
|
|
this.asking = false;
|
|
}
|
|
}
|
|
|
|
private async askOnce(): Promise<void> {
|
|
this.asks++;
|
|
const values = new Map<string, unknown>();
|
|
let firstPage: ViewAnswer | undefined;
|
|
for (let after: string | undefined; ;) {
|
|
const a = await this.o.ask(after);
|
|
firstPage ??= a;
|
|
for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v);
|
|
if (!a.next || a.next === after) break;
|
|
after = a.next;
|
|
}
|
|
const old = new Map(this.values);
|
|
this.values.clear();
|
|
for (const [k, v] of values) this.values.set(k, v);
|
|
this.revision = firstPage.revision;
|
|
this.created = firstPage.created;
|
|
this.ready = true;
|
|
const during = this.during.sort((a, b) => a.revision - b.revision);
|
|
this.during = [];
|
|
for (const ev of during) {
|
|
if (ev.created === this.created && ev.revision === this.revision + 1 && !ev.gap) {
|
|
if (ev.deleted) this.values.delete(ev.key);
|
|
else this.values.set(ev.key, ev.value);
|
|
this.revision = ev.revision;
|
|
}
|
|
}
|
|
for (const [k, v] of this.values) {
|
|
if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.take({ key: k, op: "put", value: v, current: true });
|
|
}
|
|
for (const k of old.keys()) if (!this.values.has(k)) await this.o.take({ key: k, op: "delete", current: true });
|
|
}
|
|
|
|
/** One event of the owner's views family: taken into the picture, or a gap that asks the tool once more. */
|
|
async hear(ev: ViewEvent): Promise<void> {
|
|
if (ev.state !== this.o.state) return;
|
|
if (this.asking) this.during.push(ev);
|
|
if (!this.ready) return; // the first view holds it
|
|
let why = "";
|
|
if (ev.created !== this.created) why = `${this.o.owner}'s ${this.o.state} was made again`;
|
|
else if (ev.revision <= this.revision) return;
|
|
else if (ev.revision > this.revision + 1) why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.revision}`;
|
|
else if (ev.gap) why = `${this.o.owner}'s ${this.o.state}.${ev.key} was too large to carry`;
|
|
if (why) {
|
|
this.say(`a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`);
|
|
await this.askUntil(`a gap: ${why}`);
|
|
return;
|
|
}
|
|
if (ev.deleted) this.values.delete(ev.key);
|
|
else this.values.set(ev.key, ev.value);
|
|
this.revision = ev.revision;
|
|
await this.o.take({ key: ev.key, op: ev.deleted ? "delete" : "put", value: ev.value, revision: ev.revision, current: false });
|
|
}
|
|
}
|
|
|
|
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
|
|
|
/** Where a first view stands (design 25 § 12.2): the owner and its tool, the revision the picture holds, how many asks. (r) */
|
|
export function status(f: ViewFollower, owner: string, tool: string, state: string): Record<string, unknown> {
|
|
return { owner, tool, state, revision: f.revision, created: f.created, keys: f.values.size, answered: f.ready, asks: f.asks, "by-hand": f.byHand };
|
|
}
|
|
|
|
/** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */
|
|
export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined,
|
|
record: (act: Record<string, unknown>) => Promise<unknown>): Tool[] {
|
|
return [
|
|
{
|
|
name: "gitea_first_views",
|
|
description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, how many asks (by hand among them). (r)",
|
|
input: {},
|
|
run: async () => {
|
|
const v = view();
|
|
return { views: v ? [status(v.follower, v.owner, v.tool, v.state)] : [] };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_first_view_again",
|
|
description: "Ask the indexer's modules again by hand: a hand act, for a picture the operator doubts. Answers where it stands after. (a)",
|
|
input: {},
|
|
run: async () => {
|
|
const v = view();
|
|
if (!v) throw new Error("the module follows no first view yet");
|
|
// A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing.
|
|
try {
|
|
await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" });
|
|
} catch (err) {
|
|
throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
await v.follower.again("the operator asked");
|
|
return { views: [status(v.follower, v.owner, v.tool, v.state)] };
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */
|
|
export function findingKey(scope: string, id: string, kind: string): string {
|
|
return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join(".");
|
|
}
|
|
|
|
export const UNANSWERED_KIND = "first-view-unanswered";
|
|
|
|
/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the
|
|
* condition keeper raises the condition from the module's state `findings`. */
|
|
export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date):
|
|
{ key: string; value: Record<string, unknown> } {
|
|
const id = `${reader}.${owner}.${state}`;
|
|
const key = findingKey("module", id, UNANSWERED_KIND);
|
|
const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`;
|
|
return { key, value: {
|
|
v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary,
|
|
headline: `${reader} waits for ${owner}`,
|
|
explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` +
|
|
`Look at ${owner}'s runtime of its own: is it running and connected?`,
|
|
resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(),
|
|
} };
|
|
}
|