Spec #137: per-Series poll failure state, completion hint, and outbound owner notification #174
@@ -14,6 +14,25 @@ import (
|
||||
// words (no-browser-route, sidecar-down, adapter-broken); this ticket
|
||||
// declares only the stall.
|
||||
const ConditionStall = "stall"
|
||||
// ConditionNoBrowserRoute is the owner-notice machine word for a Site whose
|
||||
// challenge refuses for longer than the owner window with no browser route
|
||||
// to clear it — the 403-with-interstitial the reader maps to
|
||||
// errChallengeHeld (read.go), which plain TLS cannot clear. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable.
|
||||
const ConditionNoBrowserRoute = "no-browser-route"
|
||||
|
||||
// ConditionSidecarDown is the owner-notice machine word for a browser
|
||||
// sidecar no Lane has reached for longer than the owner window: every
|
||||
// browser-backed Lane's latest pass is a sidecar skip. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable, and its
|
||||
// suppression row holds the empty Site (AC4).
|
||||
const ConditionSidecarDown = "sidecar-down"
|
||||
|
||||
// ConditionAdapterBroken is the owner-notice machine word for a Site whose
|
||||
// adapter stopped finding chapters: more than half of its Series hold an
|
||||
// old no-chapter failure row. The word is the message's footer and its
|
||||
// suppression key; it is wire-stable.
|
||||
const ConditionAdapterBroken = "adapter-broken"
|
||||
|
||||
// OwnerWindow is the class-level staleness boundary every owner-notice
|
||||
// condition measures against — the same twelve hours the Lanes page's
|
||||
@@ -33,6 +52,22 @@ type Fault struct {
|
||||
// conditions can add inputs without changing either caller.
|
||||
type FaultInput struct {
|
||||
Passes []store.LanePass
|
||||
// RefusingSince is, per Site, the unix ms when that Site's current
|
||||
// unbroken run of refusing passes began, or absent when its latest pass
|
||||
// did not refuse. Its zero value is an empty map, which contributes no
|
||||
// fault.
|
||||
RefusingSince map[string]int64
|
||||
|
||||
// SidecarOK is, per browser-backed Site, the unix ms of that Site's most
|
||||
// recent pass that actually reached the sidecar. Zero when the pass log
|
||||
// holds none — an asleep Lane never reached it and never counts as
|
||||
// evidence either way. Its zero value is an empty map.
|
||||
SidecarOK map[string]int64
|
||||
|
||||
// NoChapterShare is, per Site, the share of that Site's Series holding a
|
||||
// no-chapter failure row older than the owner window. Its zero value is
|
||||
// an empty map, which contributes no fault.
|
||||
NoChapterShare map[string]float64
|
||||
}
|
||||
|
||||
// FaultsFrom judges the owner-notice conditions from durable rows alone, so
|
||||
@@ -42,12 +77,27 @@ type FaultInput struct {
|
||||
// value and no refusal — exactly the row the mid-loop browser loss writes
|
||||
// (see the comment at the outcomeUnreachable return in runLanePass), so a
|
||||
// Lane that owed Polls, made none, and has nothing to say for it is a fault.
|
||||
// The no-refusal clause is AC6's amendment: the twice-refused break happens
|
||||
// inside the pass loop, not on an early return, so a newly-gated Site would
|
||||
// otherwise send two messages. A pause is excluded by Skip == "" (SkipPaused):
|
||||
// the owner's own act is not reported back (AC10). The episode began at the
|
||||
// pass that produced the row; the stall test itself has no age clause — the
|
||||
// class-level twelve hours belongs to #172's conditions.
|
||||
// The three #172 conditions share the same OwnerWindow boundary and the same
|
||||
// fail-open shape: an input's absence contributes no fault, never a false
|
||||
// one.
|
||||
//
|
||||
// - no-browser-route: a Site whose refusing run began before the window
|
||||
// and that has no browser route to clear the challenge (AC2). Both
|
||||
// refusal shapes count — the gate's skip='refusing' rows and the loop's
|
||||
// refused>0 rows (the twice-refused break writes skip="") — so the run
|
||||
// stays unbroken across them, and one healthy pass ends it.
|
||||
// - sidecar-down: every browser-backed Site's latest pass is a sidecar
|
||||
// skip — SkipSidecarDown or SkipNoFetcher, the two early returns that
|
||||
// record a skip value — and each Site's most recent sidecar-reaching
|
||||
// pass is older than the window (AC4). The skip clause is what keeps
|
||||
// SkipAsleep out: a Lane under both wake thresholds is the commonest
|
||||
// healthy state, never reached the sidecar, and would otherwise age into
|
||||
// a false alarm. Emitted once, with Site "" (AC4's one row per episode).
|
||||
// - adapter-broken: more than half of one Site's Series hold a no-chapter
|
||||
// failure row older than the window (AC6). Strictly above half: the
|
||||
// filter is the tool, and one Site change makes hundreds of rows, so a
|
||||
// single failing Series never fires (AC7). The episode's age is the
|
||||
// window — the old rows prove the episode is at least that old.
|
||||
func FaultsFrom(in FaultInput, now time.Time) []Fault {
|
||||
var faults []Fault
|
||||
for _, p := range in.Passes {
|
||||
@@ -55,9 +105,56 @@ func FaultsFrom(in FaultInput, now time.Time) []Fault {
|
||||
faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt})
|
||||
}
|
||||
}
|
||||
cutoff := now.Add(-OwnerWindow).UnixMilli()
|
||||
for site, since := range in.RefusingSince {
|
||||
if since < cutoff && !isBrowserSite(site) {
|
||||
faults = append(faults, Fault{Condition: ConditionNoBrowserRoute, Site: site, Since: since})
|
||||
}
|
||||
}
|
||||
if since, down := sidecarDownSince(in.Passes, in.SidecarOK, cutoff); down {
|
||||
faults = append(faults, Fault{Condition: ConditionSidecarDown, Site: "", Since: since})
|
||||
}
|
||||
for site, share := range in.NoChapterShare {
|
||||
if share > 0.5 {
|
||||
faults = append(faults, Fault{Condition: ConditionAdapterBroken, Site: site, Since: now.Add(-OwnerWindow).UnixMilli()})
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
// sidecarDownSince reports whether no browser Lane has reached the sidecar
|
||||
// for longer than the owner window and, when it has, the last moment any
|
||||
// Lane reached it. The skip clause — every browser-backed Site's latest pass
|
||||
// must be SkipSidecarDown or SkipNoFetcher — keeps SkipAsleep out (see
|
||||
// FaultsFrom). A Site with no pass row at all is not judged down either: a
|
||||
// fresh database is not a dead sidecar.
|
||||
func sidecarDownSince(passes []store.LanePass, ok map[string]int64, cutoff int64) (since int64, down bool) {
|
||||
latest := make(map[string]store.LanePass, len(passes))
|
||||
for _, p := range passes {
|
||||
latest[p.Site] = p
|
||||
}
|
||||
for _, site := range browserBackedSites() {
|
||||
p, found := latest[site]
|
||||
if !found || (p.Skip != SkipSidecarDown && p.Skip != SkipNoFetcher) {
|
||||
return 0, false
|
||||
}
|
||||
reached := ok[site]
|
||||
if reached > 0 && reached >= cutoff {
|
||||
return 0, false
|
||||
}
|
||||
if reached > since {
|
||||
since = reached
|
||||
}
|
||||
}
|
||||
// No Lane's retained pass log shows a sidecar reach: the honest age is
|
||||
// the window itself — "at least twelve hours" — not the epoch, which
|
||||
// humanAge would render as tens of thousands of days.
|
||||
if since == 0 {
|
||||
since = cutoff
|
||||
}
|
||||
return since, true
|
||||
}
|
||||
|
||||
// Notifier delivers one owner notice. The poller neither retries nor queues:
|
||||
// an error is logged and the suppression row left unwritten, so the next pass
|
||||
// tries again while the condition holds.
|
||||
@@ -69,7 +166,7 @@ type Notifier interface {
|
||||
// clear loop in recordPass: a condition absent from a pass's fault list
|
||||
// forgets its episode, so the next occurrence sends again. #172 extends the
|
||||
// list when it adds its conditions.
|
||||
var ownerNoticeConditions = []string{ConditionStall}
|
||||
var ownerNoticeConditions = []string{ConditionStall, ConditionNoBrowserRoute, ConditionSidecarDown, ConditionAdapterBroken}
|
||||
|
||||
// noticeFor renders one fault's message: the description sentence — condition,
|
||||
// age, repair — and the deep link the embed's title points at. Each condition
|
||||
@@ -80,6 +177,18 @@ func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href strin
|
||||
return fmt.Sprintf(
|
||||
"%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state",
|
||||
f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionNoBrowserRoute:
|
||||
return fmt.Sprintf(
|
||||
"%s has refused for %s with no browser route — the challenge does not clear on plain TLS; redeploy or add a browser route",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionSidecarDown:
|
||||
return fmt.Sprintf(
|
||||
"no browser Lane has reached the sidecar for %s — the browser sidecar is down; start or repair the browser machine",
|
||||
humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionAdapterBroken:
|
||||
return fmt.Sprintf(
|
||||
"more than half of %s's Series have failed no-chapter reads for at least %s — the Site's layout changed and the adapter is broken",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
@@ -568,18 +568,50 @@ func (p *Poller) recordPass(ctx context.Context, rec passRecord, fig passFigures
|
||||
|
||||
// ownerNotices judges the owner-notice conditions for the pass just recorded
|
||||
// and fires (issue #171). It sits in recordPass because that deferred call is
|
||||
// the one place every return path passes through: two of the four conditions
|
||||
// occur on early returns and the success path can never see them. Per fault:
|
||||
// NoticeSent → send → MarkNoticeSent, so a fault lasting a month sends one
|
||||
// message, not one per pass; a condition absent from this pass's fault list
|
||||
// forgets its episode, so the next occurrence sends again. Everything here is
|
||||
// best-effort: a failed send, a failed store read and a failed notice write
|
||||
// are all logged and never change the pass's outcome counts or its return
|
||||
// value. The clear runs even when Notify is nil, so a deployment that turns
|
||||
// the webhook off does not leave stale rows that suppress the first real
|
||||
// notice after it is turned back on.
|
||||
// the one place every return path passes through: three of the four
|
||||
// conditions occur on early returns and the success path can never see them.
|
||||
// The judgement reads the whole pass log plus three derived reads — the
|
||||
// other Lanes' latest passes, each Site's refusing-run start, its last
|
||||
// sidecar-reaching pass, and its no-chapter share — so one pass judges every
|
||||
// condition (issue #172). Per fault: NoticeSent → send → MarkNoticeSent, so
|
||||
// a fault lasting a month sends one message, not one per pass; a condition
|
||||
// absent from this pass's fault list forgets its episode, so the next
|
||||
// occurrence sends again. Everything here is best-effort: a failed send, a
|
||||
// failed store read and a failed notice write are all logged and never
|
||||
// change the pass's outcome counts or its return value. The clear runs even
|
||||
// when Notify is nil, so a deployment that turns the webhook off does not
|
||||
// leave stale rows that suppress the first real notice after it is turned
|
||||
// back on.
|
||||
func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
|
||||
faults := FaultsFrom(FaultInput{Passes: []store.LanePass{row}}, p.Now())
|
||||
now := p.Now()
|
||||
in := FaultInput{Passes: []store.LanePass{row}}
|
||||
// Each read fails independently: a failure logs and contributes no fault,
|
||||
// never a false one.
|
||||
if passes, err := p.Store.LatestLanePasses(); err != nil {
|
||||
log.Printf("latest poll %s: latest lane passes: %v", row.Site, err)
|
||||
} else {
|
||||
in.Passes = passes
|
||||
}
|
||||
if since, err := p.Store.RefusingSince(now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: refusing since: %v", row.Site, err)
|
||||
} else {
|
||||
in.RefusingSince = since
|
||||
}
|
||||
if ok, err := p.Store.SidecarOK(browserBackedSites()); err != nil {
|
||||
log.Printf("latest poll %s: sidecar ok: %v", row.Site, err)
|
||||
} else {
|
||||
in.SidecarOK = ok
|
||||
}
|
||||
if share, err := p.Store.NoChapterShare(now.Add(-OwnerWindow).UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: no-chapter share: %v", row.Site, err)
|
||||
} else {
|
||||
in.NoChapterShare = share
|
||||
}
|
||||
faults := FaultsFrom(in, now)
|
||||
bySite := make(map[string]store.LanePass, len(in.Passes))
|
||||
for _, pass := range in.Passes {
|
||||
bySite[pass.Site] = pass
|
||||
}
|
||||
for _, f := range faults {
|
||||
if p.Notify == nil {
|
||||
continue
|
||||
@@ -592,14 +624,20 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
|
||||
if sent {
|
||||
continue
|
||||
}
|
||||
sentence, href := noticeFor(f, row, p.Now())
|
||||
// The fault's own Site's pass renders its sentence — a stall judged
|
||||
// from another Lane's pass must not quote this pass's figures.
|
||||
pass, ok := bySite[f.Site]
|
||||
if !ok {
|
||||
pass = row
|
||||
}
|
||||
sentence, href := noticeFor(f, pass, now)
|
||||
if err := p.Notify.Notify(ctx, f, sentence, href); err != nil {
|
||||
// The stamp stays unset: no queue, no backoff — the condition is
|
||||
// durable, so the next pass tries again while it holds.
|
||||
log.Printf("latest poll %s: owner notice %s: %v", row.Site, f.Condition, err)
|
||||
continue
|
||||
}
|
||||
if err := p.Store.MarkNoticeSent(f.Condition, f.Site, p.Now().UnixMilli()); err != nil {
|
||||
if err := p.Store.MarkNoticeSent(f.Condition, f.Site, now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: mark notice sent: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
@@ -610,6 +648,14 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// sidecar-down suppresses under the empty Site — one row across all
|
||||
// browser Lanes (AC4) — so clear that row when it is absent from this
|
||||
// pass's fault list, and a lifted sidecar fires again when it returns.
|
||||
if !hasFault(faults, ConditionSidecarDown, "") {
|
||||
if err := p.Store.ClearNotice(ConditionSidecarDown, ""); err != nil {
|
||||
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasFault reports whether faults hold the given condition for the site.
|
||||
|
||||
@@ -3181,6 +3181,437 @@ func TestFaultsFromStall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The #172 conditions are judged from the durable inputs alone, like the
|
||||
// stall. A refusal is only a fault when the Site has no browser route to
|
||||
// clear it (AC2's "browser Site" exclusions), and only once it is older than
|
||||
// the owner window.
|
||||
func TestFaultsFromNoBrowserRoute(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
old := now.Add(-2 * OwnerWindow).UnixMilli() // a two-day refusal
|
||||
fresh := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window
|
||||
tests := []struct {
|
||||
name string
|
||||
in FaultInput
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "plain-TLS Sites refusing for two days are faults",
|
||||
in: FaultInput{RefusingSince: map[string]int64{"asura": old, "demonic": now.Add(-3 * OwnerWindow).UnixMilli()}},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "a browser-backed Site's refusal is a route it has, not a fault",
|
||||
in: FaultInput{RefusingSince: map[string]int64{"comix": old, "kagane": old, "novelfull": old}},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "a fresh refusal is not old enough",
|
||||
in: FaultInput{RefusingSince: map[string]int64{"asura": fresh}},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "no refusal is no fault",
|
||||
in: FaultInput{},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
faults := FaultsFrom(tt.in, now)
|
||||
if got := len(faults); got != tt.want {
|
||||
t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want)
|
||||
}
|
||||
for _, f := range faults {
|
||||
if f.Condition != ConditionNoBrowserRoute || f.Site == "" || f.Since != tt.in.RefusingSince[f.Site] {
|
||||
t.Fatalf("fault = %+v, want no-browser-route on the refusing Site since its run began", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sidecar-down is one site-wide fault: every browser Lane's latest pass must
|
||||
// be a sidecar skip (SkipSidecarDown or SkipNoFetcher — the skip clause keeps
|
||||
// an asleep Lane out) and each Lane's most recent sidecar-reaching pass must
|
||||
// be older than the window. The fault's Since is the last moment any Lane
|
||||
// reached the sidecar.
|
||||
func TestFaultsFromSidecarDown(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
oldReach := now.Add(-2 * OwnerWindow).UnixMilli()
|
||||
skip := func(site string) store.LanePass {
|
||||
return store.LanePass{Site: site, RanAt: oldReach, Skip: SkipSidecarDown}
|
||||
}
|
||||
allSkipped := []store.LanePass{skip("comix"), skip("kagane"), skip("novelfull")}
|
||||
reached := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window
|
||||
tests := []struct {
|
||||
name string
|
||||
in FaultInput
|
||||
want int
|
||||
wantSince int64
|
||||
}{
|
||||
{
|
||||
name: "every browser Lane sidecar-skipped past the window is one fault",
|
||||
in: FaultInput{Passes: allSkipped, SidecarOK: map[string]int64{"comix": oldReach}},
|
||||
want: 1,
|
||||
wantSince: oldReach,
|
||||
},
|
||||
{
|
||||
name: "a browser Lane asleep for two days sends nothing",
|
||||
in: FaultInput{
|
||||
Passes: []store.LanePass{
|
||||
skip("comix"),
|
||||
{Site: "kagane", RanAt: oldReach, Skip: SkipAsleep},
|
||||
skip("novelfull"),
|
||||
},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "a Lane that reached the sidecar inside the window is not down",
|
||||
in: FaultInput{
|
||||
Passes: allSkipped,
|
||||
SidecarOK: map[string]int64{"comix": reached, "kagane": reached, "novelfull": reached},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "a browser Site with no pass row is not judged down",
|
||||
in: FaultInput{
|
||||
Passes: []store.LanePass{skip("comix"), skip("kagane")},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
faults := FaultsFrom(tt.in, now)
|
||||
if got := len(faults); got != tt.want {
|
||||
t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want)
|
||||
}
|
||||
for _, f := range faults {
|
||||
if f.Condition != ConditionSidecarDown || f.Site != "" || f.Since != tt.wantSince {
|
||||
t.Fatalf("fault = %+v, want one site-wide sidecar-down since %d", f, tt.wantSince)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adapter-broken fires strictly above half: a float share judges a
|
||||
// four-Series Site and a 200-Series Site on the same scale, exactly half
|
||||
// stays quiet, and a single failing Series never reaches it.
|
||||
func TestFaultsFromAdapterBroken(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
tests := []struct {
|
||||
name string
|
||||
in FaultInput
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "three of four Series failing is a fault",
|
||||
in: FaultInput{NoChapterShare: map[string]float64{"asura": 3.0 / 4.0}},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "exactly half is not a fault",
|
||||
in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 2.0}},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "one of two hundred is not a fault",
|
||||
in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 200.0}},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
faults := FaultsFrom(tt.in, now)
|
||||
if got := len(faults); got != tt.want {
|
||||
t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want)
|
||||
}
|
||||
for _, f := range faults {
|
||||
if f.Condition != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// seedRefusingRun writes a refusing pass and the gate row that follows it,
|
||||
// so a Site's run of refusing passes begins at start.
|
||||
func seedRefusingRun(t *testing.T, s *store.Store, site string, start time.Time) {
|
||||
t.Helper()
|
||||
for i, row := range []store.LanePass{
|
||||
{Site: site, RanAt: start.UnixMilli(), Skip: "", Refused: 2},
|
||||
{Site: site, RanAt: start.Add(time.Minute).UnixMilli(), Skip: SkipRefusing},
|
||||
} {
|
||||
if err := s.RecordLanePass(row, -1); err != nil {
|
||||
t.Fatalf("seed refusing run %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedSidecarSkips writes one sidecar-skipped pass for every browser-backed
|
||||
// Lane, all at the same time, so the pass log shows a sidecar that has been
|
||||
// unreachable since then.
|
||||
func seedSidecarSkips(t *testing.T, s *store.Store, at time.Time) {
|
||||
t.Helper()
|
||||
for _, site := range browserBackedSites() {
|
||||
if err := s.RecordLanePass(store.LanePass{Site: site, RanAt: at.UnixMilli(), Skip: SkipSidecarDown}, -1); err != nil {
|
||||
t.Fatalf("seed sidecar skip %s: %v", site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedNoChapter writes an old no-chapter failure row for each Series id.
|
||||
func seedNoChapter(t *testing.T, s *store.Store, site string, ids []string, failingSince int64) {
|
||||
t.Helper()
|
||||
for _, id := range ids {
|
||||
if err := s.RecordSeriesFailure(site, id, "no_chapter", failingSince); err != nil {
|
||||
t.Fatalf("seed no-chapter failure %s:%s: %v", site, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no-browser-route fires once while the refusal holds, and again after it
|
||||
// lifts and returns: the suppression row is the whole state, the gate row of
|
||||
// a second refusing pass is the same episode, a healthy pass in between
|
||||
// breaks the run and clears the row, and a later run that has aged past the
|
||||
// window sends again (AC1, AC9).
|
||||
func TestOwnerNoticeNoBrowserRouteFiresOncePerEpisode(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
refusing := &fakeFetcher{status: 403}
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, refusing, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.Notify = notifier
|
||||
|
||||
// A run that began before the window: seed two-day-old refusing rows,
|
||||
// then a fresh pass that refuses again — the run is unbroken and still
|
||||
// old, so the owner is told once.
|
||||
seedRefusingRun(t, s, "asura", now.Add(-2*OwnerWindow))
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after first refusing pass = %d, want 1", got)
|
||||
}
|
||||
if f := notifier.fault(0); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != now.Add(-2*OwnerWindow).UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want no-browser-route on asura since the run began", f)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "asura"); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after first refusing pass = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
|
||||
// A second refusing pass — the refusal gate now returns early — is the
|
||||
// same episode: the gate row continues the run, no second message.
|
||||
now = now.Add(time.Minute)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after gated refusing pass = %d, want 1 (one per episode)", got)
|
||||
}
|
||||
|
||||
// A healthy pass in between breaks the run: the condition lifts and the
|
||||
// suppression row is cleared.
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
p.Fetch = &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after healthy pass = %d, want 1 (a healthy pass sends nothing)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "asura"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
|
||||
}
|
||||
|
||||
// A later run that has aged past the window again is a new episode.
|
||||
runStart := now.Add(2 * OwnerWindow)
|
||||
seedRefusingRun(t, s, "asura", runStart)
|
||||
now = runStart.Add(2 * OwnerWindow)
|
||||
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
p.Fetch = refusing
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 2 {
|
||||
t.Fatalf("notices after the returned refusal = %d, want 2 (a new episode)", got)
|
||||
}
|
||||
if f := notifier.fault(1); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != runStart.UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want no-browser-route on asura since the new run began", f)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser-backed Site's refusal sends nothing (AC2): it has a route, so a
|
||||
// two-day refusal is a browser-side problem, not the no-browser-route fault.
|
||||
func TestOwnerNoticeBrowserRefusalSendsNothing(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
for i := 1; i <= 6; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("comix:b%d", i), fmt.Sprintf("https://comix.to/title/b%d", i), 0)
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.BrowserFetch = &fakeFetcher{status: 403}
|
||||
p.Notify = notifier
|
||||
|
||||
seedRefusingRun(t, s, "comix", now.Add(-2*OwnerWindow))
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 0 {
|
||||
t.Fatalf("notices = %d, want 0 (a browser Site's refusal is not a no-browser-route)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sidecar-down fires once while no Lane reaches the sidecar, and again after
|
||||
// it returns and dies again (AC4, AC9): the suppression row holds the empty
|
||||
// Site, the first Lane to notice writes it and the others stay quiet, a
|
||||
// healthy browser pass clears it, and a later outage is a new episode.
|
||||
func TestOwnerNoticeSidecarDownFiresOncePerEpisode(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.Notify = notifier
|
||||
|
||||
// Seed the pass log so every browser Lane's latest pass is a sidecar
|
||||
// skip older than the window, then a fresh pass that skips too.
|
||||
seedSidecarSkips(t, s, now.Add(-2*OwnerWindow))
|
||||
p.setBrowserDown(now)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after first sidecar-skipped pass = %d, want 1", got)
|
||||
}
|
||||
if f := notifier.fault(0); f.Condition != ConditionSidecarDown || f.Site != "" {
|
||||
t.Fatalf("fault = %+v, want one site-wide sidecar-down", f)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
|
||||
// Another Lane's sidecar-skipped pass is the same episode: still one
|
||||
// message.
|
||||
p.runLanePass(context.Background(), "kagane", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after another Lane's skipped pass = %d, want 1 (one per episode)", got)
|
||||
}
|
||||
|
||||
// A healthy browser pass reaches the sidecar: the condition lifts and the
|
||||
// suppression row is cleared. Five due Series wake the browser, and the
|
||||
// series ids carry the fixture's id prefix so the scoped reader finds its
|
||||
// chapters.
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
for i := 1; i <= 5; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("comix:h%d", i), "https://comix.to/title/n8we-dungeons-and-crayons", 0)
|
||||
}
|
||||
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after healthy pass = %d, want 1 (a healthy pass sends nothing)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
|
||||
}
|
||||
|
||||
// The sidecar dies again: a new episode, told again.
|
||||
now = now.Add(2 * OwnerWindow)
|
||||
p.setBrowserDown(now)
|
||||
seedSidecarSkips(t, s, now.Add(-time.Minute))
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 2 {
|
||||
t.Fatalf("notices after the returned outage = %d, want 2 (a new episode)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser Lane asleep under both wake thresholds sends nothing: it never
|
||||
// reached the sidecar, but its skip is not a sidecar skip, so it cannot age
|
||||
// into a false alarm (AC9).
|
||||
func TestOwnerNoticeSidecarDownAsleepSendsNothing(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.Notify = notifier
|
||||
|
||||
seedSidecarSkips(t, s, now.Add(-2*OwnerWindow))
|
||||
if err := s.RecordLanePass(store.LanePass{Site: "kagane", RanAt: now.Add(-2*OwnerWindow + time.Minute).UnixMilli(), Skip: SkipAsleep}, -1); err != nil {
|
||||
t.Fatalf("seed asleep pass: %v", err)
|
||||
}
|
||||
p.setBrowserDown(now)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 0 {
|
||||
t.Fatalf("notices = %d, want 0 (an asleep Lane is not a down sidecar)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || sent {
|
||||
t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// adapter-broken fires once while more than half of a Site's Series hold an
|
||||
// old no-chapter failure row, and again after the failures clear and return:
|
||||
// the suppression row is the whole state, a pass that clears the failures
|
||||
// forgets the episode, and a later return sends again. The share is judged
|
||||
// from durable rows, so the pass itself may be healthy (AC6, AC9).
|
||||
func TestOwnerNoticeAdapterBrokenFiresOncePerEpisode(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000_000)
|
||||
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), now.UnixMilli())
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.Notify = notifier
|
||||
|
||||
// Three of four Series hold an old no-chapter row: the first pass fires
|
||||
// one adapter-broken fault.
|
||||
seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli())
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after first pass = %d, want 1", got)
|
||||
}
|
||||
if f := notifier.fault(0); f.Condition != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
|
||||
// A second pass is the same episode: still one message.
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after second pass = %d, want 1 (one per episode)", got)
|
||||
}
|
||||
|
||||
// The failures clear: the condition lifts and the suppression row is
|
||||
// cleared.
|
||||
for _, id := range []string{"a1", "a2", "a3"} {
|
||||
if err := s.ClearSeriesFailure("asura", id); err != nil {
|
||||
t.Fatalf("clear failure %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after cleared failures = %d, want 1 (a healthy share sends nothing)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after cleared failures = %v, %v; want false, nil (row cleared)", sent, err)
|
||||
}
|
||||
|
||||
// The failures return: a new episode, told again.
|
||||
seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli())
|
||||
now = now.Add(time.Minute)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := notifier.callCount(); got != 2 {
|
||||
t.Fatalf("notices after the returned failures = %d, want 2 (a new episode)", got)
|
||||
}
|
||||
}
|
||||
// The stall fires exactly once while it holds, and again after it lifts and
|
||||
// returns: the suppression row is the whole state, so the second stall-shaped
|
||||
// pass is the same episode, a healthy pass in between clears the row, and the
|
||||
|
||||
@@ -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