mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
fix(admin): make bulk feed delete fast + add purge endpoint
This commit is contained in:
+97
-29
@@ -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}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user