Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #174.
This commit is contained in:
+195
-13
@@ -84,6 +84,10 @@ type Series struct {
|
||||
LatestChapter string
|
||||
LatestChapterNum *float64 // nil until first captured
|
||||
LatestCheckedAt int64 // unix ms; see MarkLatestChecked
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's
|
||||
// own completed value, zero meaning it did not (issue #168). A poller
|
||||
// fact, not reading progress: Upsert never touches it.
|
||||
SiteCompletedAt int64
|
||||
// LatestRaisedBy is the Reader whose Sighting last raised LatestChapter,
|
||||
// and nil when the stored value is a Poll's own finding. It is what lets a
|
||||
// Poll that contradicts the value downwards name a Reader instead of
|
||||
@@ -110,7 +114,7 @@ type LanePass struct {
|
||||
GapMS int64
|
||||
Clamped bool
|
||||
Refused, Unreachable, NoChapter int
|
||||
Unfetchable, Errors int
|
||||
Unfetchable, Errors, NotFound int
|
||||
PausedUntil, RefuseUntil int64
|
||||
}
|
||||
|
||||
@@ -119,7 +123,7 @@ type LanePass struct {
|
||||
type SiteOutcomes struct {
|
||||
Site string
|
||||
Refused, Unreachable, NoChapter int
|
||||
Unfetchable, Errors int
|
||||
Unfetchable, Errors, NotFound int
|
||||
}
|
||||
|
||||
// LanePause is one persisted Lane pause stamp.
|
||||
@@ -240,10 +244,11 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add
|
||||
// due query. latest_checked_at lives only on series — see MarkLatestChecked
|
||||
// for why it stays off every client-visible write.
|
||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by,
|
||||
s.site_completed_at`
|
||||
|
||||
const lanePassColumns = `p.site, p.ran_at, p.skip, p.due, p.checked, p.gap_ms, p.clamped,
|
||||
p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors,
|
||||
p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors, p.not_found,
|
||||
COALESCE(l.paused_until, 0), COALESCE(l.refuse_until, 0)`
|
||||
|
||||
// Owner is the person running the service: the first Reader, seeded at startup
|
||||
@@ -646,7 +651,7 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
||||
if err := scan(
|
||||
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
||||
&sr.Forced, &sr.readerCount,
|
||||
&sr.SiteCompletedAt, &sr.Forced, &sr.readerCount,
|
||||
); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
@@ -663,7 +668,7 @@ func scanLanePass(scan func(...any) error) (LanePass, error) {
|
||||
var p LanePass
|
||||
if err := scan(
|
||||
&p.Site, &p.RanAt, &p.Skip, &p.Due, &p.Checked, &p.GapMS, &p.Clamped,
|
||||
&p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors,
|
||||
&p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors, &p.NotFound,
|
||||
&p.PausedUntil, &p.RefuseUntil,
|
||||
); err != nil {
|
||||
return LanePass{}, err
|
||||
@@ -1131,10 +1136,10 @@ func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO poll_passes
|
||||
(site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||
refused, unreachable, no_chapter, unfetchable, errors)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
refused, unreachable, no_chapter, unfetchable, errors, not_found)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
p.Site, p.RanAt, p.Skip, p.Due, p.Checked, p.GapMS, p.Clamped,
|
||||
p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors); err != nil {
|
||||
p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors, p.NotFound); err != nil {
|
||||
return fmt.Errorf("insert lane pass %s at %d: %w", p.Site, p.RanAt, err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM poll_passes WHERE ran_at < $1`, retainBefore); err != nil {
|
||||
@@ -1171,7 +1176,7 @@ func (s *Store) LatestLanePasses() ([]LanePass, error) {
|
||||
FROM (
|
||||
SELECT DISTINCT ON (site)
|
||||
site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||
refused, unreachable, no_chapter, unfetchable, errors
|
||||
refused, unreachable, no_chapter, unfetchable, errors, not_found
|
||||
FROM poll_passes
|
||||
ORDER BY site, ran_at DESC
|
||||
) p
|
||||
@@ -1198,7 +1203,7 @@ func (s *Store) LatestLanePasses() ([]LanePass, error) {
|
||||
func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT site, SUM(refused), SUM(unreachable), SUM(no_chapter),
|
||||
SUM(unfetchable), SUM(errors)
|
||||
SUM(unfetchable), SUM(errors), SUM(not_found)
|
||||
FROM poll_passes
|
||||
WHERE ran_at >= $1
|
||||
GROUP BY site
|
||||
@@ -1213,7 +1218,7 @@ func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||
var outcomes SiteOutcomes
|
||||
if err := rows.Scan(
|
||||
&outcomes.Site, &outcomes.Refused, &outcomes.Unreachable,
|
||||
&outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors,
|
||||
&outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors, &outcomes.NotFound,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lane pass outcomes: %w", err)
|
||||
}
|
||||
@@ -1222,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 {
|
||||
@@ -1298,6 +1398,72 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
return pausedUntil, refuseUntil, nil
|
||||
}
|
||||
|
||||
// RecordSeriesFailure upserts one Series' failure row: the outcome word is
|
||||
// updated when it changes, failing_since is never overwritten, and an
|
||||
// unchanged word writes nothing. One statement, so the identical-word no-op
|
||||
// and the keep-the-stamp rule are the same guarantee: the SET arm fires only
|
||||
// when the word differs, and failing_since is simply absent from it
|
||||
// (ADR-0016).
|
||||
func (s *Store) RecordSeriesFailure(site, seriesID, outcome string, now int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO poll_failures (site, series_id, outcome, failing_since) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET outcome = EXCLUDED.outcome
|
||||
WHERE poll_failures.outcome <> EXCLUDED.outcome`, site, seriesID, outcome, now); err != nil {
|
||||
return fmt.Errorf("record series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSeriesFailure deletes one Series' failure row. A row that is not there
|
||||
// is not an error.
|
||||
func (s *Store) ClearSeriesFailure(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM poll_failures WHERE site = $1 AND series_id = $2`, site, seriesID); err != nil {
|
||||
return fmt.Errorf("clear series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NoticeSent reports whether the owner has already been told about this
|
||||
// episode (issue #171). The row's presence is the whole state, so a missing
|
||||
// row reads false without error.
|
||||
func (s *Store) NoticeSent(condition, site string) (bool, error) {
|
||||
var at int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT notified_at FROM owner_notices WHERE condition = $1 AND site = $2`,
|
||||
condition, site).Scan(&at)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notice sent %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MarkNoticeSent records that the owner was told. Called only after a
|
||||
// successful send. Re-marking the same episode just moves the stamp: the row
|
||||
// is a lock against a second message, not a log.
|
||||
func (s *Store) MarkNoticeSent(condition, site string, at int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO owner_notices (condition, site, notified_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (condition, site) DO UPDATE SET notified_at = EXCLUDED.notified_at`,
|
||||
condition, site, at); err != nil {
|
||||
return fmt.Errorf("mark notice sent %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearNotice forgets an episode, so the condition's next occurrence sends
|
||||
// again. A row that is not there is not an error.
|
||||
func (s *Store) ClearNotice(condition, site string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM owner_notices WHERE condition = $1 AND site = $2`, condition, site); err != nil {
|
||||
return fmt.Errorf("clear notice %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueForLatestCheck returns one Site's series whose server-side
|
||||
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
|
||||
// reference them (descending) then least-recently-checked first. One Site per
|
||||
@@ -1356,7 +1522,7 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at, s.finished_at
|
||||
s.site_completed_at, s.force_poll_at, s.finished_at
|
||||
HAVING (COUNT(*) > 1
|
||||
OR s.latest_sighted_at <= $2::bigint
|
||||
OR s.latest_checked_at <= $3::bigint
|
||||
@@ -1491,6 +1657,22 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSiteCompletedAt stores when the last successful read saw the Site's
|
||||
// completed value, zero meaning it did not. Series-level, like the Latest
|
||||
// Chapter: it is a fact about the work, not about one Reader's bookmark, and
|
||||
// the bookmark's updated_at is never touched — this is not reading progress
|
||||
// and must not reorder any Reader's list, the same rule SetLatestChapter's
|
||||
// comment states. Touching a missing series is not an error: the row may
|
||||
// have been orphaned, and the caller's read decides what exists.
|
||||
func (s *Store) SetSiteCompletedAt(site, seriesID string, at int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET site_completed_at = $1 WHERE site = $2 AND series_id = $3`,
|
||||
at, site, seriesID); err != nil {
|
||||
return fmt.Errorf("set site completed %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSeriesURL stores the owner's repair for a Series' source address
|
||||
// (issue #151): the one write that lifts the write-once rule documented on
|
||||
// Series.SeriesURL. It is a store, not a verification — the caller has
|
||||
|
||||
Reference in New Issue
Block a user