Skip to content

Commit 4fefe49

Browse files
ralyodioclaude
andcommitted
pit: claim a pasted list in batches, not one round trip at a time
A 313-ending paste stopped after 54 with "259 not attempted". That was not an API limit and not the database being slow — it was this code asking six or seven questions per ending: insert, log, read the row back, then read-check- write for the price. At ~60ms a trip that is ~370ms an ending, and the 20s budget bought 54 of them. Batched instead. One `INSERT OR IGNORE` batch per chunk, and `rowsAffected` per statement says which landed — that is exactly the claimed/taken split without a SELECT each. The ones that collided get a single IN query to find out whether they are already yours or someone else's. Price and alias fold into one UPDATE rather than the read-check-write setTldPrice does, because ownership was just established by the insert above and re-reading the row asks a question already answered. Validation happens in memory first. A reserved or malformed ending never needed a round trip to be refused. 300 endings now land in ~150ms, so the time budget is gone — there is nothing left for it to ration, and `remaining` is always empty. The chunk size that replaced it bounds request size, not throughput. `INSERT OR IGNORE` cannot fail on a name someone already holds, so a batch in write mode never rolls back on a collision — which is the property that makes one transaction safe for a list where some entries are expected to lose. Tests replaced accordingly: the budget ones tested behaviour that no longer exists, and in their place is the paste that used to fail — 300 endings, none left over, inside what used to be the whole budget. Chunk boundaries are tested for double-counting, and claimed / already-yours / someone-else's are still told apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fd300d0 commit 4fefe49

3 files changed

Lines changed: 141 additions & 94 deletions

File tree

apps/pwa/src/lib/moshpit-name.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ export const MAX_BULK_TLDS = 1000;
136136
*/
137137
export const BULK_TIME_BUDGET_MS = 20_000;
138138

139+
/**
140+
* How many endings go into one round trip.
141+
*
142+
* Claiming used to cost six or seven trips per ending — insert, log, read
143+
* back, then read-check-write for the price — which is why a 300-ending paste
144+
* spent its whole time budget on 54 of them. Batched, the cost is a handful of
145+
* trips for the entire paste, so the chunk exists to bound request size rather
146+
* than to ration anything.
147+
*/
148+
export const BULK_CHUNK = 100;
149+
139150
/** 1000 -> "1k". A ceiling is a rough promise and should read like one. */
140151
export function shortCount(n) {
141152
return n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n);

apps/pwa/src/moshpit.mjs

Lines changed: 89 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@
1010
// without a mirror being able to forge or seize a name, because the order is
1111
// checkable rather than trusted.
1212

