Dev #8

Merged
Michilis merged 7 commits from dev into main 2026-08-25 14:40:03 +00:00
6 changed files with 455 additions and 18 deletions
Showing only changes of commit 9ffa53094d - Show all commits
+13
View File
@@ -96,6 +96,19 @@ SEO_PRODUCT_JSONLD=1
# so reviews the old site published to snort/primal were invisible to it. # so reviews the old site published to snort/primal were invisible to it.
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
# How many events a backfill has to read before it counts as having read anything.
#
# A backfill asks every relay above for the whole history of four kinds; on a working
# relay list that is thousands of events. Under this floor, discovery logs
# `ERROR discovery starvation suspected` and /api/health answers 503 with
# `discovery_starved: true` until the next backfill clears it.
#
# This exists because a RELAYS list missing the relay that carries the announcement
# archive returned about thirty events per backfill for a year, reported ok=true every
# time, and left the index at eight mints with every health signal green. Lower it only
# for a private or test relay that genuinely holds less; 1 disables the check.
#BACKFILL_MIN_EVENTS=200
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home # Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind # page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
# 0 at all and snort/primal hold almost none, so the two aggregators below are what # 0 at all and snort/primal hold almost none, so the two aggregators below are what
+78 -1
View File
@@ -585,6 +585,7 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
| `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. | | `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. |
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` | | `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list | | `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
| `BACKFILL_MIN_EVENTS` | `200` | Events a backfill has to read before it counts as one. Under it, discovery logs `ERROR discovery starvation suspected` and health goes 503. See [Starvation](#discovery-starvation). |
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles | | `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles | | `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel | | `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
@@ -717,7 +718,7 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
| Endpoint | Notes | | Endpoint | Notes |
| -------------------- | ------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------ |
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. | | `GET /api/health` | Never cached. 503 when probes are stale, discovery failed, or discovery is starved. Carries the last cycle's per-relay outcome. |
| `GET /api/stats` | Network counters, memoized 60s in process. | | `GET /api/stats` | Network counters, memoized 60s in process. |
| `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. | | `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. |
| `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. | | `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
@@ -725,6 +726,82 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
`/icons/*` serves the cached mint icons. `/icons/*` serves the cached mint icons.
### Discovery starvation
For about a year, `GET /api/health` said `ok`, every discovery cycle reported
`ok=true`, and the index sat at eight mints. The production `RELAYS` list did not
include the relay carrying the kind 38000/38172 archive, so each backfill read about
thirty events, wrote them faithfully, and the nightly build republished the result.
Nothing was broken in a way anything measured.
What was missing is that "the cycle completed" and "the cycle read anything" are
different claims, and only the first one was being made. Three things now make the
second one:
**Per-relay attribution.** A cycle records, for every relay in `RELAYS`, whether a
socket opened, how many events it sent, and whether it ended in a real EOSE. Counts are
taken before cross-relay deduplication, so they say what each relay contributed rather
than what happened to be new because of it. The one-line cycle log carries the lot:
```
INFO discovery cycle mode=backfill events=1528 … starved=false \
relays=wss://relay.cashumints.space=12 wss://nos.lol=1566 wss://relay.azzamo.net=10 \
wss://relay.snort.social=18 wss://relay.primal.net=1
```
Read that line before changing `RELAYS`. It is also how you find out that most of this
network's archive currently sits behind one relay.
**A WARN per relay, naming it.** A relay that would not connect is warned about on every
cycle. A relay that connected and sent nothing is warned about on backfills only — an
incremental cycle asking for one interval is *supposed* to come back empty, and an
hourly warning about that would train everyone to skip the line that eventually matters.
```
WARN discovery relay unreachable relay=wss://relay.example.invalid mode=backfill
WARN discovery relay returned no events relay=wss://relay.azzamo.net mode=backfill
```
**A floor.** `BACKFILL_MIN_EVENTS`, 200 by default. A backfill asks five relays for the
entire history of four kinds; on a working relay set that is thousands of events. Under
the floor:
```
ERROR discovery starvation suspected events=31 floor=200 relays=5 silent=4 unreachable=0
```
and a flag is set that `GET /api/health` reports as `discovery_starved`, which forces
`status` to `degraded` and the response to **503**. The flag is sticky across
incremental cycles: an hourly cycle finding four events must not clear a starvation a
backfill diagnosed. Only the next backfill clears it.
`GET /api/health` grew four fields for this:
```json
{
"status": "degraded",
"discovery_relays": [
{ "url": "wss://relay.cashumints.space", "connected": true, "events": 12, "eose": true },
{ "url": "wss://relay.example.invalid", "connected": false, "events": 0, "eose": false }
],
"last_discovery_events": 31,
"last_discovery_mode": "backfill",
"discovery_starved": true,
"backfill_min_events": 200
}
```
**A fresh database reports 503 until its first backfill finishes, and that is correct.**
Before any backfill has run, nothing has confirmed that this deployment's relay list
reads anything at all, and answering `ok` would be the original bug in miniature. In
practice it holds `cashumints-web.service` at its health gate — which is the point: a
first deploy should not publish a site built from an empty index. The state is stored in
the database rather than in memory for the same reason, so a restart cannot launder a
starvation into "no cycle yet".
To silence it deliberately on a deployment that genuinely has less history than this —
a private relay, a test rig — set `BACKFILL_MIN_EVENTS=1`.
### Indexing on demand ### Indexing on demand
```bash ```bash
+14
View File
@@ -112,6 +112,20 @@ export const config = {
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'), iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ?? relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[], [...DEFAULT_RELAYS]) as string[],
/**
* The floor a backfill cycle has to clear before it counts as a real read.
*
* For about a year this deployment's RELAYS list did not include the relay carrying
* the kind 38000/38172 archive. Every backfill returned about thirty events, wrote
* them, reported ok=true, and the index sat at eight mints while every health signal
* stayed green. A backfill asks five relays for the whole history of four kinds; on a
* working relay set it comes back with thousands. Anything under this is not a quiet
* network, it is a misconfigured one, and it says so in the log and on /api/health.
*
* Raise it on a deployment that genuinely has more history, lower it for a local
* test relay. It is deliberately not zero-able: set it to 1 if you mean "off".
*/
backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200),
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10), probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60), discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8), probeConcurrency: int('PROBE_CONCURRENCY', 8),
+276 -14
View File
@@ -19,9 +19,10 @@ import {
type FedimintAnnouncement, type FedimintAnnouncement,
type LnurlAnnouncement, type LnurlAnnouncement,
type LnurlFields, type LnurlFields,
type RelayHealth,
} from '@cashumints/shared'; } from '@cashumints/shared';
import { config } from './config.ts'; import { config } from './config.ts';
import { getDb, setState, getStateNumber } from './db.ts'; import { getDb, setState, getState, getStateNumber } from './db.ts';
import type { Sql } from './db-driver.ts'; import type { Sql } from './db-driver.ts';
import { log } from './log.ts'; import { log } from './log.ts';
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts'; import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
@@ -39,8 +40,118 @@ export interface DiscoveryResult {
newMints: string[]; newMints: string[];
newReviews: number; newReviews: number;
ok: boolean; ok: boolean;
/** What each configured relay actually did, in `config.relays` order. */
relays: RelayHealth[];
/** A backfill that came in under `config.backfillMinEvents`. */
starved: boolean;
} }
/**
* What the last cycle did, kept so /api/health can answer for it.
*
* Written to the `state` table rather than held in memory, because the question it
* answers — "is discovery actually reading anything?" — has to survive the restart that
* would otherwise reset it to "no cycle yet, nothing to report". A process that crash
* loops would clear an in-memory flag on every attempt.
*/
export interface DiscoveryReport {
at: number;
mode: 'backfill' | 'incremental';
events: number;
ok: boolean;
relays: RelayHealth[];
starved: boolean;
}
/** `state` key holding the JSON of the above. */
const REPORT_KEY = 'last_discovery_report';
/**
* The last cycle's report, or null before any cycle has run.
*
* A row that will not parse reads as null — the same as no cycle — because the caller
* is a health endpoint and "I cannot tell you" must not be dressed up as "fine".
*/
export async function lastDiscoveryReport(): Promise<DiscoveryReport | null> {
const raw = await getState(REPORT_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as DiscoveryReport;
return Array.isArray(parsed.relays) ? parsed : null;
} catch {
return null;
}
}
/**
* Per-relay bookkeeping for one cycle.
*
* Every relay in `config.relays` gets a row up front, including the ones that are never
* reached, because a relay that produced no row at all is exactly the one worth naming:
* the year-long starvation was a relay list that connected cleanly and simply did not
* hold the archive, and the only field that would have shown it is a zero here.
*
* `events` counts what a relay sent *before* cross-relay deduplication, so five relays
* carrying the same 400 events report 400 each rather than 400 once and 0 four times.
* Attribution is the whole point; the deduplicated total is reported separately.
*/
class RelayTally {
private readonly rows = new Map<string, { events: number; subs: number; eoses: number; connected: boolean }>();
constructor(urls: readonly string[]) {
for (const url of urls) {
this.rows.set(url, { events: 0, subs: 0, eoses: 0, connected: false });
}
}
private row(url: string) {
let found = this.rows.get(url);
if (!found) {
found = { events: 0, subs: 0, eoses: 0, connected: false };
this.rows.set(url, found);
}
return found;
}
connected(url: string): void {
this.row(url).connected = true;
}
subscribed(url: string): void {
this.row(url).subs++;
}
event(url: string): void {
this.row(url).events++;
}
eose(url: string): void {
this.row(url).eoses++;
}
/** One row per configured relay, in configuration order. */
list(): RelayHealth[] {
return [...this.rows.entries()].map(([url, row]) => ({
url,
connected: row.connected,
events: row.events,
// A cycle asks a relay many questions. It only counts as having reached the end
// of the stream if it reached the end of every one of them.
eose: row.subs > 0 && row.eoses === row.subs,
}));
}
}
/**
* Long enough that the relay's own EOSE timer never wins.
*
* `Subscription` fires `oneose` both when an EOSE frame arrives and when its internal
* timer expires, so the two are indistinguishable from the callback. Pushing that timer
* out of reach and running the deadline here instead is what makes `eose` in the report
* mean "the relay said it was done" rather than "something gave up".
*/
const NEVER_EOSE_MS = 24 * 60 * 60 * 1000;
let pool: SimplePool | null = null; let pool: SimplePool | null = null;
/** /**
@@ -66,12 +177,100 @@ export function closePool(): void {
pool = null; pool = null;
} }
/**
* Ask every configured relay one filter, and record what each of them did.
*
* This replaces `pool.querySync(config.relays, …)`, which answers the same question and
* throws the attribution away: it merges five relays into one deduplicated array, so a
* relay list where four relays are empty and one carries everything is indistinguishable
* from five healthy ones. That indistinguishability is the bug this whole file is being
* changed for — a year of ~31-event backfills, `ok=true` every time.
*
* What it keeps from `querySync`, deliberately:
*
* - One subscription per relay over the pool's existing sockets, so this is the same
* number of connections as before.
* - A single `alreadyHaveEvent` shared across all five. `AbstractRelay._onmessage`
* consults it *before* `JSON.parse` and signature verification, so an event five
* relays all carry is still verified once. Per-relay `querySync` calls would have
* verified it five times, which at 500 events a page is real CPU.
* - `receivedEvent`, which fires on the way past that check, so the per-relay count is
* what the relay sent rather than what was new because of it.
*
* What it changes: the deadline is run here rather than by each `Subscription`'s own
* EOSE timer, so `oneose` firing means an EOSE frame actually arrived. See NEVER_EOSE_MS.
*
* Never throws. A relay that will not connect is a fact to record, not a reason to
* abandon the four that did.
*/
async function queryRelays(filter: Filter, tally: RelayTally | null): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
const known = new Set<string>();
const alreadyHaveEvent = (id: string): boolean => {
if (known.has(id)) return true;
known.add(id);
return false;
};
await Promise.all(
config.relays.map(async (url) => {
let relay;
try {
// The same connection budget subscribeMap would have used for this maxWait.
relay = await getPool().ensureRelay(url, {
connectionTimeout: Math.max(MAX_WAIT_MS * 0.8, MAX_WAIT_MS - 1000),
});
} catch {
// Left as connected=false in the tally, which is the whole report this needs.
return;
}
tally?.connected(url);
await new Promise<void>((resolve) => {
let settled = false;
let deadline: ReturnType<typeof setTimeout> | undefined;
const finish = (): void => {
if (settled) return;
settled = true;
if (deadline !== undefined) clearTimeout(deadline);
resolve();
};
try {
const sub = relay.subscribe([filter], {
onevent: (event) => events.push(event),
alreadyHaveEvent,
receivedEvent: () => tally?.event(url),
oneose: () => {
tally?.eose(url);
sub.close('closed automatically on eose');
},
onclose: finish,
eoseTimeout: NEVER_EOSE_MS,
});
tally?.subscribed(url);
deadline = setTimeout(() => sub.close('closed on maxWait'), MAX_WAIT_MS);
} catch {
// The socket went away between ensureRelay and the REQ.
finish();
}
});
}),
);
return events;
}
/** /**
* Query one kind, paging backwards with `until` until a page yields nothing new. * Query one kind, paging backwards with `until` until a page yields nothing new.
* Relays cap `limit` independently, so paging is the only way a fresh database * Relays cap `limit` independently, so paging is the only way a fresh database
* converges to the complete history. * converges to the complete history.
*/ */
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> { async function fetchKind(
kind: number,
since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>(); const seen = new Map<string, NostrEvent>();
let until: number | undefined; let until: number | undefined;
@@ -82,7 +281,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
let batch: NostrEvent[]; let batch: NostrEvent[];
try { try {
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS }); batch = await queryRelays(filter, tally);
} catch (err) { } catch (err) {
log.warn('relay query failed', { log.warn('relay query failed', {
kind, kind,
@@ -123,6 +322,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
async function fetchReviewsForMint( async function fetchReviewsForMint(
target: ReviewTarget, target: ReviewTarget,
since: number | null, since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const filters: Filter[] = []; const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT }; const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
@@ -178,11 +378,7 @@ async function fetchReviewsForMint(
} }
const batches = await Promise.all( const batches = await Promise.all(
filters.map((filter) => filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
); );
return batches.flat(); return batches.flat();
@@ -596,6 +792,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const since = lastRun === null ? null : Math.max(0, lastRun - 3600); const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
const newMints = new Set<string>(); const newMints = new Set<string>();
const tally = new RelayTally(config.relays);
let newReviews = 0; let newReviews = 0;
let events = 0; let events = 0;
let ok = true; let ok = true;
@@ -610,7 +807,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const announcementsByType = new Map<string, NostrEvent[]>(); const announcementsByType = new Map<string, NostrEvent[]>();
await Promise.all( await Promise.all(
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => { Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
announcementsByType.set(type, await fetchKind(kind, since)); announcementsByType.set(type, await fetchKind(kind, since, tally));
}), }),
); );
@@ -625,10 +822,10 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
// Announcements alone miss mints that only ever appear in a review's `u` tag, // Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too. // so reviews feed discovery too.
const reviews = await fetchKind(KIND_REVIEW, since); const reviews = await fetchKind(KIND_REVIEW, since, tally);
// The recent window catches anything a relay dropped from the unbounded query. // The recent window catches anything a relay dropped from the unbounded query.
const recent = const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : []; since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
const byId = new Map<string, NostrEvent>(); const byId = new Map<string, NostrEvent>();
for (const e of [...reviews, ...recent]) byId.set(e.id, e); for (const e of [...reviews, ...recent]) byId.set(e.id, e);
@@ -689,7 +886,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
while (cursor < targets.length) { while (cursor < targets.length) {
const target = targets[cursor++]; const target = targets[cursor++];
if (!target) continue; if (!target) continue;
const found = await fetchReviewsForMint(target, since); const found = await fetchReviewsForMint(target, since, tally);
if (found.length > 0) { if (found.length > 0) {
events += found.length; events += found.length;
newReviews += await ingestReviews(found, index); newReviews += await ingestReviews(found, index);
@@ -707,14 +904,79 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) }); log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
} }
const mode = backfill ? 'backfill' : 'incremental';
const relays = tally.list();
/*
* Name the relay, every time, one line each.
*
* A relay that would not connect is worth saying on any cycle: the address is wrong,
* or it is down, and neither gets better by itself. A relay that connected and sent
* nothing is only news on a backfill — an incremental cycle asking for the last hour
* of four kinds legitimately comes back empty, and warning about that hourly would
* train everyone to skip the line that eventually matters.
*/
for (const relay of relays) {
if (!relay.connected) {
log.warn('discovery relay unreachable', { relay: relay.url, mode });
continue;
}
if (backfill && relay.events === 0) {
log.warn('discovery relay returned no events', { relay: relay.url, mode });
} else if (!relay.eose) {
log.warn('discovery relay never reached EOSE', {
relay: relay.url,
mode,
events: relay.events,
});
}
}
/*
* The floor, and the flag the health endpoint reads.
*
* Only a backfill is measured against it. A backfill asks for the entire history of
* every announcement kind and every review, so on a working relay set it is thousands
* of events; an incremental cycle asks for one interval and is supposed to be small.
*
* The flag is sticky across incremental cycles: an hourly cycle that finds four
* events must not clear a starvation a backfill diagnosed, so a non-backfill carries
* forward whatever the last backfill concluded.
*/
let starved: boolean;
if (backfill) {
starved = events < config.backfillMinEvents;
if (starved) {
log.error('discovery starvation suspected', {
events,
floor: config.backfillMinEvents,
relays: relays.length,
silent: relays.filter((r) => r.events === 0).length,
unreachable: relays.filter((r) => !r.connected).length,
hint: 'check RELAYS: a relay list missing the announcement archive looks exactly like this',
});
}
} else {
// No backfill has ever run in this deployment: nothing has confirmed the relay set
// reads anything, and saying "fine" would be the whole original bug.
starved = (await lastDiscoveryReport())?.starved ?? true;
}
const report: DiscoveryReport = { at: now, mode, events, ok, relays, starved };
// A report that cannot be written is not worth failing a cycle over; the cycle's own
// work is already committed, and health degrades on the stale timestamp instead.
await setState(REPORT_KEY, JSON.stringify(report)).catch(() => undefined);
log.info('discovery cycle', { log.info('discovery cycle', {
mode: backfill ? 'backfill' : 'incremental', mode,
events, events,
new_mints: newMints.size, new_mints: newMints.size,
new_reviews: newReviews, new_reviews: newReviews,
ok, ok,
starved,
relays: relays.map((r) => `${r.url}=${r.connected ? r.events : 'down'}`).join(' '),
ms: Date.now() - started, ms: Date.now() - started,
}); });
return { events, newMints: [...newMints], newReviews, ok }; return { events, newMints: [...newMints], newReviews, ok, relays, starved };
} }
+31 -3
View File
@@ -16,6 +16,7 @@ import {
} from '@cashumints/shared'; } from '@cashumints/shared';
import { config, startedAt } from './config.ts'; import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts'; import { getDb, getStateNumber, getState } from './db.ts';
import { lastDiscoveryReport } from './discovery.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts'; import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/** /**
@@ -379,28 +380,55 @@ export function resetStatsCache(): void {
statsCache = null; statsCache = null;
} }
/** Health bypasses the stats cache: it is the endpoint you page on. */ /**
* Health bypasses the stats cache: it is the endpoint you page on.
*
* Three things can degrade it, and they are three different failures:
*
* probeStale nothing has checked a mint in three intervals
* !discoveryOk the last discovery cycle threw
* report.starved the last backfill read less than BACKFILL_MIN_EVENTS
*
* The third is the one added after the postmortem, and it is the only one that would
* have caught a year of the index sitting at eight mints: the cycles were completing,
* `ok` was true, the probes were fresh, and the relay list simply did not contain the
* relay holding the archive. "Ran without throwing" is not the same claim as "read
* anything", and only the second one is worth a green light.
*
* A deployment with an empty database reports degraded until its first backfill lands,
* because until then nothing has confirmed the relay set reads anything at all. That is
* intended: it holds `cashumints-web.service` at its health gate rather than letting it
* publish a site built from nothing.
*/
export async function getHealth(): Promise<Health> { export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const db = await getDb(); const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([ const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([
getStateNumber('last_probe_at'), getStateNumber('last_probe_at'),
getStateNumber('last_discovery_at'), getStateNumber('last_discovery_at'),
getState('last_discovery_ok'), getState('last_discovery_ok'),
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'), db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
lastDiscoveryReport(),
]); ]);
const discoveryOk = discoveryOkRaw !== '0'; const discoveryOk = discoveryOkRaw !== '0';
const staleAfter = config.probeIntervalMin * 60 * 3; const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter; const probeStale = lastProbe === null || now - lastProbe > staleAfter;
// No report at all is starvation by default: see the note above.
const starved = report?.starved ?? true;
return { return {
status: probeStale || !discoveryOk ? 'degraded' : 'ok', status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok',
uptime_s: now - startedAt, uptime_s: now - startedAt,
last_probe_at: lastProbe, last_probe_at: lastProbe,
last_discovery_at: lastDiscovery, last_discovery_at: lastDiscovery,
mints_tracked: tracked?.n ?? 0, mints_tracked: tracked?.n ?? 0,
updated_at: now, updated_at: now,
discovery_relays: report?.relays ?? [],
last_discovery_events: report?.events ?? null,
last_discovery_mode: report?.mode ?? null,
discovery_starved: starved,
backfill_min_events: config.backfillMinEvents,
}; };
} }
+43
View File
@@ -236,6 +236,29 @@ export interface Stats {
lnurl_reviews: number; lnurl_reviews: number;
} }
/**
* How one configured relay answered during the last discovery cycle.
*
* The three facts are deliberately separate, because the failure this exists to catch
* had all three looking different from each other: the relays in `RELAYS` connected
* fine, reached EOSE fine, and simply did not carry the archive, so `events` was the
* only field that would have said anything. A relay that is down and a relay that is
* up and empty are different problems with different fixes.
*/
export interface RelayHealth {
url: string;
/** A socket was opened to it. false means the address is wrong or the relay is down. */
connected: boolean;
/**
* Events it sent, counted before cross-relay deduplication — so this is what *this*
* relay contributed, not what was new because of it. Zero on a connected relay is
* the interesting number.
*/
events: number;
/** Every query it was asked ended in a real EOSE rather than in a timeout. */
eose: boolean;
}
/** `GET /api/health`. */ /** `GET /api/health`. */
export interface Health { export interface Health {
status: 'ok' | 'degraded'; status: 'ok' | 'degraded';
@@ -244,6 +267,26 @@ export interface Health {
last_discovery_at: number | null; last_discovery_at: number | null;
mints_tracked: number; mints_tracked: number;
updated_at: number; updated_at: number;
/**
* Per-relay outcome of the last discovery cycle. Empty until one has run — including
* on a fresh database, which is why a brand new deployment reports degraded until its
* first backfill finishes.
*/
discovery_relays: RelayHealth[];
/** Unique events the last discovery cycle received. null before the first one. */
last_discovery_events: number | null;
/** Which kind of cycle those numbers describe. */
last_discovery_mode: 'backfill' | 'incremental' | null;
/**
* The last backfill came back under `backfill_min_events`, or none has run yet.
*
* This is the flag that would have caught a year of ~31-event backfills against a
* relay list missing the archive. It forces `status` to degraded, and /api/health to
* 503, which is what the build gate and the site's own health checks read.
*/
discovery_starved: boolean;
/** `BACKFILL_MIN_EVENTS`, echoed so a reader of this payload can see the threshold. */
backfill_min_events: number;
} }
/** /**