Skip to content

feat(grabber): a site remembers a programme's details between runs - #30

Merged
Ruby184 merged 1 commit into
mainfrom
feat/remember
Oct 17, 2026
Merged

Ruby184 merged 1 commit into
mainfrom
feat/remember

Conversation

@Ruby184

@Ruby184 Ruby184 commented Oct 17, 2026

Copy link
Copy Markdown
Owner

A site can now keep what it asked a source for between runs, so a nightly refetch doesn't ask for every programme's details again. homepi asked for this: three of its sites carry the same ~40 lines to do it by hand, and with them webtv.sk went from 9,643 requests a cold day to 1,543. One commit, a feature.

ctx.remember(key, { until, miss? }, task)

Every context a site is handed has it: requests, parseDay, and a stream's pass. It answers with what an earlier run kept under key, or runs task and keeps its answer until until.

const details = await remember(
  `detail:${item.id}|${item.start}`,
  {
    until: Date.parse(item.end) + 86_400_000,
    miss: (error) => error instanceof HTTPError && error.response.status === 404,
  },
  async () => trim(await paced(({ signal }) => http.get(`detail/${item.id}`, { signal }).json())),
);
  • until is required, a Date or epoch milliseconds, since how long something is worth keeping is the site's to say. One already past keeps the value for this run only, with one warning per run; it usually means seconds where milliseconds were meant.
  • miss turns a failure into a kept null, so "no details" is asked once. Any other failure is thrown and not kept, so the next run asks again.
  • The return type has null in it only when the ask has a miss. A "none" kept for an ask with a miss is never handed to one without; that ask runs its own task.
  • A value must be plain JSON, as a channel's data must. A Date or a Map is refused with the site, the key and where in the value. Each caller gets its own copy.

Two asks at once

Two asks of one key while the first is still running share one task, and each reads the outcome with its own miss. That covers a broadcast past midnight, which appears in two days' listings. A parse waiting on another parse's ask gives up its localWork slot meanwhile, as it does for paced, so localConcurrency: 1 can't deadlock. A request can't give up its site-queue slot yet, so it asks for itself instead of waiting.

Storage

  • Values live in a state group of their own, remembered, beside the site's state, so a token isn't rewritten for the details' sake.
  • Expired values are dropped at the end of every site run, whether or not anything asked. That's one extra state read per site per run; for a site that never remembers anything, it finds nothing and writes nothing.
  • --refresh reads nothing an earlier run kept, and still keeps new answers, as it does for cached listings.
  • A failed read of a site's state is no longer kept as the group, so the next ask tries again rather than the run failing when it saves.

Smaller changes

  • The plain-JSON check moved from the channel module to src/core/plain.ts, since remember uses it too.
  • StateKey.REMEMBERED names the new group, and the API docs now list all four state groups.

Testing

  • 17 new tests, covering:
    • keeping and reading across runs;
    • miss, other failures and each caller's own miss;
    • expiry, and pruning when nothing asks;
    • a shared ask, including the slot-lending case, which hung before the fix;
    • copies through a store that really serializes, --refresh, a past until, option validation, a failed read, and the return types.
  • Each behaviour was broken in turn to confirm a test fails.
  • 1,788 tests, passing on Node 22.18, 24.15 and 26.12.
  • The docs example is in test/readme-examples.ts and typechecks.

A source that wants a request per programme for its details makes a nightly
refetch expensive. Today is fetched again every run, and with it every detail
call for programmes already asked about. homepi carries the same forty lines
in three sites to avoid it, and webtv.sk went from 9,643 requests a cold day
to 1,543.

Every context a site is handed now has `remember(key, { until, miss? }, task)`,
which answers with what an earlier run kept under the key, or with the task's
answer, kept until `until`. A failure `miss` matches is kept as `null`, so
"no details" is asked once; any other is thrown and not kept, so the next run
asks again. A kept "none" is an answer only to an ask with a `miss` of its
own, which is what lets the type leave `null` out when there is none.

Two asks of one key while the first is on its way are one task, each reading
what it came to with its own `miss`. A parse waiting on another's lends its
slot meanwhile, as it does for `paced`; a request cannot lend its queue slot,
so it asks for itself. Every caller is handed a copy.

Values are kept in a state group of their own, `remembered`, beside the
site's `state`, and what has passed its `until` is dropped at the end of every
run, whether or not anything asked. `--refresh` reads nothing kept and still
keeps. An `until` already past keeps a value for this run only, and says so
once. A value must be plain, as a channel's data must, since it comes back
from JSON. A site state read that fails is no longer kept as the group, so
the next ask tries again.
@Ruby184
Ruby184 merged commit 83438e7 into main Oct 17, 2026
4 checks passed
@Ruby184
Ruby184 deleted the feat/remember branch October 17, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant