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:
@@ -347,11 +347,13 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
fig.Due = len(due)
|
fig.Due = len(due)
|
||||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) && !anyForced(due) {
|
||||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||||
// browser): waking it for a single Poll would cost a challenge solve
|
// browser): waking it for a single Poll would cost a challenge solve
|
||||||
// per request. The Lane still paces at the default gap, which is what
|
// per request. A forced Series is the one exception — a human asking
|
||||||
// the owner's page must show rather than a zero.
|
// is not the machine waking itself (issue #146). The Lane still paces
|
||||||
|
// at the default gap, which is what the owner's page must show rather
|
||||||
|
// than a zero.
|
||||||
rec.skip = SkipAsleep
|
rec.skip = SkipAsleep
|
||||||
fig.Gap = defaultGap
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
@@ -539,6 +541,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool
|
|||||||
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anyForced reports whether the due list holds a forced Series: one whose
|
||||||
|
// owner check-now request (issue #146) has not been answered yet. A human
|
||||||
|
// asking wakes a sleeping Chrome even below the wake thresholds; the request
|
||||||
|
// itself still ages visibly if the home machine is off.
|
||||||
|
func anyForced(due []store.Series) bool {
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Forced {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
||||||
// waiting past its due moment (0 when due is empty).
|
// waiting past its due moment (0 when due is empty).
|
||||||
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
||||||
|
|||||||
@@ -2098,3 +2098,141 @@ func TestPassRetentionCutoffIsFourteenDays(t *testing.T) {
|
|||||||
t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n)
|
t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stampRecordingFetcher is a fakeFetcher that records the Series' check stamp
|
||||||
|
// at call time — the seam that proves the check stamp is written before the
|
||||||
|
// fetch (issue #146). If the order were swapped, the recorded stamp would be
|
||||||
|
// the pre-pass value and the ordering assertion would fail.
|
||||||
|
type stampRecordingFetcher struct {
|
||||||
|
fakeFetcher
|
||||||
|
store *store.Store
|
||||||
|
site string
|
||||||
|
seriesID string
|
||||||
|
stampAtCall int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *stampRecordingFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
||||||
|
ts, err := f.store.LatestCheckedAt(f.site, f.seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
f.stampAtCall = ts
|
||||||
|
return f.fakeFetcher.Get(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check stamp is written before the fetch is attempted: the fake fetcher
|
||||||
|
// records the stamp it sees at call time, and it must already be the pass's
|
||||||
|
// own stamp. The order is load-bearing — a forced request is pending while
|
||||||
|
// force_poll_at > latest_checked_at, so stamping after the fetch would make a
|
||||||
|
// failed forced request sticky — and the assertion fails if it is swapped.
|
||||||
|
func TestCheckStampIsWrittenBeforeFetch(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
const (
|
||||||
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
)
|
||||||
|
seedForCheck(t, s, key, seriesURL, 0)
|
||||||
|
now := time.UnixMilli(7_000_000)
|
||||||
|
rec := &stampRecordingFetcher{
|
||||||
|
fakeFetcher: fakeFetcher{body: asuraSeriesFixture, status: 200},
|
||||||
|
store: s, site: "asura", seriesID: "chronicles-of-the-demon-faction-f886a8af",
|
||||||
|
}
|
||||||
|
newTestPoller(t, s, rec, now).runOnce(context.Background())
|
||||||
|
if rec.stampAtCall != now.UnixMilli() {
|
||||||
|
t.Fatalf("check stamp at fetch time = %d, want %d (the stamp must be written before the fetch)", rec.stampAtCall, now.UnixMilli())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series whose attempt fails is no longer pending: the check stamp
|
||||||
|
// is written before the fetch, so the first attempt ends the pending state
|
||||||
|
// whatever it returns. A naive implementation (stamp only on success, or
|
||||||
|
// after the fetch) leaves the request sticky and the row due next pass.
|
||||||
|
func TestForcedSeriesSelfClearsOnFailedAttempt(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(7_000_000)
|
||||||
|
const url = "https://asurascans.com/comics/x"
|
||||||
|
// Freshly checked, so only the force flag makes it due.
|
||||||
|
seedForCheck(t, s, "asura:x", url, now.Add(-30*time.Minute).UnixMilli())
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:x" || !due[0].Forced {
|
||||||
|
t.Fatalf("forced row not due and flagged before the attempt: %+v", due)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attempt fails, but the attempt still happened: the row is stamped
|
||||||
|
// and no longer pending.
|
||||||
|
f := &fakeFetcher{err: errors.New("dial tcp: refused")}
|
||||||
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() {
|
||||||
|
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("failed attempt left the forced row due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refusal backoff is a Lane gate, not a Series gate: a forced Series does
|
||||||
|
// not override a Site that is actively refusing, because hand-forcing a
|
||||||
|
// request into a refusal only makes it worse (issue #146).
|
||||||
|
func TestForcedSeriesDoesNotOverrideRefusalBackoff(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
seedForCheck(t, s, "kagane:w", "https://kagane.to/series/w", 0)
|
||||||
|
if err := s.ForceSeriesPoll("kagane", "w", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLaneRefusal("kagane", now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
if got := browser.callCount(); got != 0 {
|
||||||
|
t.Fatalf("browser fetches through a refusal backoff = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series wakes a sleeping browser Lane: the wake thresholds exist to
|
||||||
|
// stop the machine waking itself for one unattended check, and a human asking
|
||||||
|
// is not that (issue #146). Below both thresholds the Lane still sleeps when
|
||||||
|
// nothing is forced.
|
||||||
|
func TestForcedSeriesWakesSleepingBrowser(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
// Freshly due: checked two minutes before the rest elapses, so the wait
|
||||||
|
// is far below browserWakeAge and the count is under browserWakeCount.
|
||||||
|
seedForCheck(t, s, "kagane:w1", "https://kagane.to/series/w1", now.Add(-62*time.Minute).UnixMilli())
|
||||||
|
seedForCheck(t, s, "kagane:w2", "https://kagane.to/series/w2", now.Add(-62*time.Minute).UnixMilli())
|
||||||
|
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
if got := browser.callCount(); got != 0 {
|
||||||
|
t.Fatalf("browser fetches without a forced series = %d, want 0 (Chrome stays asleep)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("kagane", "w1", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
if got := browser.callCount(); got != 2 {
|
||||||
|
t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type SeriesFilter struct {
|
|||||||
// must never leave the store package — so the projection does not select it,
|
// must never leave the store package — so the projection does not select it,
|
||||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||||
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at`
|
||||||
|
|
||||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||||
@@ -68,8 +68,12 @@ type AdminSeries struct {
|
|||||||
LatestChapter string
|
LatestChapter string
|
||||||
LatestChapterNum *float64 // nil until first captured
|
LatestChapterNum *float64 // nil until first captured
|
||||||
LatestCheckedAt int64
|
LatestCheckedAt int64
|
||||||
ReaderCount int
|
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
|
||||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
// meaning never asked. Pending is derived, never stored: a request is
|
||||||
|
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||||
|
ForcePollAt int64
|
||||||
|
ReaderCount int
|
||||||
|
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||||
@@ -185,7 +189,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
|||||||
`+where+`
|
`+where+`
|
||||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
s.latest_raised_by
|
s.force_poll_at, s.latest_raised_by
|
||||||
`+having+`
|
`+having+`
|
||||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||||
@@ -260,6 +264,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
|||||||
if err := scan(
|
if err := scan(
|
||||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||||
|
&a.ForcePollAt,
|
||||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return AdminSeries{}, 0, err
|
return AdminSeries{}, 0, err
|
||||||
|
|||||||
@@ -348,3 +348,49 @@ func TestAdminFilterUnknownNameRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceSeriesPoll is the idempotent stamp write: a second press overwrites
|
||||||
|
// the request time, and touching a missing series is not an error.
|
||||||
|
func TestForceSeriesPollStampsIdempotently(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 42); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 99); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll re-stamp: %v", err)
|
||||||
|
}
|
||||||
|
// Touching a missing series is not an error: the row may have been
|
||||||
|
// orphaned, and the caller's read decides what exists.
|
||||||
|
if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("read force_poll_at: %v", err)
|
||||||
|
}
|
||||||
|
if got != 99 {
|
||||||
|
t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin projection carries the force stamp so the web layer can derive
|
||||||
|
// the pending flag without a second read.
|
||||||
|
func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := s.SeriesPage(SeriesFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage: %v", err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 {
|
||||||
|
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ type Series struct {
|
|||||||
// readerCount is the number of bookmarks referencing this series, filled
|
// readerCount is the number of bookmarks referencing this series, filled
|
||||||
// only by the due-queue query that orders on it.
|
// only by the due-queue query that orders on it.
|
||||||
readerCount int
|
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
|
// 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
|
// scanSeries reads one row in seriesColumns order, plus the due query's
|
||||||
// reader_count column. latest_chapter_num and latest_raised_by are both
|
// forced flag and reader_count columns. latest_chapter_num and
|
||||||
// nullable, same as latest_chapter_num on the bookmark read path.
|
// latest_raised_by are both nullable, same as latest_chapter_num on the
|
||||||
|
// bookmark read path.
|
||||||
func scanSeries(scan func(...any) error) (Series, error) {
|
func scanSeries(scan func(...any) error) (Series, error) {
|
||||||
var (
|
var (
|
||||||
sr Series
|
sr Series
|
||||||
@@ -632,7 +638,7 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
|||||||
if err := scan(
|
if err := scan(
|
||||||
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
||||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
||||||
&sr.readerCount,
|
&sr.Forced, &sr.readerCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Series{}, err
|
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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// allowed to defer at all was settled when the Sighting was recorded — see
|
||||||
// RecordSighting.
|
// RecordSighting.
|
||||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
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
|
FROM series s
|
||||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||||
WHERE s.site = $1
|
WHERE s.site = $1
|
||||||
AND s.series_url <> ''
|
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,
|
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.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
s.force_poll_at
|
||||||
|
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||||
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
AND (COUNT(*) > 1
|
AND (COUNT(*) > 1
|
||||||
OR s.latest_sighted_at <= $2::bigint
|
OR s.latest_sighted_at <= $2::bigint
|
||||||
OR s.latest_checked_at <= $3::bigint)
|
OR s.latest_checked_at <= $3::bigint
|
||||||
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query due series: %w", err)
|
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
|
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
|
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||||
// tests outside this package (the poller's own tests assert on rest
|
// tests outside this package (the poller's own tests assert on rest
|
||||||
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
|||||||
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sr, err := scanSeries(s.db.QueryRow(
|
sr, err := scanSeries(s.db.QueryRow(
|
||||||
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
|
`SELECT `+seriesColumns+`, false AS forced, 0 AS reader_count FROM series s
|
||||||
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
||||||
@@ -1748,3 +1748,154 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
|
|||||||
t.Fatalf("lane state rows after resume = %d, want 1", rows)
|
t.Fatalf("lane state rows after resume = %d, want 1", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A forced Series is due ahead of the rest cutoff: the request overrides the
|
||||||
|
// rest gate so the Lane's next pass picks it up however recently it was
|
||||||
|
// checked. An unforced series under the rest stays out.
|
||||||
|
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
const now = int64(10 * 3600_000)
|
||||||
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
|
||||||
|
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:forced" {
|
||||||
|
t.Fatalf("due = %v, want only the forced series", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sighting-deferral clause holds a solitary series back after a recent
|
||||||
|
// Reader report; a forced request overrides it and puts the series back on
|
||||||
|
// the Lane's list.
|
||||||
|
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
const now = int64(10 * 3600_000)
|
||||||
|
// One bookmark (so deferral can apply), sighted and checked 10m ago:
|
||||||
|
// inside the deferral window and under the ceiling.
|
||||||
|
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
|
||||||
|
now-10*60_000); err != nil {
|
||||||
|
t.Fatalf("seed sighting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unforced: deferred, and under the rest anyway.
|
||||||
|
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("unforced deferred series is due: %v", due)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced: the request overrides the deferral.
|
||||||
|
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:deferred" {
|
||||||
|
t.Fatalf("forced deferred series not due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The finished-only bucket excludes a series whose only bookmarks are
|
||||||
|
// finished; a forced request overrides it — the owner asked, so the Lane
|
||||||
|
// looks.
|
||||||
|
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||||
|
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
||||||
|
SeriesURL: "https://asurascans.com/comics/finished",
|
||||||
|
Status: StatusFinished, UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed finished: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Key() == "asura:finished" {
|
||||||
|
t.Fatalf("unforced finished series is due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, sr := range due {
|
||||||
|
got[sr.Key()] = true
|
||||||
|
}
|
||||||
|
if !got["asura:reading"] || !got["asura:finished"] {
|
||||||
|
t.Fatalf("forced finished series not due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series jumps the queue: it sorts ahead of a more-read series that
|
||||||
|
// is due by rest, without disturbing the reader-count-then-age tie-break
|
||||||
|
// among the unforced rows (ADR-0003).
|
||||||
|
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
// "popular" has two readers and is long overdue; "forced" has one reader
|
||||||
|
// and a fresh check stamp. The forced row must come first.
|
||||||
|
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
|
||||||
|
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
|
||||||
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 2 {
|
||||||
|
t.Fatalf("due = %d rows, want 2", len(due))
|
||||||
|
}
|
||||||
|
if due[0].Key() != "asura:forced" || due[1].Key() != "asura:popular" {
|
||||||
|
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series with no series URL is still not fetched — nothing to fetch —
|
||||||
|
// and one with no Bookmarks is still excluded by the join. The force flag
|
||||||
|
// opens the three gates it is allowed to, not the whole query.
|
||||||
|
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:nourl", "", 0)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
||||||
|
latest_chapter, latest_chapter_num, latest_checked_at,
|
||||||
|
force_poll_at)
|
||||||
|
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
||||||
|
'', 'manga', '', NULL, 0, 5000)`); err != nil {
|
||||||
|
t.Fatalf("seed orphan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func (h *Handler) adminRoutes() []adminRoute {
|
|||||||
{"GET /admin/readers", h.adminReaders},
|
{"GET /admin/readers", h.adminReaders},
|
||||||
{"GET /admin/series", h.adminSeries},
|
{"GET /admin/series", h.adminSeries},
|
||||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||||
|
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||||
{"GET /ui/admin/lanes", h.uiLanes},
|
{"GET /ui/admin/lanes", h.uiLanes},
|
||||||
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
||||||
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookmarkmanager/backend/internal/latest"
|
"bookmarkmanager/backend/internal/latest"
|
||||||
@@ -80,6 +81,11 @@ type seriesFilterOption struct {
|
|||||||
// the alternating row tint by class rather than nth-of-type, so the confirm
|
// the alternating row tint by class rather than nth-of-type, so the confirm
|
||||||
// rows later tickets add are row siblings without breaking the alternation.
|
// rows later tickets add are row siblings without breaking the alternation.
|
||||||
// Attention tints the title patina: a row with any hygiene chip needs one.
|
// Attention tints the title patina: a row with any hygiene chip needs one.
|
||||||
|
//
|
||||||
|
// CanPoll is the Check now control's visibility: absent on a Series with no
|
||||||
|
// page to fetch and on an orphan, so the owner is never offered a button that
|
||||||
|
// can never do anything. Pending is derived — the request stamp is newer than
|
||||||
|
// the check stamp — and Requested is its ageing label.
|
||||||
type seriesRowView struct {
|
type seriesRowView struct {
|
||||||
Key string
|
Key string
|
||||||
Title string
|
Title string
|
||||||
@@ -91,6 +97,9 @@ type seriesRowView struct {
|
|||||||
More int // chips past the cap, rendered as a +N tail
|
More int // chips past the cap, rendered as a +N tail
|
||||||
Band bool
|
Band bool
|
||||||
Attention bool
|
Attention bool
|
||||||
|
CanPoll bool
|
||||||
|
Pending bool
|
||||||
|
Requested string // "requested 3m ago", rendered only while pending
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||||
@@ -106,6 +115,58 @@ func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
|
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
|
||||||
|
// and answers with the freshly rendered row, so the figures describe the
|
||||||
|
// state after the press. The control never commands the poller — the request
|
||||||
|
// is a fact about the Series, and the Lane's next pass reads it through
|
||||||
|
// DueForLatestCheck (ADR-0013). The owner gate is the route's, not this
|
||||||
|
// handler's; the body is capped like the API path caps its bodies; the key is
|
||||||
|
// validated here — a malformed key is a 400 and an unknown one a 404.
|
||||||
|
func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
|
if !ok || site == "" || seriesID == "" {
|
||||||
|
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
} else if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-read after the stamp: the answer must describe the state after the
|
||||||
|
// press. The detail page's control swaps its meta in place and the list
|
||||||
|
// row's swaps the row; htmx names an id target in HX-Target, so the
|
||||||
|
// response matches the surface it came from.
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||||
|
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "series-row", seriesRow(a, 0, time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
// seriesListView assembles one Series list view from the request's query
|
// seriesListView assembles one Series list view from the request's query
|
||||||
// string. An unknown filter value is the absent All case, never an error: the
|
// string. An unknown filter value is the absent All case, never an error: the
|
||||||
// select's options are not the only way this URL can be reached.
|
// select's options are not the only way this URL can be reached.
|
||||||
@@ -213,6 +274,11 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
|||||||
Site: a.Site,
|
Site: a.Site,
|
||||||
Readers: a.ReaderCount,
|
Readers: a.ReaderCount,
|
||||||
Band: i%2 == 1,
|
Band: i%2 == 1,
|
||||||
|
CanPoll: a.SeriesURL != "" && a.ReaderCount > 0,
|
||||||
|
}
|
||||||
|
if a.ForcePollAt > a.LatestCheckedAt {
|
||||||
|
row.Pending = true
|
||||||
|
row.Requested = requestedAge(now, a.ForcePollAt)
|
||||||
}
|
}
|
||||||
if a.LatestChapterNum != nil {
|
if a.LatestChapterNum != nil {
|
||||||
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||||
@@ -273,6 +339,14 @@ func checkedAge(now time.Time, ts int64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestedAge is the pending marker's text: how long ago the owner asked,
|
||||||
|
// and nothing about when the request will run — the page does not know when a
|
||||||
|
// sleeping browser will wake (issue #146). An unanswered request ages forever;
|
||||||
|
// there is no expiry.
|
||||||
|
func requestedAge(now time.Time, ts int64) string {
|
||||||
|
return "requested " + checkedAge(now, ts)
|
||||||
|
}
|
||||||
|
|
||||||
// pagerRange is the pager's "1–50 of 120" line. The template renders the
|
// pagerRange is the pager's "1–50 of 120" line. The template renders the
|
||||||
// pager only over rows (the empty state replaces it), so it is never asked
|
// pager only over rows (the empty state replaces it), so it is never asked
|
||||||
// to describe an empty list.
|
// to describe an empty list.
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ type seriesDetailView struct {
|
|||||||
NoCover bool
|
NoCover bool
|
||||||
Orphan bool // no Reader holds the Series
|
Orphan bool // no Reader holds the Series
|
||||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||||
|
|
||||||
|
// Poll is the Check now control and the pending marker (issue #146): the
|
||||||
|
// same derivation and visibility as the list row. CanPoll is false on a
|
||||||
|
// Series with no page to fetch and on an orphan; Pending is derived —
|
||||||
|
// the request stamp is newer than the check stamp — and Requested is its
|
||||||
|
// ageing label.
|
||||||
|
CanPoll bool
|
||||||
|
Pending bool
|
||||||
|
Requested string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||||
@@ -38,35 +47,50 @@ type seriesDetailView struct {
|
|||||||
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
||||||
// projection is the privacy boundary, and a dedicated single-row read would
|
// projection is the privacy boundary, and a dedicated single-row read would
|
||||||
// be a second definition of it.
|
// be a second definition of it.
|
||||||
// ponytail: a page scan per detail view, one query per page of the Site's
|
|
||||||
// rows up to the window total; a keyed read alongside SeriesPage when the
|
|
||||||
// library outgrows the page size.
|
|
||||||
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
if !ok || site == "" || seriesID == "" {
|
if !ok || site == "" || seriesID == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesByKey reads one Series through the list's own SeriesPage read
|
||||||
|
// narrowed to the key's Site: the admin projection is the privacy boundary,
|
||||||
|
// and a dedicated single-row read would be a second definition of it. Absence
|
||||||
|
// is reported with found=false, never an error.
|
||||||
|
// ponytail: a page scan per keyed read, one query per page of the Site's rows
|
||||||
|
// up to the window total; a keyed read alongside SeriesPage when the library
|
||||||
|
// outgrows the page size.
|
||||||
|
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
|
||||||
seen := 0
|
seen := 0
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
return store.AdminSeries{}, false, err
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
seen += len(p.Rows)
|
seen += len(p.Rows)
|
||||||
for i := range p.Rows {
|
for i := range p.Rows {
|
||||||
if p.Rows[i].SeriesID == seriesID {
|
if p.Rows[i].SeriesID == seriesID {
|
||||||
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])})
|
return p.Rows[i], true, nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if seen >= p.Total {
|
if seen >= p.Total {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
return store.AdminSeries{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
||||||
@@ -83,6 +107,11 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|||||||
NoCover: a.CoverAddress == "",
|
NoCover: a.CoverAddress == "",
|
||||||
Orphan: a.ReaderCount == 0,
|
Orphan: a.ReaderCount == 0,
|
||||||
SightingRaised: a.RaisedByReader,
|
SightingRaised: a.RaisedByReader,
|
||||||
|
CanPoll: a.SeriesURL != "" && a.ReaderCount > 0,
|
||||||
|
}
|
||||||
|
if a.ForcePollAt > a.LatestCheckedAt {
|
||||||
|
v.Pending = true
|
||||||
|
v.Requested = requestedAge(time.Now(), a.ForcePollAt)
|
||||||
}
|
}
|
||||||
if a.LatestChapterNum == nil {
|
if a.LatestChapterNum == nil {
|
||||||
v.Chapter = "—"
|
v.Chapter = "—"
|
||||||
|
|||||||
@@ -281,6 +281,10 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-act .ghost + .ghost {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl .trow > .confirm-row {
|
.admin-sheet .tbl .trow > .confirm-row {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -685,6 +689,12 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .dform .field {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .pausebar {
|
.admin-sheet .pausebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
||||||
list row is one hop from it. Everything here is a Series-level fact plus
|
list row is one hop from it. Everything here is a Series-level fact plus
|
||||||
the anonymous Reader count; the intervention forms a later ticket adds
|
the anonymous Reader count. The Check now control lands in its own .dform
|
||||||
land inside the empty .detail-grid, so this page edits nothing. */}}
|
below the (empty) .detail-grid; the pending marker rides the meta line
|
||||||
|
with the other marks. */}}
|
||||||
{{define "series-detail"}}
|
{{define "series-detail"}}
|
||||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||||
<h1 class="detail-title">{{.Title}}</h1>
|
<h1 class="detail-title">{{.Title}}</h1>
|
||||||
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
||||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||||
{{else}}<div class="cover"></div>{{end}}
|
{{else}}<div class="cover"></div>{{end}}
|
||||||
<div class="detail-meta">
|
{{template "series-detail-meta" .}}
|
||||||
|
<div class="detail-grid"></div>
|
||||||
|
{{if .CanPoll}}
|
||||||
|
<div class="dform">
|
||||||
|
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
||||||
|
the detail page swaps into its place: the same marks, re-rendered after
|
||||||
|
the stamp so the pending marker shows. */}}
|
||||||
|
{{define "series-detail-meta"}}
|
||||||
|
<div class="detail-meta" id="detail-meta">
|
||||||
<span>ch {{.Chapter}}</span>
|
<span>ch {{.Chapter}}</span>
|
||||||
<span>checked {{.Checked}}</span>
|
<span>checked {{.Checked}}</span>
|
||||||
<span>{{.Readers}} readers</span>
|
<span>{{.Readers}} readers</span>
|
||||||
|
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||||
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-grid"></div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -23,17 +23,7 @@
|
|||||||
{{if .Rows}}
|
{{if .Rows}}
|
||||||
<div class="tbl series">
|
<div class="tbl series">
|
||||||
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
||||||
{{range .Rows}}
|
{{range .Rows}}{{template "series-row" .}}{{end}}
|
||||||
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
|
|
||||||
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a></span>
|
|
||||||
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
|
||||||
<span class="c-ch">{{.Ch}}</span>
|
|
||||||
<span>{{.Age}}</span>
|
|
||||||
<span class="c-rd">{{.Readers}}</span>
|
|
||||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
|
||||||
<span class="c-act"></span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
|
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
|
||||||
@@ -44,3 +34,20 @@
|
|||||||
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
|
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* series-row is one Series list row, and the answer a Check now press swaps
|
||||||
|
into the row's place (hx-target="closest .trow"): it must render the
|
||||||
|
pending marker the press created. The control is absent on a Series with
|
||||||
|
no page to fetch and on an orphan, so the owner is never offered a button
|
||||||
|
that can never do anything. */}}
|
||||||
|
{{define "series-row"}}
|
||||||
|
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
|
||||||
|
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
|
||||||
|
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
||||||
|
<span class="c-ch">{{.Ch}}</span>
|
||||||
|
<span>{{.Age}}</span>
|
||||||
|
<span class="c-rd">{{.Readers}}</span>
|
||||||
|
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
||||||
|
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" href="#">Check now</a>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|||||||
+188
-3
@@ -2354,10 +2354,15 @@ func TestSeriesListRowShape(t *testing.T) {
|
|||||||
if !strings.Contains(body, `class="c-site site-asura"`) {
|
if !strings.Contains(body, `class="c-site site-asura"`) {
|
||||||
t.Errorf("the site cell lacks its site class:\n%s", body)
|
t.Errorf("the site cell lacks its site class:\n%s", body)
|
||||||
}
|
}
|
||||||
// The action cell is present and empty for the sibling ticket, and no
|
// The action cell carries the Check now control on pollable rows and is
|
||||||
// Remove control or confirm row renders in this batch.
|
// empty on the orphan (no URL, no Readers — a button that can never do
|
||||||
|
// anything is not offered), and no Remove control or confirm row renders
|
||||||
|
// in this batch.
|
||||||
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
||||||
t.Errorf("the action cell is not present and empty:\n%s", body)
|
t.Errorf("the orphan's action cell is not present and empty:\n%s", body)
|
||||||
|
}
|
||||||
|
if got := strings.Count(body, "Check now"); got != 2 {
|
||||||
|
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
|
||||||
}
|
}
|
||||||
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
||||||
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
||||||
@@ -2599,3 +2604,183 @@ func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
|
|||||||
t.Errorf("title rendered raw:\n%s", body)
|
t.Errorf("title rendered raw:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seriesDetailPage drives one Series detail request as the owner and returns
|
||||||
|
// the rendered body.
|
||||||
|
func seriesDetailPage(t *testing.T, srv http.Handler, st *store.Store, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
|
||||||
|
}
|
||||||
|
return rr.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressing Check now answers with freshly rendered markup showing the pending
|
||||||
|
// marker: the list row (or the detail meta) re-rendered after the stamp, so
|
||||||
|
// the figures describe the state after the press, not before (issue #146).
|
||||||
|
func TestSeriesPollRoundTrip(t *testing.T) {
|
||||||
|
router, st := newWebTestServer(t, testConfig())
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:x", Site: "asura", SeriesID: "x",
|
||||||
|
Title: "Title of asura:x", SeriesURL: "https://asurascans.com/comics/x",
|
||||||
|
})
|
||||||
|
|
||||||
|
// List surface: the row anchor swaps its own row.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST poll status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `class="trow`) {
|
||||||
|
t.Errorf("poll response is not the freshly rendered row:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<span class="mark">requested `) {
|
||||||
|
t.Errorf("poll response lacks the pending marker:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail surface: the meta fragment carries the same marker.
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
req.Header.Set("HX-Target", "detail-meta")
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST poll (detail) status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body = rr.Body.String()
|
||||||
|
if !strings.Contains(body, `id="detail-meta"`) || !strings.Contains(body, "requested ") {
|
||||||
|
t.Errorf("detail response lacks the meta fragment with the pending marker:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request is durable: the list shows the pending marker, which is the
|
||||||
|
// same row the press's answer rendered.
|
||||||
|
body = adminSeriesPage(t, router, st, "")
|
||||||
|
if !strings.Contains(body, "requested ") {
|
||||||
|
t.Errorf("the list does not show the pending marker after the press:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Check now control is hidden on a Series with no page to fetch and on
|
||||||
|
// one no Reader holds — the owner is never offered a button that can never do
|
||||||
|
// anything — and present otherwise, on both the list row and the detail page.
|
||||||
|
func TestSeriesPollControlVisibility(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nourl", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||||
|
router := newRouter(st, testConfig())
|
||||||
|
|
||||||
|
// List: exactly the pollable row offers the control.
|
||||||
|
body := adminSeriesPage(t, router, st, "")
|
||||||
|
if got := strings.Count(body, "Check now"); got != 1 {
|
||||||
|
t.Errorf("list offers Check now %d times, want 1 (only the pollable row):\n%s", got, body)
|
||||||
|
}
|
||||||
|
// Detail pages: the pollable row offers it, the other two do not.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
key string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"asura:ok", true},
|
||||||
|
{"asura:nourl", false},
|
||||||
|
{"asura:orphan", false},
|
||||||
|
} {
|
||||||
|
body := seriesDetailPage(t, router, st, tc.key)
|
||||||
|
if got := strings.Contains(body, "Check now"); got != tc.want {
|
||||||
|
t.Errorf("%s detail offers Check now = %v, want %v", tc.key, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pending marker ages and never expires: an old unanswered request still
|
||||||
|
// renders its marker with an old age, and a second press re-stamps the
|
||||||
|
// request time.
|
||||||
|
func TestSeriesPollMarkerAgesAndNeverExpires(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:x", url: "u", checkedAt: 0, bookmarks: 1})
|
||||||
|
// A five-day-old request, never checked: pending, with no expiry.
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE series SET force_poll_at = $1 WHERE site = 'asura' AND series_id = 'x'`,
|
||||||
|
time.Now().Add(-5*24*time.Hour).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("seed force stamp: %v", err)
|
||||||
|
}
|
||||||
|
router := newRouter(st, testConfig())
|
||||||
|
body := adminSeriesPage(t, router, st, "")
|
||||||
|
if !strings.Contains(body, "requested 5d ago") {
|
||||||
|
t.Errorf("old request does not render its aged marker:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second press re-stamps: the marker reads fresh again.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST poll status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "requested 1m ago") {
|
||||||
|
t.Errorf("re-stamp does not re-age the marker:\n%s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed key is a 400 and an unknown key a 404 — neither is a 500, and
|
||||||
|
// neither reaches the store as an unvalidated write.
|
||||||
|
func TestSeriesPollRejectsBadKeys(t *testing.T) {
|
||||||
|
router, st := newWebTestServer(t, testConfig())
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"/admin/series/nocolon/poll", http.StatusBadRequest},
|
||||||
|
{"/admin/series/:x/poll", http.StatusBadRequest},
|
||||||
|
{"/admin/series/asura:/poll", http.StatusBadRequest},
|
||||||
|
{"/admin/series/asura:ghost/poll", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != tc.want {
|
||||||
|
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form bodies on the action route are capped the way the API path caps them:
|
||||||
|
// an oversized body is a 400, not a memory grant.
|
||||||
|
func TestSeriesPollCapsBody(t *testing.T) {
|
||||||
|
router, st := newWebTestServer(t, testConfig())
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:x", Site: "asura", SeriesID: "x",
|
||||||
|
Title: "Title of asura:x", SeriesURL: "u",
|
||||||
|
})
|
||||||
|
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader(big))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("oversized body status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
// Nothing was stamped: the list still shows no pending marker.
|
||||||
|
if body := adminSeriesPage(t, router, st, ""); strings.Contains(body, "requested ") {
|
||||||
|
t.Errorf("an oversized body still stamped the request:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-0013: Commands through the database
|
||||||
|
|
||||||
|
Date: 2026-08-22
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Owner interventions are **facts about rows, never commands to the poller**.
|
||||||
|
*Check now* (`POST /admin/series/{key}/poll`) writes one stamp —
|
||||||
|
`series.force_poll_at`, unix ms, zero meaning never asked (the column landed
|
||||||
|
in migration 0014) — and the poller's next pass reads it through
|
||||||
|
`Store.DueForLatestCheck`. The control never signals the running process, so
|
||||||
|
a request survives a restart, and the whole surface is testable with no
|
||||||
|
poller running at all.
|
||||||
|
|
||||||
|
**Pending is derived, never stored**: a Series is pending while
|
||||||
|
`force_poll_at > latest_checked_at`. It self-clears with no second write and
|
||||||
|
no sweeper because the check stamp is written *before* the fetch (the same
|
||||||
|
"attempted" discipline as ADR-0010) — the first attempt ends the pending
|
||||||
|
state whatever the attempt returns. There is no expiry: a request the Lane
|
||||||
|
never reaches keeps ageing in the UI, and an old pending marker is itself the
|
||||||
|
evidence that a Lane is stuck. Writing again re-stamps the request time; the
|
||||||
|
write is idempotent.
|
||||||
|
|
||||||
|
**Queue-jump rules.** A forced Series overrides exactly three gates in the
|
||||||
|
due query: the rest cutoff, the Sighting-deferral clause and the finished-only
|
||||||
|
bucket, and it sorts to the front of the queue
|
||||||
|
(`ORDER BY forced DESC, reader_count DESC, latest_checked_at ASC`). It never
|
||||||
|
overrides an empty `series_url` (nothing to fetch), the Bookmarks join (a
|
||||||
|
Series no Reader holds has no consumer for the result), the Lane's refusal
|
||||||
|
backoff, the sidecar-down skip, or the Lane's gap — the last three are
|
||||||
|
poller-side gates the query cannot see and must not. The one pass-level gate
|
||||||
|
a forced Series does open is the browser wake threshold: a human asking wakes
|
||||||
|
a sleeping Chrome, where the thresholds exist to stop the machine waking
|
||||||
|
itself for one unattended check. If the home machine is off, nothing happens
|
||||||
|
and the request ages visibly, which is correct.
|
||||||
|
|
||||||
|
Rejected: zeroing the check stamp as the force signal. It would corrupt the
|
||||||
|
never-checked and stale counts the landing page exists to show, and make a
|
||||||
|
pending marker impossible.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
A stuck-looking Series previously waited for its turn in the Lane's hour, and
|
||||||
|
there was no way to ask for one check sooner. A direct poller command would
|
||||||
|
have been lost on every restart and untestable without a running poller; a
|
||||||
|
row the poller already reads is neither. Deriving pending from the two stamps
|
||||||
|
keeps the flag honest across restarts and makes the mechanism two column
|
||||||
|
writes and three query clauses instead of a state machine.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The finished-status clause the force flag overrides is today's Lifecycle
|
||||||
|
test; a later spec in this series deletes it wholesale rather than amending
|
||||||
|
it, so the clause stays as it stands.
|
||||||
|
- The control is unconfirmed (it takes nothing away) and renders no
|
||||||
|
`.confirm-row`; it is hidden on a Series with no `series_url` and on an
|
||||||
|
orphan — the same pair the due query refuses to override.
|
||||||
|
- The answer to a press is the freshly rendered row, so the figures describe
|
||||||
|
the state after the press.
|
||||||
Reference in New Issue
Block a user