Dev #8
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user