Skip to content

Commit d9d6c49

Browse files
committed
make it not download 27mb on page load
1 parent 39d512a commit d9d6c49

13 files changed

Lines changed: 355 additions & 104 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The backend API is rooted at `/api`. `/live` reports process liveness and
4545

4646
| Method | Route | Purpose |
4747
| --- | --- | --- |
48-
| `GET` | `/api/config`, `/api/status`, `/api/network` | Capabilities, freshness, protocol state, and public cases |
48+
| `GET` | `/api/config`, `/api/status`, `/api/network` | Capabilities; freshness and protocol state; public cases (ETag-revalidatable) |
4949
| `GET` | `/api/sequencers/:address`, `/api/cases/:id` | Public sequencer and exact-case views |
5050
| `POST` | `/api/watches` | Create a private watch and return its management token once |
5151
| `GET/PATCH/DELETE` | `/api/watches/:id` | Bearer-authenticated watch management |

collector/src/case-api-server.mjs

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import http from 'node:http';
2-
import { randomUUID } from 'node:crypto';
2+
import { createHash, randomUUID } from 'node:crypto';
3+
import { gzipSync } from 'node:zlib';
34

45
import {
56
createOpaqueToken,
@@ -89,7 +90,7 @@ export class CaseApiServer {
8990
} else {
9091
this.logger?.debug?.('API request rejected', details);
9192
}
92-
this.sendError(response, error);
93+
this.sendError(request, response, error);
9394
});
9495
});
9596
}
@@ -125,14 +126,14 @@ export class CaseApiServer {
125126
}
126127

