Merge #172: The other three conditions the rule selects

This commit is contained in:
2026-08-23 02:49:07 +07:00
5 changed files with 860 additions and 20 deletions
+95
View File
@@ -1227,6 +1227,101 @@ func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
return out, rows.Err()
}
// RefusingSince reports, per Site, when that Site's current unbroken run of
// refusing passes began: a pass refuses when the Lane gate returned early
// (skip = 'refusing') or the pass loop counted refusals (refused > 0), so a
// Site parked in refusal backoff keeps its run unbroken. A Site whose
// latest pass did not refuse is absent. The run is bounded by the pass
// log's retention — a run older than the log answers with the oldest row
// present — and nothing after now counts: the cutoff is the caller's clock.
func (s *Store) RefusingSince(now int64) (map[string]int64, error) {
rows, err := s.db.Query(`
SELECT site, MIN(ran_at)
FROM poll_passes p
WHERE ran_at <= $1
AND (refused > 0 OR skip = 'refusing')
AND ran_at > COALESCE((
SELECT MAX(q.ran_at) FROM poll_passes q
WHERE q.site = p.site AND q.ran_at <= $1
AND NOT (q.refused > 0 OR q.skip = 'refusing')
), 0)
GROUP BY site`, now)
if err != nil {
return nil, fmt.Errorf("query refusing since: %w", err)
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var site string
var since int64
if err := rows.Scan(&site, &since); err != nil {
return nil, fmt.Errorf("scan refusing since: %w", err)
}
out[site] = since
}
return out, rows.Err()
}
// SidecarOK reports, per Site in sites, the unix ms of that Site's most
// recent pass that actually reached the sidecar: the pass ran its loop
// (skip = '') and no Series read lost Chrome (unreachable = 0) — a
// challenge answer still reached the sidecar, a lost sidecar did not. A
// Site with no such pass is absent: an asleep Lane never reached it, so it
// is absent too and cannot age into a sidecar-down alarm by itself.
func (s *Store) SidecarOK(sites []string) (map[string]int64, error) {
rows, err := s.db.Query(`
SELECT DISTINCT ON (site) site, ran_at
FROM poll_passes
WHERE site = ANY($1) AND skip = '' AND unreachable = 0
ORDER BY site, ran_at DESC`, sites)
if err != nil {
return nil, fmt.Errorf("query sidecar ok: %w", err)
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var site string
var ranAt int64
if err := rows.Scan(&site, &ranAt); err != nil {
return nil, fmt.Errorf("scan sidecar ok: %w", err)
}
out[site] = ranAt
}
return out, rows.Err()
}
// NoChapterShare reports, per Site, the share of that Site's Series holding
// a no-chapter failure row older than cutoff: old rows over the Site's
// whole Series count, so a four-Series Site and a 200-Series Site are
// judged on the same scale. A Site with no Series has no share and is
// absent.
func (s *Store) NoChapterShare(cutoff int64) (map[string]float64, error) {
rows, err := s.db.Query(`
SELECT s.site,
(COUNT(*) FILTER (WHERE f.outcome = 'no_chapter' AND f.failing_since < $1))::float8
/ (COUNT(*)::float8)
FROM series s
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
GROUP BY s.site
ORDER BY s.site`, cutoff)
if err != nil {
return nil, fmt.Errorf("query no-chapter share: %w", err)
}
defer rows.Close()
out := map[string]float64{}
for rows.Next() {
var site string
var share float64
if err := rows.Scan(&site, &share); err != nil {
return nil, fmt.Errorf("scan no-chapter share: %w", err)
}
out[site] = share
}
return out, rows.Err()
}
// SetLaneRefusal persists a Site's refusal backoff stamp without touching its
// pause. until is supplied by the caller's clock.
func (s *Store) SetLaneRefusal(site string, until int64) error {
+159
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"errors"
"io/fs"
"fmt"
"os"
"path/filepath"
"strconv"
@@ -2738,3 +2739,161 @@ func TestOwnerNoticeRows(t *testing.T) {
t.Fatalf("ClearNotice on a missing row: %v", err)
}
}
// RefusingSince reads one Site's current unbroken run of refusing passes
// (issue #172). A refusing pass is one the Lane gate returned early from
// (skip 'refusing') or one whose loop counted refusals (refused > 0) — the
// two shapes a persistently-challenged Site alternates between, so the run
// must not break when the gate row follows the refusal row. A Site whose
// latest pass did not refuse is absent, nothing after the caller's clock
// counts, and a run older than the log answers with the oldest row present.
func TestRefusingSince(t *testing.T) {
s := newTestStore(t)
seed := func(site string, ranAt int64, skip string, refused int) {
t.Helper()
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Refused: refused}, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
}
}
// asura: a refusing run broken by a healthy pass, then resumed; the
// current run began at the pass after the break.
seed("asura", 100, "", 2)
seed("asura", 200, "", 0)
seed("asura", 300, "refusing", 0)
seed("asura", 400, "", 2)
// demonic: the latest pass is healthy — no run, absent.
seed("demonic", 100, "", 2)
seed("demonic", 200, "", 0)
// novelfull: a healthy pass after now must not break the run — the run
// is judged as of the caller's clock.
seed("novelfull", 100, "", 2)
seed("novelfull", 600, "", 0)
// comix: an unbroken run reaches back to the oldest row present.
seed("comix", 100, "", 2)
seed("comix", 200, "refusing", 0)
got, err := s.RefusingSince(450)
if err != nil {
t.Fatalf("RefusingSince: %v", err)
}
want := map[string]int64{"asura": 300, "novelfull": 100, "comix": 100}
if len(got) != len(want) {
t.Fatalf("RefusingSince = %v, want %v", got, want)
}
for site, since := range want {
if got[site] != since {
t.Fatalf("RefusingSince[%s] = %d, want %d (got %v)", site, got[site], since, got)
}
}
if _, ok := got["demonic"]; ok {
t.Fatalf("RefusingSince names demonic, whose latest pass did not refuse: %v", got)
}
}
// SidecarOK reads, per Site, the most recent pass that actually reached the
// sidecar (issue #172): the pass ran its loop (skip '') and no read lost
// Chrome (unreachable 0). A challenge answer still reached the sidecar, a
// lost sidecar and every skip value did not, and a Site with no qualifying
// pass — an asleep Lane included — is absent.
func TestSidecarOK(t *testing.T) {
s := newTestStore(t)
seed := func(site string, ranAt int64, skip string, unreachable int) {
t.Helper()
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Unreachable: unreachable}, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
}
}
// comix: only the skip='' unreachable=0 pass reached the sidecar; the
// mid-loop browser-loss row (skip '', unreachable > 0) and the skip
// values never did.
seed("comix", 100, "", 1)
seed("comix", 200, "sidecar-down", 0)
seed("comix", 300, "", 0)
seed("comix", 400, "refusing", 0)
// kagane: two passes reached the sidecar; the newest wins.
seed("kagane", 150, "", 0)
seed("kagane", 250, "", 0)
// novelfull: an asleep Lane never reached it.
seed("novelfull", 120, "asleep", 0)
got, err := s.SidecarOK([]string{"comix", "kagane", "novelfull", "lightnovelworld"})
if err != nil {
t.Fatalf("SidecarOK: %v", err)
}
want := map[string]int64{"comix": 300, "kagane": 250}
if len(got) != len(want) {
t.Fatalf("SidecarOK = %v, want %v", got, want)
}
for site, ranAt := range want {
if got[site] != ranAt {
t.Fatalf("SidecarOK[%s] = %d, want %d (got %v)", site, got[site], ranAt, got)
}
}
for _, site := range []string{"novelfull", "lightnovelworld"} {
if _, ok := got[site]; ok {
t.Fatalf("SidecarOK names %s, which never reached the sidecar: %v", site, got)
}
}
}
// NoChapterShare reads, per Site, the share of its Series holding a
// no-chapter failure row older than the cutoff (issue #172): old rows over
// the Site's whole Series count, so a four-Series Site and a 200-Series
// Site are judged on the same scale. A fresh no-chapter row and a row of
// any other outcome do not count.
func TestNoChapterShare(t *testing.T) {
s := newTestStore(t)
for i := 1; i <= 4; i++ {
seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), 0)
}
seedForCheck(t, s, "comix:c1", "https://comix.to/title/c1", 0)
seedForCheck(t, s, "comix:c2", "https://comix.to/title/c2", 0)
seedForCheck(t, s, "demonic:d1", "https://demonicscans.org/series/d1", 0)
for i := 1; i <= 200; i++ {
seedForCheck(t, s, fmt.Sprintf("novelfull:n%d", i), fmt.Sprintf("https://novelfull.com/n%d.html", i), 0)
}
const cutoff = 1000
oldNoChapter := func(site, seriesID string) {
t.Helper()
if err := s.RecordSeriesFailure(site, seriesID, "no_chapter", 100); err != nil {
t.Fatalf("seed no-chapter failure %s:%s: %v", site, seriesID, err)
}
}
// asura: three of four hold an old no-chapter row; the fourth's
// no-chapter row is fresh — it is not old, so it does not count.
oldNoChapter("asura", "a1")
oldNoChapter("asura", "a2")
oldNoChapter("asura", "a3")
if err := s.RecordSeriesFailure("asura", "a4", "no_chapter", 2000); err != nil {
t.Fatalf("seed fresh no-chapter failure: %v", err)
}
// comix: an old no-chapter row and an old errors row — the errors row
// is not a no-chapter row, so the share is 1/2, the exact boundary.
oldNoChapter("comix", "c1")
if err := s.RecordSeriesFailure("comix", "c2", "errors", 100); err != nil {
t.Fatalf("seed errors failure: %v", err)
}
// demonic and novelfull: one old no-chapter row each, against sites of
// one and two hundred Series.
oldNoChapter("demonic", "d1")
oldNoChapter("novelfull", "n1")
got, err := s.NoChapterShare(cutoff)
if err != nil {
t.Fatalf("NoChapterShare: %v", err)
}
checks := []struct {
site string
want float64
}{
{"asura", 3.0 / 4.0},
{"comix", 1.0 / 2.0},
{"demonic", 1.0 / 1.0},
{"novelfull", 1.0 / 200.0},
}
for _, c := range checks {
if got[c.site] != c.want {
t.Fatalf("NoChapterShare[%s] = %v, want %v", c.site, got[c.site], c.want)
}
}
}