feat(web,latest): pause and resume one Site's Lane with a mandatory expiry (#147)
Two owner-gated POST routes write the durable poll_lanes pause stamp the
poller's top-of-pass gate already reads: /admin/lanes/{site}/pause validates
the duration against the fixed 1h/6h/24h allow-list and the Site against the
registry, and /admin/lanes/{site}/resume zeroes the stamp. Both cap the form
body like the API path, answer with the freshly rendered Lanes block, and
never command the poller — the pause is a fact about the Site, so it
survives a restart. The Lanes page's c-ctrl slot now carries the pausebar:
Resume while paused, the duration select plus Pause while running, with the
paused phrase read from the live poll_lanes stamp so a press renders as
paused with no pass having run. Tests cover the round trips, rejections,
body caps, the pause-before-refusal ordering, fresh-poller survival, resume
restoring the full queue, and acquisition being unaffected.
This commit is contained in:
@@ -135,6 +135,33 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A pause governs the Lane only: a Reader's first bookmark of a Series on a
|
||||
// paused Site still reads the page, because acquisition is the creation-time
|
||||
// fetch, not the poll queue (issue #147).
|
||||
func TestAcquireIgnoresLanePause(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}
|
||||
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
||||
acq := newAcquirer(s, page, covers)
|
||||
|
||||
bookmarkNewSeries(t, s, acquireSeriesURL)
|
||||
acq.Wait()
|
||||
|
||||
if got := page.callCount(); got != 1 {
|
||||
t.Fatalf("series page fetches on a paused Site = %d, want 1", got)
|
||||
}
|
||||
if got := covers.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetches = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, acquireKey)
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series that already exists is not re-acquired: no fetch, and the Cover it
|
||||
// already has is left alone.
|
||||
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
||||
|
||||
@@ -2058,6 +2058,107 @@ func TestDurableRefusalSurvivesFreshPoller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The pause is read ahead of the refusal check: a Lane that is both paused
|
||||
// and inside a refusal backoff records the paused skip value, not the
|
||||
// refusing one. The pause is the owner's order and outranks the Site's mood
|
||||
// (issue #147).
|
||||
func TestPauseGatePrecedesRefusalGate(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||
if err := s.SetLaneRefusal("asura", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("SetLaneRefusal: %v", err)
|
||||
}
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||
t.Fatalf("paused-while-refusing pace = %s, want 30m (the pause's expiry)", pace)
|
||||
}
|
||||
if f.callCount() != 0 {
|
||||
t.Fatalf("fetches while paused and refusing = %d, want 0", f.callCount())
|
||||
}
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||
t.Fatalf("skip = %q, want %q (the pause outranks the refusal)", pass.Skip, SkipPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// A pause is a fact about the Site, not about the process: a freshly
|
||||
// constructed poller against a store holding a pause row stays paused until
|
||||
// the expiry, then runs the Lane normally. The restart criterion is the
|
||||
// whole point of writing a row instead of commanding a poller (issue #147).
|
||||
func TestDurablePauseSurvivesFreshPoller(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
// A restart: a brand-new poller, no in-memory state, same store.
|
||||
fresh := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||
if pace := fresh.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||
t.Fatalf("fresh poller's paused pace = %s, want 30m", pace)
|
||||
}
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipPaused)
|
||||
}
|
||||
|
||||
// Past the expiry the same fresh poller runs the Lane normally.
|
||||
fresh.Now = func() time.Time { return now.Add(31 * time.Minute) }
|
||||
fresh.runLanePass(context.Background(), "asura", false)
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != "" {
|
||||
t.Fatalf("pass after the expiry skip = %q, want the loop reached", pass.Skip)
|
||||
}
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got == 0 {
|
||||
t.Fatal("the Series was not checked after the pause lifted")
|
||||
}
|
||||
}
|
||||
|
||||
// ResumeLane zeroes the pause and the Lane's next pass finds its full queue
|
||||
// waiting: a pause delays work rather than discarding it, so the due Series
|
||||
// sit unstamped while paused and are all fetched once the pause lifts
|
||||
// (issue #147).
|
||||
func TestResumeLaneRestoresTheQueue(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
for i := 0; i < 3; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("asura:s%d", i), "https://asurascans.com/series/x", 0)
|
||||
}
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if f.callCount() != 0 {
|
||||
t.Fatalf("fetches while paused = %d, want 0", f.callCount())
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got != 0 {
|
||||
t.Fatalf("asura:s%d stamp while paused = %d, want 0 (due and unstamped)", i, got)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ResumeLane("asura"); err != nil {
|
||||
t.Fatalf("ResumeLane: %v", err)
|
||||
}
|
||||
before := f.callCount()
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := f.callCount() - before; got != 3 {
|
||||
t.Fatalf("fetches after resume = %d, want 3 (the full due queue)", got)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got == 0 {
|
||||
t.Fatalf("asura:s%d still untried after resume", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLanePass prunes in the same call that inserts, so the retention
|
||||
// cutoff the recorder passes is observable in what survives: a row just inside
|
||||
// 14 days behind the poller's clock is kept, one just outside is pruned
|
||||
|
||||
Reference in New Issue
Block a user