Skip to content

Commit e72a697

Browse files
ralyodioclaude
andauthored
Take subscription lists as they come: 200k lines, and .txt everywhere (#134)
Three things stopped a hundred-and-ten-thousand-line list of feeds from being an ordinary thing to submit. The cap that was read as one was never a cap on a submission at all. RAW_INPUT_LINE_LIMIT bounds the copy of an upload kept to name it on its status page — the ceiling on a submission is MAX_UPLOAD_FEEDS and it is in the tens of millions — but "only the first 50,000 lines of the upload are kept" reads as a limit however it is meant, and fifty thousand is below the size of the lists people actually bring. Raised to 200,000, so a real export is recorded whole and the note is one almost nobody sees. A plain list of feed URLs uploaded as a file was silently thrown away by every client that is not our uploader. The field is named `opml` and the endpoint took the name for the truth, so a .txt of one feed per line was parsed as XML, matched no outlines, and came back `no-feeds-in-opml` having imported nothing — with JavaScript off, from curl, or from an agent. It is sniffed now, by the same call the browser has always made. sniffKind reads the file before the name while it is here: `<opml` in the head means OPML whatever the file is called, which is the case its own comment promised and the old order lost. And a large submission arriving in one piece was crawled inside the request that carried it — importFeeds reads every feed URL and slug in the directory, then a round trip per five hundred rows, which for a hundred and eight thousand entries is over two hundred of them under a five-minute ceiling. Past five thousand entries it is staged instead and released to the poller, exactly as the batched uploader stages it: one bulk insert per two thousand, nothing that scales with the directory. Measured on that file: 1.0s to hand over, all 108,000 recorded. Below the threshold nothing changes, and one URL still redirects to the blog it just added. Two things the staged path exposed once everything took it: - The progress bar counted only queued feeds, so the whole handover — now most of a large import's life — showed a full bar and the word "finished" under a heading saying "Import in progress". Staged entries count towards the total in both the page and the stream, and the tick interval is sized from them too, which had put the biggest uploads on the fastest poll. - The uploader switched its progress panel on the sniffed kind, so a .txt chosen in the file form drew its bar under the paste box. It switches on which form started it now. Verified end to end against a 108,000-line, 5.4 MB file: staged in 1.0s over multipart, 2.3s in the browser (54 batches), 50,000 feeds drained in 6.1s, unique slugs throughout, and OPML — including OPML living in a .txt — still read as OPML with its titles and entities intact. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d926f64 commit e72a697

10 files changed

Lines changed: 293 additions & 36 deletions

File tree

‎apps/web/src/app/api/submissions/[id]/stream/route.js‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@ export async function GET(req, { params }) {
4242
// uploader made a file that size importable. Such a queue also takes days to
4343
// drain, so polling it every second buys no visible smoothness at all: the
4444
// bar advances by a fraction of a percent an hour either way.
45-
const tick = pollInterval(Number(submission.queued_count ?? 0));
45+
//
46+
// Sized from the staged total as well as the queued one. A large import
47+
// spends its first stretch entirely staged — `queued_count` is still zero —
48+
// so reading that alone put the biggest uploads on the fastest tick, which is
49+
// precisely backwards and is the case this whole calculation exists for.
50+
const tick = pollInterval(
51+
Math.max(Number(submission.queued_count ?? 0), Number(submission.entries_total ?? 0)),
52+
);
4653

4754
return stream(async (first) => {
4855
const [progressRow, events] = await Promise.all([
@@ -51,15 +58,20 @@ export async function GET(req, { params }) {
5158
]);
5259

5360
const frames = [];
54-
const total = progressRow.queued;
61+
62+
// Staged entries count towards both numbers. They are feeds this import
63+
// will crawl that simply have no row yet, so leaving them out reported a
64+
// handover as a finished crawl — a full bar, the word "finished", and the
65+
// page above it still saying how many were waiting to be added.
66+
const total = progressRow.queued + progressRow.pending;
5567
const settled = progressRow.crawled + progressRow.failed;
56-
const done = progressRow.waiting === 0;
68+
const done = progressRow.waiting === 0 && progressRow.pending === 0;
5769

5870
const progress = {
5971
total,
6072
settled,
61-
// A submission with nothing queued finished the moment it was made, and
62-
// an empty bar reading 0% would be a lie about that.
73+
// A submission with nothing queued and nothing staged finished the moment
74+
// it was made, and an empty bar reading 0% would be a lie about that.
6375
percent: total === 0 ? 100 : Math.floor((settled / total) * 100),
6476
crawled: progressRow.crawled,
6577
failed: progressRow.failed,

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

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { submitCatalogue, submitOpml, hashIp } from '@rssamplifier/ingest';
1+
import { submitCatalogue, hashIp } from '@rssamplifier/ingest';
2+
import { parseOpml } from '@rssamplifier/feed';
23
import { q, newId } from '@rssamplifier/db';
34

45
import { db, siteUrl } from '../../../lib/db.js';
6+
import { sniffKind } from '../../../lib/opml-scan.js';
57
import { clampRawInput } from '../../../lib/submitted.js';
68

79
export const dynamic = 'force-dynamic';
@@ -27,6 +29,47 @@ const RATE_LIMIT = 20;
2729
*/
2830
const INLINE_UPLOAD_LIMIT = Number(process.env['SUBMIT_INLINE_BYTES'] ?? 10_000_000) || 10_000_000;
2931

32+
/**
33+
* How much of a file is read before deciding what kind of file it is.
34+
*
35+
* The signature that settles it — the `<opml>` root, or the first `<outline>` —
36+
* is in the first line or two of any real export, so this only has to be past
37+
* the XML declaration and any comment somebody put above it.
38+
*/
39+
const SNIFF_CHARS = 4096;
40+
41+
/**
42+
* Entries above which a submission is handed over rather than imported here.
43+
*
44+
* Importing means `importFeeds`: one read of every feed URL and slug in the
45+
* directory, then a round trip per five hundred rows. That is seconds for a
46+
* paste and minutes for a subscription export — a hundred and ten thousand
47+
* entries is over two hundred sequential round trips inside a single request
48+
* with a five-minute ceiling on it, which is a coin toss at best and loses the
49+
* whole upload when it comes up wrong.
50+
*
51+
* Past this the entries are staged instead, exactly as the batched uploader
52+
* stages them: one bulk insert per couple of thousand, no lookups, no slugs,
53+
* nothing that scales with the size of the directory, and the poller drains it
54+
* afterwards. The submitter gets the status page, which is where an import of
55+
* that size was always going to end up.
56+
*
57+
* Below it nothing changes, and that matters more than it sounds: a small
58+
* submission is resolved over the network while the submitter waits, and one
59+
* URL still redirects to the blog it just added.
60+
*/
61+
const STAGE_ABOVE = 5_000;
62+
63+
/**
64+
* Entries written per staging statement.
65+
*
66+
* `stageImportEntries` binds four parameters per row, and SQLite's ceiling on
67+
* bound parameters is what decides this rather than the size of the payload —
68+
* two thousand rows is eight thousand of them, comfortably under it, and the
69+
* same slice the uploader and the drainer both work in.
70+
*/
71+
const STAGE_CHUNK = 2_000;
72+
3073
/**
3174
* Split a paste into candidate URLs.
3275
*
@@ -107,9 +150,24 @@ export async function POST(req) {
107150

108151
if (file && typeof file !== 'string' && file.size > 0) {
109152
if (file.size > INLINE_UPLOAD_LIMIT) return tooLarge(req, file.size);
110-
kind = 'opml';
111153
raw = await file.text();
112-
opml = raw;
154+
155+
// What the file is, rather than what the field is called. The input is
156+
// named `opml` because that is what it was built for, but a plain list
157+
// of feed URLs is the other half of what this page accepts and arrives
158+
// through the same field — and reading one as the other finds no
159+
// outlines at all, so a perfectly good subscription list was answered
160+
// with `no-feeds-in-opml` and nothing was imported. The browser has
161+
// sniffed its own uploads since the batched uploader landed; this is
162+
// the same call, for the clients that do not run it.
163+
if (sniffKind({ name: file.name }, raw.slice(0, SNIFF_CHARS)) === 'opml') {
164+
kind = 'opml';
165+
opml = raw;
166+
} else {
167+
const urls = splitUrls(raw);
168+
kind = urls.length > 1 ? 'list' : 'url';
169+
entries = urls.map((url) => ({ url }));
170+
}
113171
} else {
114172
raw = String(form.get('input') ?? '');
115173
const urls = splitUrls(raw);
@@ -135,15 +193,83 @@ export async function POST(req) {
135193

136194
const browser = (req.headers.get('accept') ?? '').includes('text/html');
137195

196+
// An OPML document becomes its entries here rather than inside `submitOpml`,
197+
// because how many of them there are is what decides what happens next.
198+
const catalogue = opml === null ? entries : parseOpml(opml);
199+
200+
// A file that parsed as OPML and holds no outlines is the one failure worth
201+
// naming: it is almost always the wrong file, and "nothing happened" is a
202+
// much worse answer than saying so.
203+
if (opml !== null && catalogue.length === 0) {
204+
const rejected = [{ url: '', error: 'no-feeds-in-opml' }];
205+
206+
await q.completeSubmission(client, submissionId, {
207+
accepted_count: 0,
208+
rejected_count: 1,
209+
queued_count: 0,
210+
notify_email: null,
211+
errors: rejected,
212+
});
213+
214+
if (browser) {
215+
return new Response(null, { status: 303, headers: { location: '/submit?error=1' } });
216+
}
217+
218+
return json({
219+
ok: false,
220+
accepted: [],
221+
rejected,
222+
queued: 0,
223+
total: 0,
224+
submissionId,
225+
statusUrl: `${siteUrl()}/submissions/${submissionId}`,
226+
});
227+
}
228+
229+
// Too big to crawl inside this request, so it is handed over instead: staged
230+
// in bulk and released to the poller, which is what the batched uploader has
231+
// done since it landed. This is the same handover for everyone who is not
232+
// running it — a client with JavaScript off, curl, an agent posting JSON.
233+
if (catalogue.length > STAGE_ABOVE) {
234+
const staged = await stageAll(client, submissionId, catalogue);
235+
236+
await q.markImportReady(client, submissionId, {
237+
entries_total: staged,
238+
rejected_count: catalogue.length - staged,
239+
// Nobody is owed a notification about an upload that staged nothing, for
240+
// the reason `completeSubmission` documents: an address on a submission
241+
// with no pending work reads as a finished import and is mailed at once.
242+
notify_email: staged > 0 ? email : null,
243+
});
244+
245+
if (browser) {
246+
return new Response(null, {
247+
status: 303,
248+
headers: { location: `/submissions/${submissionId}` },
249+
});
250+
}
251+
252+
return json({
253+
ok: staged > 0,
254+
accepted: [],
255+
rejected: [],
256+
queued: 0,
257+
// Staged, not queued: these are recorded and waiting for the poller to
258+
// turn them into feeds, and the status page reports them as such.
259+
pending: staged,
260+
total: catalogue.length,
261+
submissionId,
262+
statusUrl: `${siteUrl()}/submissions/${submissionId}`,
263+
});
264+
}
265+
138266
let resolveQueued;
139267
const queuedCount = new Promise((resolve) => {
140268
resolveQueued = resolve;
141269
});
142270

143271
const opts = { submissionId, onQueued: (n) => resolveQueued(n) };
144-
const work = (
145-
opml === null ? submitCatalogue(client, entries, opts) : submitOpml(client, opml, opts)
146-
).then(async (result) => {
272+
const work = submitCatalogue(client, catalogue, opts).then(async (result) => {
147273
await q.completeSubmission(client, submissionId, {
148274
accepted_count: result.accepted.length,
149275
rejected_count: result.rejected.length,
@@ -191,6 +317,38 @@ export async function POST(req) {
191317
});
192318
}
193319

320+
/**
321+
* Write a catalogue into staging, one statement at a time.
322+
*
323+
* Entries with nothing url-shaped in them are dropped rather than stored, the
324+
* same way `/api/submit/stage` drops them: the drainer would only throw them
325+
* out later, and they would sit in the table until it got round to it. What is
326+
* dropped is still counted, by the caller, so the totals add up to what arrived.
327+
*
328+
* @param {import('@libsql/client').Client} client
329+
* @param {string} submissionId
330+
* @param {Array<{ url?: string, title?: string, siteUrl?: string|null }>} catalogue
331+
* @returns {Promise<number>} rows written
332+
*/
333+
async function stageAll(client, submissionId, catalogue) {
334+
let staged = 0;
335+
336+
for (let at = 0; at < catalogue.length; at += STAGE_CHUNK) {
337+
const slice = catalogue
338+
.slice(at, at + STAGE_CHUNK)
339+
.map((entry) => ({
340+
url: String(entry?.url ?? '').trim(),
341+
title: entry?.title ?? null,
342+
siteUrl: entry?.siteUrl ?? null,
343+
}))
344+
.filter((entry) => entry.url);
345+
346+
if (slice.length > 0) staged += await q.stageImportEntries(client, submissionId, slice);
347+
}
348+
349+
return staged;
350+
}
351+
194352
/**
195353
* Refuse an upload this endpoint cannot read in one piece.
196354
*

‎apps/web/src/app/submissions/[id]/page.jsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@ export default async function SubmissionPage({ params }) {
5757
const staged = Number(progress.pending ?? 0);
5858
const done = progress.waiting === 0 && staged === 0;
5959
const settled = progress.crawled + progress.failed;
60-
const percent = progress.queued === 0 ? 100 : Math.floor((settled / progress.queued) * 100);
60+
61+
// Everything this import will crawl, staged entries included. Counting only
62+
// what is already queued made the bar read 100% and "finished" for the whole
63+
// handover — which is now most of a large import's life, and is exactly the
64+
// stretch where nothing has been crawled at all. A full bar over a heading
65+
// that says "Import in progress" is worse than no bar.
66+
const total = progress.queued + staged;
67+
const percent = total === 0 ? 100 : Math.floor((settled / total) * 100);
6168

6269
return (
6370
<>
@@ -76,7 +83,7 @@ export default async function SubmissionPage({ params }) {
7683
lines={lines}
7784
unit="feeds"
7885
verb="Crawl"
79-
initial={{ total: progress.queued, settled, percent, done }}
86+
initial={{ total, settled, percent, done }}
8087
/>
8188

8289
<dl className="stats">

‎apps/web/src/app/submit/Uploader.jsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export default function Uploader({ shared = '' }) {
6767
/**
6868
* @type {[null | {
6969
* phase: 'reading'|'saving'|'done'|'empty'|'error',
70+
* via: 'paste'|'file',
7071
* name: string,
7172
* percent: number,
7273
* read: number,
@@ -89,13 +90,19 @@ export default function Uploader({ shared = '' }) {
8990
/**
9091
* Drive one import from beginning to end.
9192
*
92-
* @param {{ kind: 'opml'|'list', source: File|string, size: number, name: string, email: string }} job
93+
* @param {{ via: 'paste'|'file', kind: 'opml'|'list', source: File|string, size: number, name: string, email: string }} job
9394
*/
9495
async function start(job) {
9596
/** @type {Array<{ at: number, text: string }>} */
9697
const lines = [];
9798
let state = {
9899
phase: /** @type {'reading'} */ ('reading'),
100+
// Which form started this, kept apart from `kind` on purpose. `kind` is
101+
// what the file turned out to hold and can change under us the moment it
102+
// has been sniffed — a subscription list saved as .txt is uploaded
103+
// through the file form and scanned as a list — and switching the panel
104+
// on it put the progress bar for a file upload underneath the paste box.
105+
via: job.via,
99106
kind: job.kind,
100107
name: job.name,
101108
percent: 0,
@@ -228,7 +235,14 @@ export default function Uploader({ shared = '' }) {
228235
if (value.length < PASTE_OBVIOUS && scanUrls(value).length <= PASTE_DIRECT) return;
229236

230237
event.preventDefault();
231-
start({ kind: 'list', source: value, size: value.length, name: 'your list', email: '' });
238+
start({
239+
via: 'paste',
240+
kind: 'list',
241+
source: value,
242+
size: value.length,
243+
name: 'your list',
244+
email: '',
245+
});
232246
}
233247

234248
/**
@@ -243,6 +257,10 @@ export default function Uploader({ shared = '' }) {
243257

244258
event.preventDefault();
245259
start({
260+
via: 'file',
261+
// Provisional: `sniffKind` decides for real once the head of the file has
262+
// been read, because the field takes an OPML export and a plain list of
263+
// URLs alike and the name on it is not always the truth.
246264
kind: 'opml',
247265
source: file,
248266
size: file.size,
@@ -272,7 +290,7 @@ export default function Uploader({ shared = '' }) {
272290
</div>
273291
</form>
274292

275-
{run?.kind === 'list' && <Progress run={run} onReset={() => setRun(null)} />}
293+
{run?.via === 'paste' && <Progress run={run} onReset={() => setRun(null)} />}
276294

277295
<form
278296
className="submit-box"
@@ -281,17 +299,18 @@ export default function Uploader({ shared = '' }) {
281299
encType="multipart/form-data"
282300
onSubmit={onUpload}
283301
>
284-
<p className="eyebrow">Or upload an OPML file</p>
302+
<p className="eyebrow">Or upload a file — OPML, or one feed per line</p>
285303
<input
286304
ref={fileRef}
287305
type="file"
288306
name="opml"
289307
accept=".opml,.xml,.txt,text/xml,text/plain"
290-
aria-label="OPML file"
308+
aria-label="OPML or list of feeds"
291309
disabled={busy}
292310
/>
293311
<p className="hint">
294-
Any size. The file is read here in your browser and the feeds are sent a few thousand at a
312+
An OPML export from any reader, or a plain text file with one feed URL on each line. Any
313+
size: the file is read here in your browser and the feeds are sent a few thousand at a
295314
time, so a 100 MB subscription list imports without the upload ever timing out — you will
296315
see it go, and get a status page to watch the crawl.
297316
</p>
@@ -310,7 +329,7 @@ export default function Uploader({ shared = '' }) {
310329
</div>
311330
</form>
312331

313-
{run?.kind === 'opml' && <Progress run={run} onReset={() => setRun(null)} />}
332+
{run?.via === 'file' && <Progress run={run} onReset={() => setRun(null)} />}
314333
</>
315334
);
316335
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Uploader from './Uploader.jsx';
66
export const metadata = {
77
title: 'Submit a blog',
88
description:
9-
'Add a URL, a playlist, a list of URLs or an OPML file to the directory. No account needed.',
9+
'Add a URL, a playlist, a list of URLs, an OPML export or a text file of feed URLs to the directory. No account needed.',
1010
};
1111

1212
/**
@@ -33,8 +33,9 @@ export default async function SubmitPage({ searchParams }) {
3333
<p className="notice">
3434
That file is too big to send in one piece. The uploader below reads it in your browser and
3535
sends the feeds a few thousand at a time instead, which has no size limit worth the name —
36-
but it needs JavaScript. With JavaScript off, split the file, or post the feeds to{' '}
37-
<code>/api/submit</code> yourself.
36+
but it needs JavaScript. With JavaScript off, split the file into pieces under 10 MB —
37+
each one may hold as many feeds as it likes — or post them to <code>/api/submit</code>{' '}
38+
yourself.
3839
</p>
3940
) : (
4041
params.error && (

0 commit comments

Comments
 (0)