#172: three more owner-notice conditions: no-browser-route, sidecar-down, adapter-broken

FaultsFrom judges three more faults at the shared twelve-hour OwnerWindow:
a no-browser-route Site's refusing run (browser-backed Sites excluded), a
sidecar no Lane has reached (one site-wide fault), and a Site where more
than half of Series hold an old no-chapter failure row. recordPass fills
the inputs from three new store reads (RefusingSince, SidecarOK,
NoChapterShare), each failing independently and never failing a pass, and
the clear loop forgets the empty-Site row too so a lifted sidecar-down
fires again on return.
This commit is contained in:
2026-08-23 02:44:45 +07:00
parent 3d746092ab
commit 28fef689ec
5 changed files with 853 additions and 20 deletions
+58 -13
View File
@@ -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)
}
}
@@ -609,6 +647,13 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
}
}
// The site-wide conditions suppress under the empty Site; clear that
// row too, so a lifted sidecar-down fires again when it returns.
if !hasFault(faults, cond, "") {
if err := p.Store.ClearNotice(cond, ""); err != nil {
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
}
}
}
}