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:
@@ -347,11 +347,13 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
return defaultGap
|
||||
}
|
||||
fig.Due = len(due)
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) && !anyForced(due) {
|
||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||
// browser): waking it for a single Poll would cost a challenge solve
|
||||
// per request. The Lane still paces at the default gap, which is what
|
||||
// the owner's page must show rather than a zero.
|
||||
// per request. A forced Series is the one exception — a human asking
|
||||
// is not the machine waking itself (issue #146). The Lane still paces
|
||||
// at the default gap, which is what the owner's page must show rather
|
||||
// than a zero.
|
||||
rec.skip = SkipAsleep
|
||||
fig.Gap = defaultGap
|
||||
return defaultGap
|
||||
@@ -539,6 +541,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool
|
||||
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
||||
}
|
||||
|
||||
// anyForced reports whether the due list holds a forced Series: one whose
|
||||
// owner check-now request (issue #146) has not been answered yet. A human
|
||||
// asking wakes a sleeping Chrome even below the wake thresholds; the request
|
||||
// itself still ages visibly if the home machine is off.
|
||||
func anyForced(due []store.Series) bool {
|
||||
for _, sr := range due {
|
||||
if sr.Forced {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
||||
// waiting past its due moment (0 when due is empty).
|
||||
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
||||
|
||||
@@ -2098,3 +2098,141 @@ func TestPassRetentionCutoffIsFourteenDays(t *testing.T) {
|
||||
t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// stampRecordingFetcher is a fakeFetcher that records the Series' check stamp
|
||||
// at call time — the seam that proves the check stamp is written before the
|
||||
// fetch (issue #146). If the order were swapped, the recorded stamp would be
|
||||
// the pre-pass value and the ordering assertion would fail.
|
||||
type stampRecordingFetcher struct {
|
||||
fakeFetcher
|
||||
store *store.Store
|
||||
site string
|
||||
seriesID string
|
||||
stampAtCall int64
|
||||
}
|
||||
|
||||
func (f *stampRecordingFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
||||
ts, err := f.store.LatestCheckedAt(f.site, f.seriesID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
f.stampAtCall = ts
|
||||
return f.fakeFetcher.Get(ctx, url)
|
||||
}
|
||||
|
||||
// The check stamp is written before the fetch is attempted: the fake fetcher
|
||||
// records the stamp it sees at call time, and it must already be the pass's
|
||||
// own stamp. The order is load-bearing — a forced request is pending while
|
||||
// force_poll_at > latest_checked_at, so stamping after the fetch would make a
|
||||
// failed forced request sticky — and the assertion fails if it is swapped.
|
||||
func TestCheckStampIsWrittenBeforeFetch(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
)
|
||||
seedForCheck(t, s, key, seriesURL, 0)
|
||||
now := time.UnixMilli(7_000_000)
|
||||
rec := &stampRecordingFetcher{
|
||||
fakeFetcher: fakeFetcher{body: asuraSeriesFixture, status: 200},
|
||||
store: s, site: "asura", seriesID: "chronicles-of-the-demon-faction-f886a8af",
|
||||
}
|
||||
newTestPoller(t, s, rec, now).runOnce(context.Background())
|
||||
if rec.stampAtCall != now.UnixMilli() {
|
||||
t.Fatalf("check stamp at fetch time = %d, want %d (the stamp must be written before the fetch)", rec.stampAtCall, now.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
// A forced Series whose attempt fails is no longer pending: the check stamp
|
||||
// is written before the fetch, so the first attempt ends the pending state
|
||||
// whatever it returns. A naive implementation (stamp only on success, or
|
||||
// after the fetch) leaves the request sticky and the row due next pass.
|
||||
func TestForcedSeriesSelfClearsOnFailedAttempt(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(7_000_000)
|
||||
const url = "https://asurascans.com/comics/x"
|
||||
// Freshly checked, so only the force flag makes it due.
|
||||
seedForCheck(t, s, "asura:x", url, now.Add(-30*time.Minute).UnixMilli())
|
||||
if err := s.ForceSeriesPoll("asura", "x", now.UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:x" || !due[0].Forced {
|
||||
t.Fatalf("forced row not due and flagged before the attempt: %+v", due)
|
||||
}
|
||||
|
||||
// The attempt fails, but the attempt still happened: the row is stamped
|
||||
// and no longer pending.
|
||||
f := &fakeFetcher{err: errors.New("dial tcp: refused")}
|
||||
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() {
|
||||
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("failed attempt left the forced row due: %v", due)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal backoff is a Lane gate, not a Series gate: a forced Series does
|
||||
// not override a Site that is actively refusing, because hand-forcing a
|
||||
// request into a refusal only makes it worse (issue #146).
|
||||
func TestForcedSeriesDoesNotOverrideRefusalBackoff(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "kagane:w", "https://kagane.to/series/w", 0)
|
||||
if err := s.ForceSeriesPoll("kagane", "w", now.UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
if err := s.SetLaneRefusal("kagane", now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||
t.Fatalf("SetLaneRefusal: %v", err)
|
||||
}
|
||||
|
||||
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
if got := browser.callCount(); got != 0 {
|
||||
t.Fatalf("browser fetches through a refusal backoff = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced Series wakes a sleeping browser Lane: the wake thresholds exist to
|
||||
// stop the machine waking itself for one unattended check, and a human asking
|
||||
// is not that (issue #146). Below both thresholds the Lane still sleeps when
|
||||
// nothing is forced.
|
||||
func TestForcedSeriesWakesSleepingBrowser(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
// Freshly due: checked two minutes before the rest elapses, so the wait
|
||||
// is far below browserWakeAge and the count is under browserWakeCount.
|
||||
seedForCheck(t, s, "kagane:w1", "https://kagane.to/series/w1", now.Add(-62*time.Minute).UnixMilli())
|
||||
seedForCheck(t, s, "kagane:w2", "https://kagane.to/series/w2", now.Add(-62*time.Minute).UnixMilli())
|
||||
|
||||
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
if got := browser.callCount(); got != 0 {
|
||||
t.Fatalf("browser fetches without a forced series = %d, want 0 (Chrome stays asleep)", got)
|
||||
}
|
||||
|
||||
if err := s.ForceSeriesPoll("kagane", "w1", now.UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
if got := browser.callCount(); got != 2 {
|
||||
t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user