feat(latest): record one poll pass per exit with skip reason and outcome counts

Every way a Lane pass can end now writes exactly one durable row: a skip
value naming the exit (paused, refusing, sidecar-down, no-fetcher,
due-query, asleep, eligible-count, nothing-eligible, or empty for the
loop), five outcome counts from the classification the Series read already
makes, and carry-forward of the previous pass's figures exactly when the
pass's own gap is zero. Refusal is durable through the poll_lanes row, so
a restart does not re-probe a Site inside its backoff. Retention is 14
days. The mid-loop browser-unreachable return writes an empty skip by
design: a tenth value is not invented here. (#141)
This commit is contained in:
2026-08-21 18:48:30 +07:00
parent e0b9063d9e
commit 503fb49d0a
4 changed files with 686 additions and 43 deletions
+10 -8
View File
@@ -1140,18 +1140,20 @@ func (s *Store) PausedLanes() ([]LanePause, error) {
return out, rows.Err()
}
// LanePausedUntil reads a Site's pause stamp. A missing state row is the
// default unpaused state.
func (s *Store) LanePausedUntil(site string) (int64, error) {
var until int64
err := s.db.QueryRow(`SELECT paused_until FROM poll_lanes WHERE site = $1`, site).Scan(&until)
// LaneGates reads a Site's pause and refusal stamps in one row read — the
// top-of-pass gate the poller uses (issue #141). A missing state row is the
// default: unpaused and not refusing.
func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err error) {
err = s.db.QueryRow(
`SELECT paused_until, refuse_until FROM poll_lanes WHERE site = $1`, site).
Scan(&pausedUntil, &refuseUntil)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
return 0, 0, nil
}
if err != nil {
return 0, fmt.Errorf("lane pause %s: %w", site, err)
return 0, 0, fmt.Errorf("lane gates %s: %w", site, err)
}
return until, nil
return pausedUntil, refuseUntil, nil
}
// DueForLatestCheck returns one Site's series whose server-side
+23 -4
View File
@@ -1681,6 +1681,24 @@ func TestLanePassOutcomesSumsWindow(t *testing.T) {
}
}
func TestLaneGatesReadsOneRow(t *testing.T) {
s := newTestStore(t)
paused, refused, err := s.LaneGates("asura")
if err != nil || paused != 0 || refused != 0 {
t.Fatalf("LaneGates on a missing state row = (%d, %d, %v), want (0, 0, nil)", paused, refused, err)
}
if err := s.PauseLane("asura", 2000); err != nil {
t.Fatalf("PauseLane: %v", err)
}
if err := s.SetLaneRefusal("asura", 3000); err != nil {
t.Fatalf("SetLaneRefusal: %v", err)
}
paused, refused, err = s.LaneGates("asura")
if err != nil || paused != 2000 || refused != 3000 {
t.Fatalf("LaneGates = (%d, %d, %v), want (2000, 3000, nil)", paused, refused, err)
}
}
func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
s := newTestStore(t)
for _, until := range []int64{0, -1} {
@@ -1697,8 +1715,9 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil {
t.Fatalf("RecordLanePass: %v", err)
}
if got, err := s.LanePausedUntil("asura"); err != nil || got != 2000 {
t.Fatalf("LanePausedUntil = %d, %v; want 2000", got, err)
pausedUntil, refuseUntil, err := s.LaneGates("asura")
if err != nil || pausedUntil != 2000 || refuseUntil != 3000 {
t.Fatalf("LaneGates = %d/%d, %v; want 2000/3000", pausedUntil, refuseUntil, err)
}
paused, err := s.PausedLanes()
if err != nil {
@@ -1715,8 +1734,8 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
if err != nil || !ok || latest.RefuseUntil != 3000 {
t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err)
}
if got, err := s.LanePausedUntil("asura"); err != nil || got != 0 {
t.Fatalf("LanePausedUntil after resume = %d, %v; want 0", got, err)
if got, got2, err := s.LaneGates("asura"); err != nil || got != 0 || got2 != 3000 {
t.Fatalf("LaneGates after resume = %d/%d, %v; want 0/3000", got, got2, err)
}
if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 {
t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err)