Skip to content

Commit 246570b

Browse files
ralyodioclaude
andauthored
Serve the status page from Redis, and keep the alarm honest (#149)
/api/crawlstats answered in 118 seconds. It fans out eleven reads and returns when the slowest does; timed against production: categoryStats 30,005ms (timed out) jobBacklogs 11,255ms failingFeeds(20) 5,172ms crawlStats 4,975ms the other seven under 600ms each No rewrite fixes `categoryStats`. It is a group-by over 476,715 rows, and on the same connection a bare `count(*)` of that table is 6.9s while `select category, count(*) … group by category` does not finish inside the client's 30s deadline. Dropping its conditional aggregates -- the fix that worked for `crawlStats` in PR #96 -- changes nothing, because the cost is visiting every row for a column no index covers. The per-process cache that was already here could not save it either, for a reason worth naming: `categoryStats` does not run slowly, it *fails*, and a cache that only stores successes stores nothing. Every request paid the full timeout, for ever. Redis plus serve-stale-on-failure inverts that -- one success, any time, serves every later reader -- and it survives the deploys that emptied the old cache. It also adds no writes to Turso, whose write path is the binding constraint on everything else here. The part that needed care is that a status page must never report a stalled crawler as healthy. The rule that keeps it honest is to cache facts and derive anything measured against now: `idleMinutes` is `now - lastSuccessAt` computed inside the query, so caching the object freezes it, and a dead crawler would go on reporting the same cheerful number. `liveStats` caches the timestamp and redoes the subtraction, so the number climbs while the crawler is down. `queueHistory` does the same with its hour labels, caching the sparse rows and filling the window on the way out. A cache that can hang is not a cache, so the lookups are bounded too, and every failure path -- no REDIS_URL, a refused connection, a socket that accepts commands and never answers -- falls through to the read it replaced. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9eddf78 commit 246570b

6 files changed

Lines changed: 720 additions & 161 deletions

File tree

‎apps/web/src/app/api/crawlstats/route.js‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { q, discovery, alerts } from '@rssamplifier/db';
1+
import { q, discovery } from '@rssamplifier/db';
22

33
import { db } from '../../../lib/db.js';
4-
import { categoryStats, indexingHistory, jobBacklogs } from '../../../lib/crawlstats.js';
4+
import {
5+
categoryStats,
6+
indexingHistory,
7+
jobBacklogs,
8+
liveStats,
9+
failingFeeds,
10+
alertingAccounts,
11+
} from '../../../lib/crawlstats.js';
512
import { toLine } from '../../../lib/crawlLog.js';
613
import { jobRows } from '../../../lib/jobs.js';
714

@@ -15,10 +22,15 @@ export const dynamic = 'force-dynamic';
1522
* backlog that should drain within a tick or two, `stale` is active feeds the
1623
* crawler has not successfully read in a day.
1724
*
18-
* Deliberately uncached — a status endpoint that answers from a cache reports
19-
* that everything was fine a minute ago, which is the one thing it must not do.
20-
* The two additions that are cached, briefly, are the ones nothing would alert
21-
* on: the hourly history and the category breakdown. See lib/crawlstats.js.
25+
* Every read here is cached, and the distinction that keeps that honest is
26+
* between a fact and a derivation. A count is a fact and may be ten seconds
27+
* old; `idleMinutes` is `now - lastSuccessAt`, so caching it would freeze the
28+
* one number a monitor alerts on. `liveStats` caches the timestamp and redoes
29+
* the subtraction — see lib/crawlstats.js.
30+
*
31+
* Before this, the endpoint answered in 118 seconds: `categoryStats` no longer
32+
* completes inside the client's 30s deadline, and since a cache that only
33+
* stores successes never stored it, every request paid the full timeout.
2234
*/
2335
export async function GET() {
2436
const client = db();
@@ -36,8 +48,8 @@ export async function GET() {
3648
alertAccounts,
3749
operationalErrors,
3850
] = await Promise.all([
39-
q.crawlStats(client),
40-
q.failingFeeds(client, 20),
51+
liveStats(),
52+
failingFeeds(),
4153
q.recentlyCrawled(client, 20),
4254
discovery.countQueuedCandidates(client),
4355
discovery.countQueuedKeywords(client),
@@ -57,7 +69,7 @@ export async function GET() {
5769
q.logActivity(client, 1),
5870
// See the page: this only tells a sender with nobody to serve from one that
5971
// has stopped, which the log alone cannot say.
60-
alerts.alertingAccountCount(client),
72+
alertingAccounts(),
6173
q.crawlOperationalErrors(client, { limit: 20, hours: 24 }),
6274
]);
6375

‎apps/web/src/app/crawlstats/page.jsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { q, discovery, alerts } from '@rssamplifier/db';
1+
import { q, discovery } from '@rssamplifier/db';
22

33
import { db } from '../../lib/db.js';
44
import {
55
categoryStats,
66
indexingHistory,
77
jobBacklogs,
88
queueHistory,
9+
liveStats,
10+
failingFeeds,
11+
alertingAccounts,
912
GROWTH_DAYS,
1013
} from '../../lib/crawlstats.js';
1114
import { describe, toLine } from '../../lib/crawlLog.js';
@@ -62,8 +65,8 @@ export default async function CrawlStatsPage() {
6265
operationalErrors,
6366
queues,
6467
] = await Promise.all([
65-
q.crawlStats(client),
66-
q.failingFeeds(client, 50),
68+
liveStats(),
69+
failingFeeds(50),
6770
q.recentlyCrawled(client, 15),
6871
discovery.countQueuedCandidates(client),
6972
discovery.countQueuedKeywords(client),
@@ -90,7 +93,7 @@ export default async function CrawlStatsPage() {
9093
// Only to tell a sender with nothing to do from one that has stopped: the
9194
// alert pass writes no log line at all when nobody is subscribed, and a
9295
// silent job is otherwise indistinguishable from a dead one.
93-
alerts.alertingAccountCount(client),
96+
alertingAccounts(),
9497
// Kept separately from the rolling live-log window. At crawler throughput,
9598
// 400 successful feed lines can evict an operational failure in minutes.
9699
q.crawlOperationalErrors(client, { limit: 20, hours: 24 }),

0 commit comments

Comments
 (0)