127128
if (request.method === 'GET' && url.pathname === '/live') {
128-
return this.send(response, 200, { status: 'live' });
129+
return this.send(request, response, 200, { status: 'live' });
129130
}
130131
if (request.method === 'GET' && url.pathname === '/health') {
131132
const status = this.status();
132-
return this.send(response, status.status === 'healthy' ? 200 : 503, status);
133+
return this.send(request, response, status.status === 'healthy' ? 200 : 503, status);
133134
}
134135
if (request.method === 'GET' && url.pathname === `${API_PREFIX}/config`) {
135-
return this.send(response, 200, {
136+
return this.send(request, response, 200, {
136137
network: this.network,
137138
maxSequencers: this.maxSequencers,
138139
notifications: {
@@ -147,29 +148,35 @@ export class CaseApiServer {
147148
});
148149
}
149150
if (request.method === 'GET' && url.pathname === `${API_PREFIX}/status`) {
150-
return this.send(response, 200, this.status());
151+
return this.send(request, response, 200, this.status());
151152
}
152153
if (request.method === 'GET' && url.pathname === `${API_PREFIX}/network`) {
153-
return this.send(response, 200, {
154-
...this.repository.getNetworkSummary(this.network),
155-
sources: this.status().sources,
156-
});
154+
return this.send(
155+
request,
156+
response,
157+
200,
158+
this.repository.getNetworkSummary(this.network),
159+
{ revalidate: true },
160+
);
157161
}
158162

159163
const sequencerMatch = /^\/api\/sequencers\/(0x[0-9a-fA-F]{40})$/.exec(
160164
url.pathname,
161165
);
162166
if (request.method === 'GET' && sequencerMatch) {
163-
return this.send(response, 200, this.repository.getSequencerRecord(
164-
sequencerMatch[1],
165-
this.network,
166-
));
167+
return this.send(
168+
request,
169+
response,
170+
200,
171+
this.repository.getSequencerRecord(sequencerMatch[1], this.network),
172+
{ revalidate: true },
173+
);
167174
}
168175
const caseMatch = /^\/api\/cases\/([^/]+)$/.exec(url.pathname);
169176
if (request.method === 'GET' && caseMatch) {
170177
const item = this.repository.getCase(decodeURIComponent(caseMatch[1]));
171178
if (!item) throw new InputError('case_not_found', 'Slashing case not found', 404);
172-
return this.send(response, 200, item);
179+
return this.send(request, response, 200, item, { revalidate: true });
173180
}
174181

175182
if (request.method === 'POST' && url.pathname === `${API_PREFIX}/watches`) {
@@ -186,7 +193,7 @@ export class CaseApiServer {
186193
addresses,
187194
now: this.now(),
188195
});
189-
return this.send(response, 201, {
196+
return this.send(request, response, 201, {
190197
watch: publicWatch(watch, this.repository),
191198
managementToken,
192199
});
@@ -196,7 +203,7 @@ export class CaseApiServer {
196203
if (watchMatch) {
197204
const watch = this.authorizeWatch(request, watchMatch[1]);
198205
if (request.method === 'GET') {
199-
return this.send(response, 200, publicWatch(watch, this.repository));
206+
return this.send(request, response, 200, publicWatch(watch, this.repository));
200207
}
201208
this.limitMutation(request);
202209
if (request.method === 'PATCH') {
@@ -208,7 +215,7 @@ export class CaseApiServer {
208215
addresses,
209216
now: this.now(),
210217
});
211-
return this.send(response, 200, publicWatch(updated, this.repository));
218+
return this.send(request, response, 200, publicWatch(updated, this.repository));
212219
}
213220
if (request.method === 'DELETE') {
214221
this.repository.deleteWatch(watch.id);
@@ -242,7 +249,7 @@ export class CaseApiServer {
242249
configJson: JSON.stringify(subscription),
243250
now: this.now(),
244251
});
245-
return this.send(response, 200, publicWatch(updated, this.repository));
252+
return this.send(request, response, 200, publicWatch(updated, this.repository));
246253
}
247254
if (request.method === 'DELETE') {
248255
this.repository.deleteEndpoint(watch.id, 'web_push');
@@ -274,7 +281,7 @@ export class CaseApiServer {
274281
expiresAt,
275282
now: this.now(),
276283
});
277-
return this.send(response, 201, {
284+
return this.send(request, response, 201, {
278285
url: `https://t.me/${this.telegramBotUsername}?start=${token}`,
279286
expiresAt: new Date(expiresAt).toISOString(),
280287
});
@@ -295,7 +302,7 @@ export class CaseApiServer {
295302
409,
296303
);
297304
}
298-
return this.send(response, 202, { queued });
305+
return this.send(request, response, 202, { queued });
299306
}
300307

301308
throw new InputError('not_found', 'Route not found', 404);
@@ -421,25 +428,46 @@ export class CaseApiServer {
421428
response.setHeader('access-control-allow-origin', this.corsOrigin);
422429
response.setHeader('access-control-allow-methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS');
423430
response.setHeader('access-control-allow-headers', 'authorization,content-type');
424-
response.setHeader('vary', 'Origin');
431+
response.setHeader('vary', 'Origin, Accept-Encoding');
425432
}
426433

427-
send(response, status, value) {
434+
// Public data endpoints send `cache-control: no-cache` plus a weak ETag so
435+
// browsers revalidate every poll and receive a bodyless 304 while nothing
436+
// changed. Private and mutating responses stay `no-store`. Bodies are
437+
// gzipped at the origin: the network path to the CDN edge is metered.
438+
send(request, response, status, value, { revalidate = false } = {}) {
428439
const body = JSON.stringify(value);
429-
response.writeHead(status, {
440+
const headers = {
430441
'content-type': 'application/json; charset=utf-8',
431-
'cache-control': 'no-store',
432-
'content-length': Buffer.byteLength(body),
433-
});
434-
response.end(body);
442+
'cache-control': revalidate ? 'no-cache' : 'no-store',
443+
};
444+
if (revalidate && status === 200) {
445+
const etag = `W/"${createHash('sha256').update(body).digest('base64url')}"`;
446+
headers.etag = etag;
447+
const ifNoneMatch = request.headers['if-none-match'];
448+
if (typeof ifNoneMatch === 'string' && ifNoneMatch.includes(etag)) {
449+
response.writeHead(304, headers);
450+
response.end();
451+
return;
452+
}
453+
}
454+
const acceptsGzip = /(?:^|[,\s])gzip(?:$|[;,])/
455+
.test(String(request.headers['accept-encoding'] ?? ''));
456+
const payload = acceptsGzip && Buffer.byteLength(body) > 1_024
457+
? gzipSync(body)
458+
: body;
459+
if (payload !== body) headers['content-encoding'] = 'gzip';
460+
headers['content-length'] = Buffer.byteLength(payload);
461+
response.writeHead(status, headers);
462+
response.end(payload);
435463
}
436464

437-
sendError(response, error) {
465+
sendError(request, response, error) {
438466
const safeStatus = errorStatus(error);
439467
if (error?.retryAfterMs) {
440468
response.setHeader('retry-after', String(Math.ceil(error.retryAfterMs / 1_000)));
441469
}
442-
this.send(response, safeStatus, {
470+
this.send(request, response, safeStatus, {
443471
error: {
444472
code: error?.code ?? 'internal_error',
445473
message: safeStatus === 500

collector/src/case-repository.mjs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,50 @@ export class CaseRepository {
6363
this.db.exec('PRAGMA foreign_keys = ON');
6464
this.db.exec('PRAGMA busy_timeout = 5000');
6565
this.initializeSchema();
66+
this.pruneResult = this.pruneSupersededRoundObservations();
6667
} catch (error) {
6768
this.db.close();
6869
throw error;
6970
}
7071
}
7172

73+
// Databases written before superseded l1_round rows were deleted on
74+
// reconcile still carry every historical vote-state progression. Remove the
75+
// rows whose canonical replacement for the same round exists in the same
76+
// case, and rebuild the affected case projections. Idempotent: after the
77+
// first run there is nothing left to match.
78+
pruneSupersededRoundObservations() {
79+
return this.transaction(() => {
80+
const rows = this.db.prepare(`
81+
SELECT id, observation_json AS observationJson
82+
FROM observations AS superseded
83+
WHERE source = 'ethereum_l1' AND kind = 'l1_round' AND canonical = 0
84+
AND COALESCE(
85+
json_extract(observation_json, '$.data.historicalExecution'), 0
86+
) != 1
87+
AND EXISTS (
88+
SELECT 1 FROM observations AS replacement
89+
WHERE replacement.canonical = 1
90+
AND replacement.kind = 'l1_round'
91+
AND replacement.network = superseded.network
92+
AND replacement.lineage_id = superseded.lineage_id
93+
AND replacement.sequencer = superseded.sequencer
94+
AND replacement.target_epoch = superseded.target_epoch
95+
AND replacement.round = superseded.round
96+
)
97+
`).all();
98+
const affected = new Set();
99+
for (const row of rows) {
100+
this.db.prepare('DELETE FROM observations WHERE id = ?').run(row.id);
101+
affected.add(caseIdFor(parseJson(row.observationJson, null)));
102+
}
103+
const projection = affected.size > 0
104+
? this.reprojectCases([...affected], { notify: false })
105+
: { changed: 0 };
106+
return { pruned: rows.length, casesChanged: projection.changed };
107+
});
108+
}
109+
72110
initializeSchema() {
73111
const applicationId = Number(
74112
this.db.prepare('PRAGMA application_id').get().application_id,
@@ -493,6 +531,11 @@ export class CaseRepository {
493531
SELECT case_json AS caseJson FROM cases WHERE id = ?
494532
`).get(caseId);
495533
const previous = previousRow ? parseJson(previousRow.caseJson, null) : null;
534+
// Deleting a superseded round row can remove a case's earliest
535+
// observation; the moment the case was first seen must survive that.
536+
if (previous && previous.firstObservedAt < current.firstObservedAt) {
537+
current.firstObservedAt = previous.firstObservedAt;
538+
}
496539
const currentJson = JSON.stringify(current);
497540
if (previousRow?.caseJson === currentJson) continue;
498541
this.db.prepare(`
@@ -590,10 +633,12 @@ export class CaseRepository {
590633
};
591634
}
592635

636+
// The network feed deliberately omits the protocol snapshot and source
637+
// health: both change every poll and live in /api/status, while this
638+
// response only changes when a case does — which keeps its ETag stable.
593639
getNetworkSummary(selectedNetwork) {
594640
const cases = this.listCases({ network: selectedNetwork });
595641
return {
596-
protocol: this.getProtocolSnapshot(),
597642
summary: summarizeNetwork(cases),
598643
cases,
599644
};
@@ -989,28 +1034,45 @@ export class CaseRepository {
9891034
});
9901035
}
9911036

1037+
// A fresh snapshot supersedes the stored vote-state row of every covered
1038+
// round it re-reports with different data. Superseded rows are deleted:
1039+
// they are poll-cadence progressions of the same round, not independent
1040+
// chain evidence, and retaining them made cases grow without bound. A row
1041+
// whose target vanished from a covered round is different — that evidence
1042+
// was removed on L1, so it is kept as a non-canonical correction.
1043+
// Historical execution rows come from Slashed logs and are only ever
1044+
// invalidated by the reorg path, never replaced by a newer snapshot.
9921045
reconcileL1RoundObservations(snapshot, current) {
9931046
const seen = new Set(current.map((item) => item.id));
1047+
const replaced = new Set(current.map((item) =>
1048+
`${item.lineageId}:${item.round}:${item.sequencer}:${item.targetEpoch}`));
9941049
const coverage = new Set((snapshot.stacks ?? []).flatMap((stack) =>
9951050
(stack.rounds ?? []).map((round) =>
9961051
`${address(stack.proposerAddress, 'SlashingProposer')}:${unsignedString(round.round, 'round')}`)));
9971052
if (coverage.size === 0) return [];
9981053
const rows = this.db.prepare(`
999-
SELECT id, lineage_id AS lineageId, round, observation_json AS observationJson
1054+
SELECT id, lineage_id AS lineageId, round, sequencer,
1055+
target_epoch AS targetEpoch, observation_json AS observationJson
10001056
FROM observations
10011057
WHERE source = 'ethereum_l1' AND kind = 'l1_round' AND canonical = 1
10021058
`).all();
10031059
const affected = new Set();
10041060
for (const row of rows) {
10051061
if (!coverage.has(`${row.lineageId}:${row.round}`) || seen.has(row.id)) continue;
10061062
const observation = parseJson(row.observationJson, null);
1007-
observation.provenance.canonical = false;
1008-
observation.provenance.invalidatedAt = toIso(
1009-
snapshot.blockTimestamp ?? snapshot.observedAt,
1010-
);
1011-
this.db.prepare(`
1012-
UPDATE observations SET canonical = 0, observation_json = ? WHERE id = ?
1013-
`).run(JSON.stringify(observation), row.id);
1063+
if (observation?.data?.historicalExecution) continue;
1064+
const key = `${row.lineageId}:${row.round}:${row.sequencer}:${row.targetEpoch}`;
1065+
if (replaced.has(key)) {
1066+
this.db.prepare('DELETE FROM observations WHERE id = ?').run(row.id);
1067+
} else {
1068+
observation.provenance.canonical = false;
1069+
observation.provenance.invalidatedAt = toIso(
1070+
snapshot.blockTimestamp ?? snapshot.observedAt,
1071+
);
1072+
this.db.prepare(`
1073+
UPDATE observations SET canonical = 0, observation_json = ? WHERE id = ?
1074+
`).run(JSON.stringify(observation), row.id);
1075+
}
10141076
affected.add(caseIdFor(observation));
10151077
}
10161078
return [...affected];

collector/src/main.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ async function main() {
2020
const config = loadConfig();
2121
const logger = new Logger(config.logLevel);
2222
const repository = new CaseRepository(config.databasePath);
23+
if (repository.pruneResult?.pruned > 0) {
24+
logger.info('Pruned superseded L1 round observations', repository.pruneResult);
25+
}
2326
try {
2427
repository.bindRuntimeIdentity({
2528
network: config.network,

0 commit comments

Comments
 (0)