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:
2026-08-22 00:53:51 +07:00
parent 6f9109c885
commit fe2cd12049
14 changed files with 818 additions and 44 deletions
+18 -3
View File
@@ -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 {
+138
View File
@@ -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)
}
}