Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions apps/pwa/src/lib/moshpit-name.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,32 @@ export function resolutionPreference({ registered, mode }) {
}

/**
* How many endings one paste may claim at a time.
* The most endings one paste may claim.
*
* A cap rather than no cap because this runs one INSERT per ending against a
* remote database, and a pasted spreadsheet column is exactly the shape of
* input that turns into ten thousand of them by accident.
* A ceiling rather than no ceiling because this runs one INSERT per ending
* against a remote database, and a pasted spreadsheet column is exactly the
* shape of input that turns into ten thousand of them by accident.
*
* It is not the thing that usually stops a paste, though — BULK_TIME_BUDGET_MS
* is. A count cannot know how slow the database is today, and the failure it
* guards against is a request that dies halfway with no report of what landed.
*/
export const MAX_BULK_TLDS = 200;
export const MAX_BULK_TLDS = 1000;

/**
* How long claiming may run before it stops and reports.
*
* Stopping on the clock rather than on a count adapts to the database: a fast
* one gets through hundreds, a slow one stops early, and neither ends as a
* timed-out request whose result nobody ever sees. Whatever is left is named
* so it can be pasted again.
*/
export const BULK_TIME_BUDGET_MS = 20_000;

/** 1000 -> "1k". A ceiling is a rough promise and should read like one. */
export function shortCount(n) {
return n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n);
}

/**
* The most a child name may cost per year.
Expand Down
25 changes: 19 additions & 6 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
// checkable rather than trusted.

import { get, all, run } from "./db.mjs";
import { MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs";
import { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs";

export {
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName,
parseTldList, tldRejection, normalizeMode, resolutionPreference,
} from "./lib/moshpit-name.mjs";

Expand Down Expand Up @@ -578,16 +578,25 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId
*/
export async function registerTlds({
input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null,
budgetMs = BULK_TIME_BUDGET_MS, now = Date.now,
}) {
const { entries, skipped } = parseTldList(input, limit);
const deadline = now() + budgetMs;
const remaining = [];

const claimed = [];
const mine = [];
const taken = [];
const rejected = [];
const settingsFailed = [];

for (const entry of entries) {
for (const [index, entry] of entries.entries()) {
// Checked before the write, not after: stopping with a claim half-made is
// the one outcome worse than stopping early.
if (index > 0 && now() >= deadline) {
remaining.push(...entries.slice(index).map((e) => e.tld));
break;
}
const tld = entry.tld;
const result = await registerTld({ tld, userId, ownerEmail });
if (result.ok) {
Expand Down Expand Up @@ -619,7 +628,7 @@ export async function registerTlds({
rejected.push({ tld, error: result.error });
}

return { claimed, mine, taken, rejected, settingsFailed, skipped, attempted: entries.length };
return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining, attempted: entries.length };
}

/**
Expand Down Expand Up @@ -654,7 +663,8 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
// batch report looks like, and the commonest path through this page is one
// ending typed into one box.
const onlyClaimed = result.claimed.length === 1 && !result.mine.length && !result.taken.length
&& !result.rejected.length && !result.settingsFailed?.length && !result.skipped;
&& !result.rejected.length && !result.settingsFailed?.length && !result.skipped
&& !result.remaining?.length;
if (onlyClaimed) return `.${result.claimed[0]} is yours.`;

const parts = [];
Expand All @@ -671,7 +681,10 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) {
const first = result.settingsFailed[0];
parts.push(`${result.settingsFailed.length} claimed but not configured (.${first.tld} — ${first.error})`);
}
if (result.skipped) parts.push(`${result.skipped} past the ${limit} limit, not attempted`);
// The leftovers are the actionable part, so they say what to do rather than
// just how many there were.
const left = (result.remaining?.length || 0) + (result.skipped || 0);
if (left) parts.push(`${left} not attempted — paste them again to carry on`);

return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line.";
}
3 changes: 2 additions & 1 deletion apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
setExempt,
setNameTarget,
setTldPrice,
shortCount,
summarizeBulkClaim,
tldRejection,
} from "../moshpit.mjs";
Expand Down Expand Up @@ -427,7 +428,7 @@ oranges, pears, plums
# anything on a line beats the defaults below · # comments ignored"></textarea>
${claimDefaults(req)}
<p class="mono faint" style="font-size:.7rem;margin:8px 0 10px">
Up to ${MAX_BULK_TLDS} at a time. Ones already taken are reported, not fatal —
Up to ${shortCount(MAX_BULK_TLDS)} at a time. Ones already taken are reported, not fatal —
the rest still land. The price and target below apply to every ending that
lands, unless a line says otherwise.
</p>
Expand Down
69 changes: 69 additions & 0 deletions apps/pwa/test/moshpit-bulk-claim.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,72 @@ test("per-line settings beat the form", { skip: installed ? false : "pwa depende
assert.equal(row.alias_of, hub);
});
});

test("a paste bigger than one request can finish", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
const { migrate } = await import("../src/migrate.mjs");
await migrate();
const { run } = await import("../src/db.mjs");
await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [ALICE, "alice@example.com", Date.now()]);
const m = await import("../src/moshpit.mjs");
const { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS } = await import("../src/lib/moshpit-name.mjs");
const uniq = () => `t${randomBytes(4).toString("hex")}`;

await t.test("the ceiling is 1000", () => {
assert.equal(MAX_BULK_TLDS, 1000);
assert.ok(BULK_TIME_BUDGET_MS > 0);
});

await t.test("running out of time names what is left instead of dropping it", async () => {
const names = Array.from({ length: 5 }, uniq);
// A clock that jumps past the budget after the first claim.
let calls = 0;
const result = await m.registerTlds({
input: names.join("\n"), userId: ALICE, budgetMs: 1000,
now: () => (calls++ === 0 ? 0 : 99_999),
});

assert.equal(result.claimed.length, 1, "the first one lands");
assert.deepEqual(result.remaining, names.slice(1), "the rest are named, not lost");
assert.match(m.summarizeBulkClaim(result), /4 not attempted — paste them again/);
});

await t.test("the budget is never checked before the first claim", async () => {
// An already-expired clock must still do one, or a slow database means a
// paste that claims nothing at all and looks broken.
const one = uniq();
const result = await m.registerTlds({
input: one, userId: ALICE, budgetMs: 0, now: () => 99_999,
});
assert.deepEqual(result.claimed, [one]);
assert.deepEqual(result.remaining, []);
});

await t.test("a paste that fits reports nothing left over", async () => {
const names = Array.from({ length: 3 }, uniq);
const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE });

assert.equal(result.claimed.length, 3);
assert.deepEqual(result.remaining, []);
assert.doesNotMatch(m.summarizeBulkClaim(result), /not attempted/);
});

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

assert.equal(result.claimed.length, 2);
assert.equal(result.skipped, 2);
assert.match(m.summarizeBulkClaim(result), /2 not attempted — paste them again/);
});
});

test("a ceiling reads like a rough promise", async () => {
const { shortCount, MAX_BULK_TLDS } = await import("../src/lib/moshpit-name.mjs");
assert.equal(shortCount(1000), "1k");
assert.equal(shortCount(2000), "2k");
assert.equal(shortCount(MAX_BULK_TLDS), "1k");
// Anything not a round thousand stays exact — "1.2k" would be a lie about
// where the ceiling actually is.
assert.equal(shortCount(200), "200");
assert.equal(shortCount(1500), "1500");
});
Loading