feat(web,store,latest): forced poll — Check now stamps a Series for the next Lane pass (#146)

The control writes series.force_poll_at (column landed in migration 0014)
and never commands the poller: pending is derived as force_poll_at >
latest_checked_at and self-clears because the check stamp is written before
the fetch. The due query's forced flag overrides the rest cutoff, the
Sighting-deferral and finished-only clauses, jumps the queue, and wakes a
sleeping browser Lane; it never overrides an empty series_url, the Bookmarks
join, the refusal backoff, the sidecar-down skip or the Lane gap.

ADRs: 0013-commands-through-the-database.
This commit is contained in:
2026-08-22 00:53:51 +07:00
parent 6f9109c885
commit fe2cd12049
14 changed files with 818 additions and 44 deletions
+48 -9
View File
@@ -88,6 +88,11 @@ type Series struct {
// readerCount is the number of bookmarks referencing this series, filled
// only by the due-queue query that orders on it.
readerCount int
// Forced is whether the owner asked for a check now (issue #146): the
// request stamp is newer than the check stamp. Derived in the due query,
// never stored, and the flag that jumps the queue and opens the browser
// wake gate.
Forced bool
}
// LanePass is one Poll Lane's durable pass snapshot. Pause and refusal stamps
@@ -621,8 +626,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
}
// scanSeries reads one row in seriesColumns order, plus the due query's
// reader_count column. latest_chapter_num and latest_raised_by are both
// nullable, same as latest_chapter_num on the bookmark read path.
// forced flag and reader_count columns. latest_chapter_num and
// latest_raised_by are both nullable, same as latest_chapter_num on the
// bookmark read path.
func scanSeries(scan func(...any) error) (Series, error) {
var (
sr Series
@@ -632,7 +638,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.readerCount,
&sr.Forced, &sr.readerCount,
); err != nil {
return Series{}, err
}
@@ -1164,6 +1170,13 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// limit — the Lane's own gap paces the fetches, and the batch size that used
// to cap this query is gone with the shared pace.
//
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
// clause and the finished-only bucket. It never overrides an empty
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
// the result — so those stay unconditional. Forced rows sort to the front of
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
//
// The reader_count ordering is the point of the split (ADR-0003): a series
// shared by several readers is fetched once per due cycle, and the popular
// ones stay freshest while the long tail absorbs any shortfall. Within one
@@ -1192,19 +1205,26 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// allowed to defer at all was settled when the Sighting was recorded — see
// RecordSighting.
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
(s.force_poll_at > s.latest_checked_at) AS forced,
COUNT(*) AS reader_count
FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.site = $1
AND s.series_url <> ''
AND s.latest_checked_at <= $2::bigint
AND (s.latest_checked_at <= $2::bigint
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
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
OR s.force_poll_at > s.latest_checked_at)
AND (COUNT(*) > 1
OR s.latest_sighted_at <= $2::bigint
OR s.latest_checked_at <= $3::bigint)
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
OR s.latest_checked_at <= $3::bigint
OR s.force_poll_at > s.latest_checked_at)
ORDER BY (s.force_poll_at > s.latest_checked_at) DESC,
reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
if err != nil {
return nil, fmt.Errorf("query due series: %w", err)
}
@@ -1264,6 +1284,25 @@ func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error {
return nil
}
// ForceSeriesPoll stamps a Series with the owner's "check now" request
// (issue #146): a fact about the Series the Lane's next pass reads through
// DueForLatestCheck, never a command to the poller — so the request survives
// a restart. Writing again overwrites the request time; the write is
// idempotent. Touching a missing series is not an error: the row may have
// been orphaned, and the caller's read decides what exists. The stamp never
// expires by itself — an unanswered request keeps ageing — and pending is
// derived as force_poll_at > latest_checked_at, which is why the poller's
// check stamp is written before the fetch: the first attempt ends the
// pending state whatever it returns.
func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
if _, err := s.db.Exec(
`UPDATE series SET force_poll_at = $1 WHERE site = $2 AND series_id = $3`,
at, site, seriesID); err != nil {
return fmt.Errorf("force poll %s:%s: %w", site, seriesID, err)
}
return nil
}
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on rest
// bookkeeping) — see MarkLatestChecked for why the field stays off the