fix(admin): make bulk feed delete fast + add purge endpoint

This commit is contained in:
Young Lee
2026-02-06 01:36:05 -08:00
parent 1c40740686
commit aaafe5eab2
+97 -29
View File
@@ -32,6 +32,15 @@ export default app;
const ADMIN_COOKIE_NAME = "admin_auth"; const ADMIN_COOKIE_NAME = "admin_auth";
const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
// Hono throws when ExecutionContext isn't present (ex: Node unit tests).
try {
c.executionCtx.waitUntil(promise);
} catch {
// ignore
}
}
function parseAllowedSenders(rawAllowedSenders: string): string[] { function parseAllowedSenders(rawAllowedSenders: string): string[] {
return rawAllowedSenders return rawAllowedSenders
.split(/[\n,]+/) .split(/[\n,]+/)
@@ -1482,32 +1491,48 @@ async function deleteKeysWithConcurrency(
return { ok, failed }; return { ok, failed };
} }
async function deleteFeedAndEmails( async function deleteFeedFast(
emailStorage: KVNamespace, emailStorage: KVNamespace,
feedId: string, feedId: string,
options: { skipListUpdate?: boolean } = {},
): Promise<boolean> { ): Promise<boolean> {
const feedConfigKey = `feed:${feedId}:config`; const feedConfigKey = `feed:${feedId}:config`;
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
const [feedConfig, feedMetadata] = (await Promise.all([ const results = await Promise.allSettled([
emailStorage.get(feedConfigKey, { type: "json" }),
emailStorage.get(feedMetadataKey, { type: "json" }),
])) as [FeedConfig | null, FeedMetadata | null];
const emailKeys = (feedMetadata?.emails || []).map((email) => email.key);
await deleteKeysWithConcurrency(emailStorage, emailKeys, 25);
await Promise.all([
emailStorage.delete(feedConfigKey), emailStorage.delete(feedConfigKey),
emailStorage.delete(feedMetadataKey), emailStorage.delete(feedMetadataKey),
]); ]);
const removedFromList = options.skipListUpdate return results.every((r) => r.status === "fulfilled");
? false }
: await removeFeedFromList(emailStorage, feedId);
return !!feedConfig || !!feedMetadata || removedFromList; async function purgeFeedKeysStep(
emailStorage: KVNamespace,
feedId: string,
options: { cursor?: string; limit?: number } = {},
): Promise<{
deletedKeys: string[];
failedKeys: string[];
cursor: string;
listComplete: boolean;
}> {
const prefix = `feed:${feedId}:`;
const limit = Math.min(
1000,
Math.max(1, Math.floor(options.limit || 250)),
);
const cursor = options.cursor || undefined;
const listed = await emailStorage.list({ prefix, cursor, limit });
const keys = (listed.keys || []).map((k) => k.name);
const { ok, failed } = await deleteKeysWithConcurrency(emailStorage, keys, 35);
return {
deletedKeys: ok,
failedKeys: failed,
cursor: listed.cursor || "",
listComplete: !!listed.list_complete,
};
} }
// Delete feed // Delete feed
@@ -1518,10 +1543,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
const view = c.req.query("view") === "table" ? "table" : "list"; const view = c.req.query("view") === "table" ? "table" : "list";
try { try {
const deleted = await deleteFeedAndEmails(emailStorage, feedId); await deleteFeedFast(emailStorage, feedId);
if (!deleted) { await removeFeedFromList(emailStorage, feedId);
return c.text("Feed not found", 404);
} // Best-effort cleanup in the background so the request stays fast.
// Use the UI purge endpoint for full, user-visible progress.
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
return c.redirect(`/admin?view=${view}`); return c.redirect(`/admin?view=${view}`);
} catch (error) { } catch (error) {
console.error("Error deleting feed:", error); console.error("Error deleting feed:", error);
@@ -1529,6 +1556,41 @@ app.post("/feeds/:feedId/delete", async (c) => {
} }
}); });
// Purge all keys for a feed in small steps (used by the admin UI after deleting feeds).
app.post("/feeds/:feedId/purge", async (c) => {
const env = c.env as unknown as Env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
try {
const body = (await c.req.json().catch(() => null)) as {
cursor?: unknown;
limit?: unknown;
} | null;
const cursor = body?.cursor ? String(body.cursor) : undefined;
const limit = Number.isFinite(Number(body?.limit))
? Number(body?.limit)
: 250;
const step = await purgeFeedKeysStep(emailStorage, feedId, {
cursor,
limit,
});
return c.json({
ok: step.failedKeys.length === 0,
deletedCount: step.deletedKeys.length,
failedCount: step.failedKeys.length,
cursor: step.cursor,
listComplete: step.listComplete,
});
} catch (error) {
console.error("Error purging feed keys:", error);
return c.json({ ok: false, error: "Error purging feed keys" }, 500);
}
});
// Bulk delete feeds selected in the dashboard // Bulk delete feeds selected in the dashboard
app.post("/feeds/bulk-delete", async (c) => { app.post("/feeds/bulk-delete", async (c) => {
const env = c.env as unknown as Env; const env = c.env as unknown as Env;
@@ -1567,17 +1629,15 @@ app.post("/feeds/bulk-delete", async (c) => {
} }
const results: Array<{ feedId: string; ok: boolean }> = []; const results: Array<{ feedId: string; ok: boolean }> = [];
const concurrency = 3; const concurrency = 10;
for (let i = 0; i < parsedFeedIds.length; i += concurrency) { for (let i = 0; i < parsedFeedIds.length; i += concurrency) {
const batch = parsedFeedIds.slice(i, i + concurrency); const batch = parsedFeedIds.slice(i, i + concurrency);
const batchResults = await Promise.all( const batchResults = await Promise.all(
batch.map(async (feedId) => { batch.map(async (feedId) => {
try { try {
await deleteFeedAndEmails(emailStorage, feedId, { const ok = await deleteFeedFast(emailStorage, feedId);
skipListUpdate: true, return { feedId, ok };
});
return { feedId, ok: true };
} catch (error) { } catch (error) {
console.error("Error bulk deleting feed:", feedId, error); console.error("Error bulk deleting feed:", feedId, error);
return { feedId, ok: false }; return { feedId, ok: false };
@@ -1592,6 +1652,12 @@ app.post("/feeds/bulk-delete", async (c) => {
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
// Best-effort: kick off small purge steps in the background so storage starts clearing.
// The UI also runs purge steps for full cleanup + progress.
deletedFeedIds.forEach((feedId) => {
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
});
return c.json({ return c.json({
ok: failedFeedIds.length === 0, ok: failedFeedIds.length === 0,
deletedFeedIds, deletedFeedIds,
@@ -1610,17 +1676,15 @@ app.post("/feeds/bulk-delete", async (c) => {
} }
const results: Array<{ feedId: string; ok: boolean }> = []; const results: Array<{ feedId: string; ok: boolean }> = [];
const concurrency = 3; const concurrency = 10;
for (let i = 0; i < parsedFeedIds.length; i += concurrency) { for (let i = 0; i < parsedFeedIds.length; i += concurrency) {
const batch = parsedFeedIds.slice(i, i + concurrency); const batch = parsedFeedIds.slice(i, i + concurrency);
const batchResults = await Promise.all( const batchResults = await Promise.all(
batch.map(async (feedId) => { batch.map(async (feedId) => {
try { try {
await deleteFeedAndEmails(emailStorage, feedId, { const ok = await deleteFeedFast(emailStorage, feedId);
skipListUpdate: true, return { feedId, ok };
});
return { feedId, ok: true };
} catch (error) { } catch (error) {
console.error("Error bulk deleting feed:", feedId, error); console.error("Error bulk deleting feed:", feedId, error);
return { feedId, ok: false }; return { feedId, ok: false };
@@ -1633,6 +1697,10 @@ app.post("/feeds/bulk-delete", async (c) => {
const okIds = results.filter((r) => r.ok).map((r) => r.feedId); const okIds = results.filter((r) => r.ok).map((r) => r.feedId);
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
deletedFeedIds.forEach((feedId) => {
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
});
return c.redirect( return c.redirect(
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
); );