Owner notices: the stall, one webhook path from env to Discord (#171)

Rollout note: the owner_notices table starts empty, so the first pass after deploy sends for conditions already true — correct per one-row-per-episode; say so rather than have it reported as a bug. Prod step: create the webhook, set DISCORD_WEBHOOK_URL on the deployment, redeploy — unset is silent by design, and without that step the feature ships dark. Security invariants preserved: the webhook address is a secret in the class of TOKEN_KEY (never logged, never on a config-printing line), and the owner gate is unchanged.
This commit is contained in:
2026-08-23 00:06:39 +07:00
parent 5a32943528
commit 7b22460f5e
13 changed files with 785 additions and 18 deletions
+101
View File
@@ -0,0 +1,101 @@
package latest
import (
"context"
"fmt"
"time"
"bookmarkmanager/backend/internal/store"
)
// ConditionStall is the owner-notice machine word for a Lane that owed Polls,
// made none, and has nothing to say for it. The word is the message's footer
// and its suppression key; it is wire-stable. #172 declares the other three
// words (no-browser-route, sidecar-down, adapter-broken); this ticket
// declares only the stall.
const ConditionStall = "stall"
// OwnerWindow is the class-level staleness boundary every owner-notice
// condition measures against — the same twelve hours the Lanes page's
// "not checked in 12h" filter uses (internal/web/admin.go). Declared here
// once so #172's three conditions and the admin filters share one figure.
const OwnerWindow = 12 * time.Hour
// Fault is one condition the owner is told about, judged from durable rows
// alone. Site is "" for a fault that is not one Site's.
type Fault struct {
Condition string // one of the Condition* words
Site string
Since int64 // unix ms the episode began; the message's age
}
// FaultInput is everything the judgement reads. A struct so #172's three
// conditions can add inputs without changing either caller.
type FaultInput struct {
Passes []store.LanePass
}
// FaultsFrom judges the owner-notice conditions from durable rows alone, so
// the poller and the landing page cannot disagree about what a fault is.
//
// A Lane stalls when its latest pass shows due > 0, none checked, no skip
// 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.
func FaultsFrom(in FaultInput, now time.Time) []Fault {
var faults []Fault
for _, p := range in.Passes {
if p.Due > 0 && p.Checked == 0 && p.Skip == "" && p.Refused == 0 {
faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt})
}
}
return faults
}
// 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.
type Notifier interface {
Notify(ctx context.Context, f Fault, sentence, href string) error
}
// ownerNoticeConditions is every condition this package judges, for the
// 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}
// noticeFor renders one fault's message: the description sentence — condition,
// age, repair — and the deep link the embed's title points at. Each condition
// provides its own wording; the stall is the only one today (issue #171).
func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href string) {
switch f.Condition {
case ConditionStall:
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"
}
return "", ""
}
// humanAge renders a duration the way an owner reads it in a message: minutes
// under an hour, then hours, then days; a stall that was just born reads
// "just now".
func humanAge(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
}
+66 -4
View File
@@ -46,7 +46,10 @@ type Poller struct {
// CoverBytesFetch is optional; it handles plain-TLS sources through the
// same failure-isolated prefetch path.
CoverBytesFetch CoverBytesFetcher
Now func() time.Time // injected so tests can freeze it
// Notify delivers owner notices. Nil disables the whole path (issue #171):
// the poller is not the place a missing webhook becomes an error.
Notify Notifier
Now func() time.Time // injected so tests can freeze it
// eligibleCount reports how many of a Site's Series are eligible for
// polling, defaulting to Store.EligibleSeriesCount. Injected so tests can
// fail the count alone: the eligible query shares the due query's tables,
@@ -357,7 +360,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// carries the previous pass's forward inside recordPass.
fig := passFigures{}
rec := passRecord{site: name, ranAt: now.UnixMilli()}
defer func() { p.recordPass(rec, fig) }()
defer func() { p.recordPass(ctx, rec, fig) }()
// One Lane row read at the top of a pass, serving two gates (issue #139).
// Both stamps outlive our process, so the gates read the durable row
@@ -527,8 +530,9 @@ type passFigures struct {
// pass's due, gap, clamped and checked forward rather than stating zeroes it
// did not measure; the skip column says why it declined, so the zeroes that
// remain (due-query, no-fetcher) read as explanations rather than
// measurements.
func (p *Poller) recordPass(rec passRecord, fig passFigures) {
// measurements. Once the row is durable, the owner-notice judgement runs
// beside it (issue #171).
func (p *Poller) recordPass(ctx context.Context, rec passRecord, fig passFigures) {
row := store.LanePass{
Site: rec.site,
RanAt: rec.ranAt,
@@ -557,7 +561,65 @@ func (p *Poller) recordPass(rec passRecord, fig passFigures) {
}
if err := p.Store.RecordLanePass(row, rec.ranAt-lanePassRetention.Milliseconds()); err != nil {
log.Printf("latest poll %s: record lane pass: %v", rec.site, err)
return
}
p.ownerNotices(ctx, row)
}
// 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.
func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
faults := FaultsFrom(FaultInput{Passes: []store.LanePass{row}}, p.Now())
for _, f := range faults {
if p.Notify == nil {
continue
}
sent, err := p.Store.NoticeSent(f.Condition, f.Site)
if err != nil {
log.Printf("latest poll %s: notice sent: %v", row.Site, err)
continue
}
if sent {
continue
}
sentence, href := noticeFor(f, row, p.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 {
log.Printf("latest poll %s: mark notice sent: %v", row.Site, err)
}
}
for _, cond := range ownerNoticeConditions {
if !hasFault(faults, cond, row.Site) {
if err := p.Store.ClearNotice(cond, row.Site); 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.
func hasFault(faults []Fault, condition, site string) bool {
for _, f := range faults {
if f.Condition == condition && f.Site == site {
return true
}
}
return false
}
// countEligible routes the eligible count through the test seam when one is
+240
View File
@@ -159,6 +159,35 @@ func (f *fakeBytesCoverFetcher) callCount() int {
return len(f.calls)
}
// fakeNotifier records the owner notices the poller sends, standing in for
// the Discord webhook the way fakeFetcher stands in for the network. An err
// set after construction makes every subsequent send fail, for the retry
// test.
type fakeNotifier struct {
mu sync.Mutex
calls []Fault
err error
}
func (f *fakeNotifier) Notify(_ context.Context, fault Fault, _, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, fault)
return f.err
}
func (f *fakeNotifier) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func (f *fakeNotifier) fault(i int) Fault {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[i]
}
// newTestPoller wires a poller with a frozen clock. Rest and gap come from the
// Site registry, so tests seed checked_at relative to the one-hour rest; the
// round entry point (runOnce) runs every Lane back to back with no real
@@ -3092,3 +3121,214 @@ func TestRunOnceSiteCompletedWritesDespiteUnchangedChapter(t *testing.T) {
t.Fatalf("site_completed_at after unchanged-chapter completed poll = %d, want %d", got, at.UnixMilli())
}
}
// The stall judgement (issue #171) selects exactly the row the mid-loop
// browser loss writes — due > 0, none checked, no skip value, and no refusal.
// 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).
func TestFaultsFromStall(t *testing.T) {
now := time.UnixMilli(5_000_000)
tests := []struct {
name string
pass store.LanePass
want int
}{
{
name: "stall-shaped pass is a stall",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 2, Checked: 0, Skip: "", Refused: 0},
want: 1,
},
{
name: "nothing due is not a stall",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 0, Checked: 0, Skip: "", Refused: 0},
want: 0,
},
{
name: "a pass that checked is not a stall",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 1, Skip: "", Refused: 0},
want: 0,
},
{
name: "paused is the owner's own act, not a stall",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: SkipPaused, Refused: 0},
want: 0,
},
{
name: "refusing has a skip value, not a stall",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: SkipRefusing, Refused: 0},
want: 0,
},
{
name: "stalled and refused fires nothing (AC6's collision)",
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 2, Checked: 0, Skip: "", Refused: 1},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
faults := FaultsFrom(FaultInput{Passes: []store.LanePass{tt.pass}}, 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 != ConditionStall || f.Site != "comix" || f.Since != now.UnixMilli() {
t.Fatalf("fault = %+v, want stall on comix since the pass time", f)
}
}
})
}
}
// 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
// next stall is a new episode that sends again.
func TestOwnerNoticeStallFiresOncePerEpisode(t *testing.T) {
now := time.UnixMilli(5_000_000)
s, _ := newTestStore(t)
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
p.Notify = notifier
// First stall-shaped pass: the episode begins, the owner is told once.
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after first stall = %d, want 1", got)
}
if f := notifier.fault(0); f.Condition != ConditionStall || f.Site != "comix" || f.Since != now.UnixMilli() {
t.Fatalf("fault = %+v, want stall on comix since the pass time", f)
}
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || !sent {
t.Fatalf("NoticeSent after first stall = %v, %v; want true, nil", sent, err)
}
// A second stall-shaped pass — the series was stamped, so re-seed it — is
// the same episode: no second message.
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
now = now.Add(RefuseBackoff + time.Minute)
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after second stall = %d, want 1 (one per episode)", got)
}
// A healthy pass in between lifts the stall: the episode ends and the
// suppression row is cleared.
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
now = now.Add(RefuseBackoff + time.Minute)
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(ConditionStall, "comix"); err != nil || sent {
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
}
// The next stall is a new episode: the owner is told again.
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
now = now.Add(RefuseBackoff + time.Minute)
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 2 {
t.Fatalf("notices after the next stall = %d, want 2 (again after it lifts and returns)", got)
}
}
// A paused Lane sends nothing: the pause is the owner's own act, and the
// skip value already keeps it out of the stall test (AC10).
func TestOwnerNoticePausedSendsNothing(t *testing.T) {
now := time.UnixMilli(5_000_000)
s, _ := newTestStore(t)
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
if err := s.PauseLane("comix", now.Add(30*time.Minute).UnixMilli()); err != nil {
t.Fatalf("PauseLane: %v", err)
}
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.BrowserFetch = &fakeFetcher{status: 200}
p.Notify = notifier
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 0 {
t.Fatalf("notices while paused = %d, want 0", got)
}
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
t.Fatalf("NoticeSent while paused = %v, %v; want false, nil", sent, err)
}
}
// A failed POST is logged and the notified stamp left unset, so the next pass
// retries while the condition holds. No queue, no backoff — the condition is
// durable, and the suppression row is the only state.
func TestOwnerNoticeFailedSendRetriesNextPass(t *testing.T) {
now := time.UnixMilli(5_000_000)
s, _ := newTestStore(t)
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
notifier := &fakeNotifier{err: errors.New("webhook down")}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
p.Notify = notifier
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("send attempts = %d, want 1", got)
}
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
t.Fatalf("NoticeSent after failed send = %v, %v; want false, nil (stamp left unset)", sent, err)
}
// The webhook recovers: the next stall-shaped pass delivers the one
// message the episode was owed.
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
notifier.err = nil
now = now.Add(RefuseBackoff + time.Minute)
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 2 {
t.Fatalf("send attempts after recovery = %d, want 2 (retried next pass)", got)
}
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || !sent {
t.Fatalf("NoticeSent after recovery = %v, %v; want true, nil", sent, err)
}
}
// Nil notifier means the whole path is off: a stall-shaped pass panics
// nothing and stamps no row, yet the clear still runs, 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 TestOwnerNoticeNilNotifierStillClears(t *testing.T) {
now := time.UnixMilli(5_000_000)
s, _ := newTestStore(t)
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
// A stall-shaped pass with no notifier configured: nothing panics and no
// row is stamped — a missing webhook is a silent, complete off switch.
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
p.runLanePass(context.Background(), "comix", false)
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
t.Fatalf("NoticeSent with nil notifier = %v, %v; want false, nil", sent, err)
}
// A stale row from before the webhook was turned off must not survive a
// healthy pass: the clear runs even though no notifier is configured.
if err := s.MarkNoticeSent(ConditionStall, "comix", now.UnixMilli()); err != nil {
t.Fatalf("seed notice row: %v", err)
}
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
now = now.Add(RefuseBackoff + time.Minute)
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
p.runLanePass(context.Background(), "comix", false)
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
t.Fatalf("NoticeSent after healthy pass with nil notifier = %v, %v; want false, nil (cleared)", sent, err)
}
}