Merge #172: The other three conditions the rule selects
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user