From 42c4c670c1d73fc19dc84405497ca59ce5e1faa7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 31 Oct 2026 09:32:15 +0000 Subject: Stop a second device from emptying the library, and recover what one did I pointed the emulator at the live server to verify that published breaks actually skip. It worked. It also pushed the emulator's own four-show subscription list, which replaced the real one, and at 07:07:13 the server reclaimed every show that device had never heard of: 26 shows and 290 mirrored episodes, deleted in a second. Three fixes, in the order they matter. **The list can no longer be emptied by one push.** It is still replaced wholesale -- the phone owns it, and reconciling two editable copies of something changed this rarely costs more in sync bugs than it saves -- but a push that keeps fewer than half of ten or more feeds is refused with a 409 and logged. Losing most of a library in one push is a second device or a bug; it is never somebody unsubscribing. Below ten feeds there is nothing to protect: a new phone's first push legitimately replaces nothing with everything. **Recognition is recovered rather than redone.** A transcript is keyed by the episode id, which comes from the feed and the guid, so it outlives the record and matches again when the episode is mirrored afresh. 332 transcript files survived on disk. Reclaim restores them -- 18 immediately, the rest as re-mirroring reaches them -- instead of spending twenty hours of CPU recognising text the server is already holding. It refuses a transcript whose last cue runs past the end of the audio, which is the same check made of a publisher's. **The pipeline's Save was never wired**, which the security review caught before it could panic in anger: the extraction moved the call and not the field. It is now optional, because every test wants it nil, and assumed-non-nil is what shipped a server that would have died on its first recognised episode. Reclaim runs before each show is polled as well as at startup, or a re-mirrored episode would wait for a restart to find the words already sitting next to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- server/cmd/listend/main.go | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'server/cmd') diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index e1329a9..2298db5 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -117,6 +117,10 @@ func main() { requests: make(chan request, 64), wakeups: make(chan struct{}, 1), } + // Wired after the worker exists, because it is the worker's own save. The pipeline runs + // for tens of minutes at a time and persists as each episode finishes, so a restart in + // the middle keeps the ones already done. + worker.words.Save = worker.save server := api.New(state, worker, api.Options{ Token: token, @@ -143,6 +147,7 @@ func main() { defer stop() if *once { + worker.words.Reclaim() worker.words.Rederive() worker.pollAll(ctx) // Recognition too, so a deployment check exercises the expensive path rather than @@ -228,6 +233,7 @@ func (w *worker) Request(feedURL, guid, enclosureURL, title string) { } func (w *worker) run(ctx context.Context, every time.Duration) { + w.words.Reclaim() w.words.Rederive() w.save() w.pollAll(ctx) @@ -373,6 +379,10 @@ func (w *worker) pollOne(ctx context.Context, feedURL string) { if fetched > 0 { log.Printf("%s: mirrored %d new episode(s)", channel.Title, fetched) } + // Before anything else looks at this show: an episode just re-mirrored may already have + // its words on disk from before its record was lost, and recognising it again would cost + // five minutes for text the server is holding. + w.words.Reclaim() w.words.Harvest(ctx, show, kept) w.words.Recurring(show) w.detect(ctx, show, kept) -- cgit v1.2.3