13-
import { get, all, run } from "./db.mjs";
14-
import { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs";
13+
import { db, get, all, run } from "./db.mjs";
14+
import {
15+
BULK_CHUNK,
16+
BULK_TIME_BUDGET_MS,
17+
MAX_BULK_TLDS,
18+
MAX_CHILD_PRICE_USD,
19+
normalizeLabel,
20+
normalizeTld,
21+
parseMoshpitName,
22+
parseTldList,
23+
tldRejection,
24+
} from "./lib/moshpit-name.mjs";
1525

1626
export {
17-
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, CHILD_PRICE_USD, ENDING_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
27+
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_CHUNK, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, CHILD_PRICE_USD, ENDING_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
1828
parseTldList, tldRejection, normalizeMode, resolutionPreference,
1929
} from "./lib/moshpit-name.mjs";
2030

@@ -578,80 +588,100 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId
578588
*/
579589
export async function registerTlds({
580590
input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null,
581-
budgetMs = BULK_TIME_BUDGET_MS, now = Date.now,
591+
chunkSize = BULK_CHUNK,
582592
}) {
583593
const { entries, skipped } = parseTldList(input, limit);
584-
const deadline = now() + budgetMs;
585-
const remaining = [];
586594

587595
const claimed = [];
588596
const mine = [];
589597
const taken = [];
590598
const rejected = [];
591599
const settingsFailed = [];
592600

593-
for (const [index, entry] of entries.entries()) {
594-
// Checked before the write, not after: stopping with a claim half-made is
595-
// the one outcome worse than stopping early.
596-
if (index > 0 && now() >= deadline) {
597-
remaining.push(...entries.slice(index).map((e) => e.tld));
598-
break;
601+
// Validation first, in memory. A reserved or malformed ending never needs a
602+
// round trip to be refused, and filtering here keeps the batches below to
603+
// things that can actually land.
604+
const candidates = [];
605+
for (const entry of entries) {
606+
const tld = normalizeTld(entry.tld);
607+
if (!tld) { rejected.push({ tld: entry.tld, error: "not a valid TLD — letters, digits and dashes only, no dots" }); continue; }
608+
const why = tldRejection(tld);
609+
if (why) { rejected.push({ tld, error: why }); continue; }
610+
candidates.push({ ...entry, tld });
611+
}
612+
613+
const at = Date.now();
614+
615+
for (const chunk of chunksOf(candidates, chunkSize)) {
616+
// One round trip for the whole chunk. `INSERT OR IGNORE` cannot fail on a
617+
// name someone already holds, so the batch never rolls back on a
618+
// collision, and rowsAffected says which of them landed — which is exactly
619+
// the claimed/taken split, without a SELECT per ending.
620+
const inserted = await db.batch(
621+
chunk.map((c) => ({
622+
sql: `INSERT OR IGNORE INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at) VALUES (?,?,?,?,?)`,
623+
args: [c.tld, userId, ownerEmail, null, at],
624+
})),
625+
"write",
626+
);
627+
628+
const landed = [];
629+
const collided = [];
630+
chunk.forEach((c, i) => (inserted[i].rowsAffected ? landed : collided).push(c));
631+
632+
// Who holds the ones that collided — one query for all of them, so that
633+
// "already yours" stays distinguishable from "someone else has it" without
634+
// costing a lookup each.
635+
if (collided.length) {
636+
const owners = await all(
637+
`SELECT tld, user_id FROM moshpit_tlds WHERE tld IN (${collided.map(() => "?").join(",")})`,
638+
collided.map((c) => c.tld),
639+
);
640+
const byTld = new Map(owners.map((row) => [row.tld, row.user_id]));
641+
for (const c of collided) (byTld.get(c.tld) === userId ? mine : taken).push(c.tld);
599642
}
600-
const tld = entry.tld;
601-
const result = await registerTld({ tld, userId, ownerEmail });
602-
if (result.ok) {
603-
claimed.push(result.tld.tld);
604-
// Settings are applied per ending, and a failure here is reported rather
605-
// than thrown: the ending is already claimed and keeping it is the point.
606-
// Losing a whole batch because one alias target was wrong would be worse
607-
// than landing forty endings with no price on them.
608-
// A value written on the line wins over the form's, in either direction:
609-
// the form is the default for the whole paste, the line is what this one
610-
// ending is actually worth.
611-
const failure = await applyTldDefaults({
612-
tld: result.tld.tld,
613-
userId,
614-
priceUsd: entry.priceUsd ?? priceUsd,
615-
aliasOf: entry.aliasOf ?? aliasOf,
643+
644+
if (!landed.length) continue;
645+
claimed.push(...landed.map((c) => c.tld));
646+
647+
// Price and alias fold into a single UPDATE per ending rather than the
648+
// read-check-write setTldPrice does: ownership was just established by the
649+
// INSERT above, so re-reading the row to confirm it would be asking a
650+
// question already answered.
651+
const updates = [];
652+
for (const c of landed) {
653+
const price = normalizePrice(c.priceUsd ?? priceUsd);
654+
const alias = normalizeTld(c.aliasOf ?? aliasOf);
655+
if (price === undefined) { settingsFailed.push({ tld: c.tld, error: "price must be a positive number" }); continue; }
656+
const target = alias && alias !== c.tld ? alias : null;
657+
if (price === null && !target) continue;
658+
updates.push({
659+
sql: `UPDATE moshpit_tlds SET price_usd = COALESCE(?, price_usd), alias_of = COALESCE(?, alias_of) WHERE tld = ? AND user_id = ?`,
660+
args: [price, target, c.tld, userId],
616661
});
617-
if (failure) settingsFailed.push({ tld: result.tld.tld, error: failure });
618-
continue;
619662
}
620663

621-
if (result.taken) {
622-
// Re-pasting a list you already claimed should read as "already yours",
623-
// not as a collision with a stranger.
624-
const owner = await getTld(tld);
625-
(owner?.user_id === userId ? mine : taken).push(tld);
626-
continue;
627-
}
628-
rejected.push({ tld, error: result.error });
664+
const logs = landed.map((c) => ({
665+
sql: `INSERT INTO moshpit_tld_log (tld, user_id, action, at) VALUES (?,?,?,?)`,
666+
args: [c.tld, userId, "register", at],
667+
}));
668+
669+
if (updates.length || logs.length) await db.batch([...updates, ...logs], "write");
629670
}
630671

631-
return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining, attempted: entries.length };
672+
return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining: [], attempted: entries.length };
632673
}
633674

634-
/**
635-
* Apply the whole-list settings to one freshly claimed ending.
636-
*
637-
* Returns an error string, or null when there was nothing to do or it worked.
638-
* Aliasing an ending to itself is silently skipped rather than reported: it is
639-
* what you get by pasting a list that happens to contain the alias target, and
640-
* refusing the whole entry over it would be pedantic.
641-
*/
642-
async function applyTldDefaults({ tld, userId, priceUsd, aliasOf }) {
643-
const wantsPrice = priceUsd !== null && priceUsd !== undefined && String(priceUsd).trim() !== "";
644-
if (wantsPrice) {
645-
const priced = await setTldPrice({ tld, userId, priceUsd });
646-
if (!priced.ok) return priced.error;
647-
}
675+
function* chunksOf(list, size) {
676+
for (let i = 0; i < list.length; i += size) yield list.slice(i, i + size);
677+
}
648678

649-
const target = normalizeTld(aliasOf);
650-
if (target && target !== tld) {
651-
const aliased = await setAlias({ from: tld, to: target, userId });
652-
if (!aliased.ok) return aliased.error;
653-
}
654-
return null;
679+
/** null = leave alone, undefined = refuse, a number = set it. */
680+
function normalizePrice(value) {
681+
if (value === null || value === undefined || String(value).trim() === "") return null;
682+
const price = Number(value);
683+
if (!Number.isFinite(price) || price <= 0 || price > 1_000_000) return undefined;
684+
return Math.round(price * 100) / 100;
655685
}
656686

657687
/** One line fit for a flash message: what landed, what did not, and why. */

apps/pwa/test/moshpit-bulk-claim.test.mjs

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -350,62 +350,68 @@ test("per-line settings beat the form", { skip: installed ? false : "pwa depende
350350
});
351351
});
352352

353-
test("a paste bigger than one request can finish", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
353+
test("a big paste lands in one go", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
354354
const { migrate } = await import("../src/migrate.mjs");
355355
await migrate();
356356
const { run } = await import("../src/db.mjs");
357357
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [ALICE, "alice@example.com", Date.now()]);
358+
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [BOB, "bob@example.com", Date.now()]);
358359
const m = await import("../src/moshpit.mjs");
359-
const { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS } = await import("../src/lib/moshpit-name.mjs");
360-
const uniq = () => `t${randomBytes(4).toString("hex")}`;
360+
const { MAX_BULK_TLDS, BULK_CHUNK } = await import("../src/lib/moshpit-name.mjs");
361+
const uniq = () => `z${randomBytes(5).toString("hex")}`;
361362

362-
await t.test("the ceiling is 1000", () => {
363+
await t.test("the ceiling is 1000 and a chunk bounds request size, not throughput", () => {
363364
assert.equal(MAX_BULK_TLDS, 1000);
364-
assert.ok(BULK_TIME_BUDGET_MS > 0);
365-
});
366-
367-
await t.test("running out of time names what is left instead of dropping it", async () => {
368-
const names = Array.from({ length: 5 }, uniq);
369-
// A clock that jumps past the budget after the first claim.
370-
let calls = 0;
371-
const result = await m.registerTlds({
372-
input: names.join("\n"), userId: ALICE, budgetMs: 1000,
373-
now: () => (calls++ === 0 ? 0 : 99_999),
374-
});
375-
376-
assert.equal(result.claimed.length, 1, "the first one lands");
377-
assert.deepEqual(result.remaining, names.slice(1), "the rest are named, not lost");
378-
assert.match(m.summarizeBulkClaim(result), /4 not attempted paste them again/);
379-
});
380-
381-
await t.test("the budget is never checked before the first claim", async () => {
382-
// An already-expired clock must still do one, or a slow database means a
383-
// paste that claims nothing at all and looks broken.
384-
const one = uniq();
385-
const result = await m.registerTlds({
386-
input: one, userId: ALICE, budgetMs: 0, now: () => 99_999,
387-
});
388-
assert.deepEqual(result.claimed, [one]);
389-
assert.deepEqual(result.remaining, []);
365+
assert.ok(BULK_CHUNK > 0 && BULK_CHUNK <= MAX_BULK_TLDS);
390366
});
391367

392-
await t.test("a paste that fits reports nothing left over", async () => {
393-
const names = Array.from({ length: 3 }, uniq);
368+
await t.test("300 endings all get claimed — none left over", async () => {
369+
// The paste that used to stop at 54 of 313 when every ending cost six
370+
// round trips. Nothing is deferred now, so `remaining` stays empty.
371+
const names = Array.from({ length: 300 }, uniq);
372+
const started = Date.now();
394373
const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE });
395374

396-
assert.equal(result.claimed.length, 3);
375+
assert.equal(result.claimed.length, 300, "every one landed");
397376
assert.deepEqual(result.remaining, []);
398-
assert.doesNotMatch(m.summarizeBulkClaim(result), /not attempted/);
377+
assert.ok(Date.now() - started < 20_000, "and inside what used to be the whole budget");
378+
});
379+
380+
await t.test("more than one chunk still reports every ending exactly once", async () => {
381+
const names = Array.from({ length: BULK_CHUNK + 7 }, uniq);
382+
const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE, chunkSize: 10 });
383+
384+
assert.equal(result.claimed.length, names.length);
385+
assert.equal(new Set(result.claimed).size, names.length, "no ending counted twice across chunks");
386+
});
387+
388+
await t.test("claimed, already-yours and someone-else's are still told apart", async () => {
389+
const fresh = uniq(), yours = uniq(), theirs = uniq();
390+
await m.registerTld({ tld: yours, userId: ALICE });
391+
await m.registerTld({ tld: theirs, userId: BOB });
392+
393+
const result = await m.registerTlds({ input: [fresh, yours, theirs].join("\n"), userId: ALICE });
394+
assert.deepEqual(result.claimed, [fresh]);
395+
assert.deepEqual(result.mine, [yours]);
396+
assert.deepEqual(result.taken, [theirs]);
397+
assert.equal((await m.getTld(theirs)).user_id, BOB, "not stolen by the batch");
399398
});
400399

401-
await t.test("over the ceiling still counts as left over, not dropped", async () => {
400+
await t.test("over the ceiling is still reported, not dropped", async () => {
402401
const names = Array.from({ length: 4 }, uniq);
403402
const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE, limit: 2 });
404403

405404
assert.equal(result.claimed.length, 2);
406405
assert.equal(result.skipped, 2);
407406
assert.match(m.summarizeBulkClaim(result), /2 not attempted paste them again/);
408407
});
408+
409+
await t.test("a reserved ending never costs a round trip", async () => {
410+
const good = uniq();
411+
const result = await m.registerTlds({ input: `bank\na\n${good}`, userId: ALICE });
412+
assert.deepEqual(result.claimed, [good]);
413+
assert.equal(result.rejected.length, 2, "filtered in memory, before any batch");
414+
});
409415
});
410416

411417
test("a ceiling reads like a rough promise", async () => {

0 commit comments

Comments
 (0)