Owner-only admin page: Reader roster plus Poll Lane status (#102) #107
@@ -57,6 +57,10 @@ type Poller struct {
|
||||
mu sync.Mutex
|
||||
refuseUntil map[string]time.Time
|
||||
browserDownAt time.Time
|
||||
// laneStates is the owner's page snapshot of each Lane's last pass
|
||||
// (issue #102), keyed by Site. Guarded by mu; a Site appears only after
|
||||
// its first pass, so a restart renders "no data yet" rather than zeroes.
|
||||
laneStates map[string]LaneState
|
||||
// coverWG tracks in-flight cover work. Covers heal in the background so a
|
||||
// slow cover host cannot delay the next Series-page Poll; tests join it
|
||||
// before asserting on cover fetches.
|
||||
@@ -218,6 +222,10 @@ func (p *Poller) runOnce(ctx context.Context) {
|
||||
// production Lane's rate limit; the deterministic test entry runs back to back.
|
||||
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
|
||||
now := p.Now()
|
||||
// Snapshot this pass for the owner's page (issue #102). Recorded on every
|
||||
// return path, with the figures filled in where the pass computes them.
|
||||
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
|
||||
defer func() { p.recordLaneState(st) }()
|
||||
if until := p.refusalBackoff(name); now.Before(until) {
|
||||
// Cooling down after a refusal: do not attempt this Site at all.
|
||||
return until.Sub(now)
|
||||
@@ -246,6 +254,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
log.Printf("latest poll %s: due query: %v", name, err)
|
||||
return defaultGap
|
||||
}
|
||||
st.Due = len(due)
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||
// browser): waking it for a single Poll would cost a challenge solve
|
||||
@@ -268,6 +277,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
return defaultGap
|
||||
}
|
||||
gap, clamped := effectiveGap(s, eligible)
|
||||
st.Gap, st.Clamped = gap, clamped
|
||||
if clamped {
|
||||
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
|
||||
}
|
||||
|
||||
@@ -1580,3 +1580,70 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
|
||||
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's admin page (issue #102) reads Lane state out of the poller.
|
||||
// Before any pass the snapshot is empty — a restart must render "no data
|
||||
// yet", not zeroes — and each pass records what it saw: the frozen clock,
|
||||
// the due count and the pace, with refusal backoff derived at snapshot time.
|
||||
func TestLaneStatus(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||
|
||||
if st := p.LaneStatus(); len(st.Lanes) != 0 {
|
||||
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
|
||||
} else if st.BrowserConfigured || st.BrowserReachable {
|
||||
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
|
||||
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
|
||||
// Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
|
||||
browser := &fakeFetcher{status: 403}
|
||||
p.BrowserFetch = browser
|
||||
for i := range 2 {
|
||||
key := fmt.Sprintf("kagane:s%d", i)
|
||||
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
|
||||
st := p.LaneStatus()
|
||||
var asura, kagane LaneState
|
||||
for _, lane := range st.Lanes {
|
||||
switch lane.Site {
|
||||
case "asura":
|
||||
asura = lane
|
||||
case "kagane":
|
||||
kagane = lane
|
||||
}
|
||||
}
|
||||
if st.Lanes[0].Site != "asura" {
|
||||
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
|
||||
}
|
||||
if asura.Site == "" {
|
||||
t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
|
||||
}
|
||||
if !asura.LastRun.Equal(now) {
|
||||
t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
|
||||
}
|
||||
if asura.Due != 1 {
|
||||
t.Fatalf("asura Due = %d, want 1", asura.Due)
|
||||
}
|
||||
if asura.Gap == 0 {
|
||||
t.Fatal("asura Gap = 0, want the Lane's pace")
|
||||
}
|
||||
if asura.Browser || asura.Refusing {
|
||||
t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
|
||||
}
|
||||
if !kagane.Browser || !kagane.Refusing {
|
||||
t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
|
||||
}
|
||||
if !st.BrowserConfigured || !st.BrowserReachable {
|
||||
t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
|
||||
// A lost sidecar reads as unreachable for the same window the Lanes skip.
|
||||
p.setBrowserDown(now)
|
||||
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
|
||||
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package latest
|
||||
|
||||
import "time"
|
||||
|
||||
// LaneState is the administrative page's view of one Poll Lane (issue #102):
|
||||
// what the Lane's last completed pass saw. Due and Gap are filled in as the
|
||||
// pass computes them, so a pass that returned before reaching a figure (Lane
|
||||
// in refusal backoff, no fetcher) records a zero in its place.
|
||||
type LaneState struct {
|
||||
Site string
|
||||
Due int
|
||||
LastRun time.Time
|
||||
Gap time.Duration
|
||||
Clamped bool
|
||||
Refusing bool
|
||||
Browser bool
|
||||
}
|
||||
|
||||
// Status is the owner's page snapshot of the whole poller (issue #102).
|
||||
type Status struct {
|
||||
Lanes []LaneState
|
||||
BrowserConfigured bool
|
||||
BrowserReachable bool
|
||||
}
|
||||
|
||||
// LaneStatus returns a copy of the poller's Lane state for the owner's page.
|
||||
// Only Sites that have completed a pass appear — a restart therefore renders
|
||||
// "no data yet" instead of confident zeroes — in the same order Run iterates.
|
||||
// Refusing is derived at snapshot time from the refusal backoff, not stored,
|
||||
// so a Lane that cooled down between passes reports false without a new pass.
|
||||
// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
|
||||
// within the refuseBackoff window since its last loss.
|
||||
func (p *Poller) LaneStatus() Status {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
lanes := make([]LaneState, 0, len(p.laneStates))
|
||||
now := p.Now()
|
||||
for _, name := range laneNames() {
|
||||
st, ok := p.laneStates[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st.Refusing = now.Before(p.refuseUntil[name])
|
||||
lanes = append(lanes, st)
|
||||
}
|
||||
configured := p.BrowserFetch != nil
|
||||
reachable := configured
|
||||
if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
|
||||
reachable = false
|
||||
}
|
||||
return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
|
||||
}
|
||||
|
||||
// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
|
||||
// from runLanePass so every return path records, even a pass that refused.
|
||||
func (p *Poller) recordLaneState(st LaneState) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.laneStates == nil {
|
||||
p.laneStates = make(map[string]LaneState)
|
||||
}
|
||||
p.laneStates[st.Site] = st
|
||||
}
|
||||
Reference in New Issue
Block a user