Merge #169: The pass remembers when a Site called a work completed

# Conflicts:
#	backend/internal/latest/poller_test.go
This commit is contained in:
2026-08-22 23:27:29 +07:00
5 changed files with 217 additions and 10 deletions
@@ -0,0 +1,4 @@
-- When the last successful Poll read saw the Site's own completed value
-- (#168): epoch-ms, zero meaning it did not. DEFAULT 0 keeps pre-existing
-- rows readable.
ALTER TABLE series ADD COLUMN site_completed_at bigint NOT NULL DEFAULT 0;
+24 -3
View File
@@ -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
@@ -240,7 +244,8 @@ 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.not_found,
@@ -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
}
@@ -1382,7 +1387,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
@@ -1517,6 +1522,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