Reconcile stale running scrapes and allow per-scrape delete
This commit is contained in:
@@ -40,6 +40,10 @@ mobile draft at 390px.
|
|||||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||||
|
- [x] C40 Stale scrape states. Two `general_chut` scrapes showed `running` because the daemons
|
||||||
|
that owned them were killed mid-job; the state file kept the last written value. On daemon
|
||||||
|
start the engine now reconciles `running`/`queued` scrapes to `paused`, and History has a
|
||||||
|
per-scrape **Delete** button (clean with a single ID) next to Resume. Unit-tested.
|
||||||
- [x] C39 File-type filter and the 4chan default. The Advanced drawer gained extension chips
|
- [x] C39 File-type filter and the 4chan default. The Advanced drawer gained extension chips
|
||||||
(video mp4/webm/mov/mkv, image png/jpg/gif/webp/avif, audio mp3/ogg/wav/m4a/flac) matched on
|
(video mp4/webm/mov/mkv, image png/jpg/gif/webp/avif, audio mp3/ogg/wav/m4a/flac) matched on
|
||||||
URL or content type, plus a default-on **Hide 4chan links (usually dead)** toggle. Backend,
|
URL or content type, plus a default-on **Hide 4chan links (usually dead)** toggle. Backend,
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ Rules:
|
|||||||
- One running scrape at a time. Further requests queue.
|
- One running scrape at a time. Further requests queue.
|
||||||
- `stop` marks the job and lets the current page finish.
|
- `stop` marks the job and lets the current page finish.
|
||||||
- `resume` continues a paused scrape from its stored cursor, so an interrupted walk does not fetch
|
- `resume` continues a paused scrape from its stored cursor, so an interrupted walk does not fetch
|
||||||
the pages it already has again. The History popup has a **Resume** button and the API a
|
the pages it already has again. The History popup has **Resume** and **Delete** buttons and the
|
||||||
`POST /api/media/scrape/resume` route.
|
API a `POST /api/media/scrape/resume` route.
|
||||||
|
- On daemon start, scrapes still marked `running` or `queued` on disk are stale (their job died
|
||||||
|
with the previous daemon). They are reconciled to `paused` so the UI tells the truth and Resume
|
||||||
|
can continue them.
|
||||||
- Every page is an outbound read. Sends from commands keep their own pacing.
|
- Every page is an outbound read. Sends from commands keep their own pacing.
|
||||||
|
|
||||||
## Pacing and backoff
|
## Pacing and backoff
|
||||||
|
|||||||
+2
-1
@@ -140,7 +140,8 @@ box live in Chat, not Call, and the connection stats are not chips yet.
|
|||||||
**New scrape…** opens a popup with **Server** and **Group chat**
|
**New scrape…** opens a popup with **Server** and **Group chat**
|
||||||
tabs: servers expand into their text channels, each channel (and each group chat) has
|
tabs: servers expand into their text channels, each channel (and each group chat) has
|
||||||
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
||||||
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews.
|
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews,
|
||||||
|
and offers **Resume** for paused ones and **Delete** for anything not running.
|
||||||
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
|
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
|
||||||
cached preview when one exists, then the local file, then the CDN URL; videos and audio are link
|
cached preview when one exists, then the local file, then the CDN URL; videos and audio are link
|
||||||
cards with the poster and posted time in the tooltip.
|
cards with the poster and posted time in the tooltip.
|
||||||
|
|||||||
@@ -208,6 +208,25 @@ export class ScrapeEngine {
|
|||||||
return { ...meta };
|
return { ...meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scrape left `running` or `queued` on disk has no live job (the daemon that owned it is
|
||||||
|
* gone). Pause it so the UI shows the truth and Resume can pick it up.
|
||||||
|
*/
|
||||||
|
reconcileStaleJobs(): string[] {
|
||||||
|
const stale: string[] = [];
|
||||||
|
|
||||||
|
for (const meta of listScrapes(this.options.rootDir)) {
|
||||||
|
if (meta.state !== "running" && meta.state !== "queued") continue;
|
||||||
|
|
||||||
|
meta.state = "paused";
|
||||||
|
meta.updatedAt = new Date(this.now()).toISOString();
|
||||||
|
writeScrapeMeta(join(this.options.rootDir, meta.id), meta);
|
||||||
|
stale.push(meta.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale;
|
||||||
|
}
|
||||||
|
|
||||||
/** Cache previews for rows a finished or paused scrape never reached. Returns the count. */
|
/** Cache previews for rows a finished or paused scrape never reached. Returns the count. */
|
||||||
async backfillPreviews(scrapeId: string): Promise<number> {
|
async backfillPreviews(scrapeId: string): Promise<number> {
|
||||||
const meta = this.active.get(scrapeId) ?? readScrapeMeta(join(this.options.rootDir, scrapeId));
|
const meta = this.active.get(scrapeId) ?? readScrapeMeta(join(this.options.rootDir, scrapeId));
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ import { betterCandidate } from "../src/dedupe.js";
|
|||||||
import { aspectCondition, resolutionCondition } from "../src/filter-presets.js";
|
import { aspectCondition, resolutionCondition } from "../src/filter-presets.js";
|
||||||
import { isNearLimit, planRetention } from "../src/retention.js";
|
import { isNearLimit, planRetention } from "../src/retention.js";
|
||||||
import { isoFromSnowflake, snowflakeFromIso } from "../src/snowflake.js";
|
import { isoFromSnowflake, snowflakeFromIso } from "../src/snowflake.js";
|
||||||
import { dirSizeBytes, listScrapes, ScrapeStore } from "../src/store.js";
|
import {
|
||||||
|
dirSizeBytes,
|
||||||
|
listScrapes,
|
||||||
|
readScrapeMeta,
|
||||||
|
ScrapeStore,
|
||||||
|
writeScrapeMeta
|
||||||
|
} from "../src/store.js";
|
||||||
import type { CoreAttachment, CoreMessage } from "../src/types.js";
|
import type { CoreAttachment, CoreMessage } from "../src/types.js";
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -877,6 +883,25 @@ void (async () => {
|
|||||||
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test("stale running scrapes are paused on reconcile", async () => {
|
||||||
|
const backend = new FakeBackend([{ messages: [message("1400")] }]);
|
||||||
|
const engine = engineFor(root, backend);
|
||||||
|
const started = await engine.start({ channelId: "170", guildId: null, mode: "links", backend });
|
||||||
|
await engine.processQueue();
|
||||||
|
|
||||||
|
// Simulate a daemon that died mid-job: the meta still says running.
|
||||||
|
const dir = join(root, started.id);
|
||||||
|
const meta = readScrapeMeta(dir);
|
||||||
|
assert.ok(meta);
|
||||||
|
meta.state = "running";
|
||||||
|
writeScrapeMeta(dir, meta);
|
||||||
|
|
||||||
|
const fresh = new ScrapeEngine({ rootDir: root, previews: false });
|
||||||
|
const stale = fresh.reconcileStaleJobs();
|
||||||
|
assert.deepEqual(stale, [started.id]);
|
||||||
|
assert.equal(readScrapeMeta(dir)?.state, "paused");
|
||||||
|
});
|
||||||
|
|
||||||
await test("markPreview stores the animated gif reference", () => {
|
await test("markPreview stores the animated gif reference", () => {
|
||||||
const dir = join(root, "anim-store");
|
const dir = join(root, "anim-store");
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|||||||
@@ -1336,6 +1336,28 @@
|
|||||||
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
|
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (scrape.state !== "running" && scrape.state !== "queued") {
|
||||||
|
row.append(
|
||||||
|
button("Delete", async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Delete scrape ${scrape.id}? This removes its database, downloads, and previews.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.VK.api.post("/api/media/clean", { ids: [scrape.id] });
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`delete failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
void loadScrapes();
|
||||||
|
void openHistoryModal();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (["paused", "queued"].includes(scrape.state)) {
|
if (["paused", "queued"].includes(scrape.state)) {
|
||||||
row.append(
|
row.append(
|
||||||
button("Resume", async () => {
|
button("Resume", async () => {
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stale = this.engine.reconcileStaleJobs();
|
||||||
|
if (stale.length) {
|
||||||
|
this.deps.log?.(`paused ${stale.length} stale scrape(s) left running by a previous daemon`);
|
||||||
|
}
|
||||||
|
|
||||||
// Preview passes only run when a walk ends; fill in scrapes that paused early.
|
// Preview passes only run when a walk ends; fill in scrapes that paused early.
|
||||||
void this.backfillPreviews();
|
void this.backfillPreviews();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user