feat(web,store,latest): forced poll — Check now stamps a Series for the next Lane pass (#146)
The control writes series.force_poll_at (column landed in migration 0014) and never commands the poller: pending is derived as force_poll_at > latest_checked_at and self-clears because the check stamp is written before the fetch. The due query's forced flag overrides the rest cutoff, the Sighting-deferral and finished-only clauses, jumps the queue, and wakes a sleeping browser Lane; it never overrides an empty series_url, the Bookmarks join, the refusal backoff, the sidecar-down skip or the Lane gap. ADRs: 0013-commands-through-the-database.
This commit is contained in:
@@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
||||
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
||||
t.Helper()
|
||||
sr, err := scanSeries(s.db.QueryRow(
|
||||
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
|
||||
`SELECT `+seriesColumns+`, false AS forced, 0 AS reader_count FROM series s
|
||||
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
||||
if err != nil {
|
||||
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
||||
@@ -1748,3 +1748,154 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
|
||||
t.Fatalf("lane state rows after resume = %d, want 1", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced Series is due ahead of the rest cutoff: the request overrides the
|
||||
// rest gate so the Lane's next pass picks it up however recently it was
|
||||
// checked. An unforced series under the rest stays out.
|
||||
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const now = int64(10 * 3600_000)
|
||||
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
|
||||
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
|
||||
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:forced" {
|
||||
t.Fatalf("due = %v, want only the forced series", due)
|
||||
}
|
||||
}
|
||||
|
||||
// The Sighting-deferral clause holds a solitary series back after a recent
|
||||
// Reader report; a forced request overrides it and puts the series back on
|
||||
// the Lane's list.
|
||||
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const now = int64(10 * 3600_000)
|
||||
// One bookmark (so deferral can apply), sighted and checked 10m ago:
|
||||
// inside the deferral window and under the ceiling.
|
||||
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
|
||||
now-10*60_000); err != nil {
|
||||
t.Fatalf("seed sighting: %v", err)
|
||||
}
|
||||
|
||||
// Unforced: deferred, and under the rest anyway.
|
||||
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("unforced deferred series is due: %v", due)
|
||||
}
|
||||
|
||||
// Forced: the request overrides the deferral.
|
||||
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:deferred" {
|
||||
t.Fatalf("forced deferred series not due: %v", due)
|
||||
}
|
||||
}
|
||||
|
||||
// The finished-only bucket excludes a series whose only bookmarks are
|
||||
// finished; a forced request overrides it — the owner asked, so the Lane
|
||||
// looks.
|
||||
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
||||
SeriesURL: "https://asurascans.com/comics/finished",
|
||||
Status: StatusFinished, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed finished: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:finished" {
|
||||
t.Fatalf("unforced finished series is due: %v", due)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, sr := range due {
|
||||
got[sr.Key()] = true
|
||||
}
|
||||
if !got["asura:reading"] || !got["asura:finished"] {
|
||||
t.Fatalf("forced finished series not due: %v", due)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced Series jumps the queue: it sorts ahead of a more-read series that
|
||||
// is due by rest, without disturbing the reader-count-then-age tie-break
|
||||
// among the unforced rows (ADR-0003).
|
||||
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
// "popular" has two readers and is long overdue; "forced" has one reader
|
||||
// and a fresh check stamp. The forced row must come first.
|
||||
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
|
||||
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
|
||||
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
|
||||
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 2 {
|
||||
t.Fatalf("due = %d rows, want 2", len(due))
|
||||
}
|
||||
if due[0].Key() != "asura:forced" || due[1].Key() != "asura:popular" {
|
||||
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
|
||||
}
|
||||
}
|
||||
|
||||
// A forced Series with no series URL is still not fetched — nothing to fetch —
|
||||
// and one with no Bookmarks is still excluded by the join. The force flag
|
||||
// opens the three gates it is allowed to, not the whole query.
|
||||
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:nourl", "", 0)
|
||||
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
||||
latest_chapter, latest_chapter_num, latest_checked_at,
|
||||
force_poll_at)
|
||||
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
||||
'', 'manga', '', NULL, 0, 5000)`); err != nil {
|
||||
t.Fatalf("seed orphan: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user