Skip to content

Feature: Per feed refresh frequency - #245

Merged
0x2E merged 29 commits into
0x2E:mainfrom
baduhai:feature/per-feed-refresh-frequency
Oct 7, 2026
Merged

0x2E merged 29 commits into
0x2E:mainfrom
baduhai:feature/per-feed-refresh-frequency

Conversation

@baduhai

@baduhai baduhai commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Reason for feature

Some feeds I'm subscribed to are either throttling or outright blocking me because of the high refresh frequency. I have raised the refresh frequency, but I'd still like some of my feeds to refresh more often, thus a per feed refresh frequency setting.

Summary

Adds the ability to override the global refresh interval (FUSION_PULL_INTERVAL) on a per-feed basis via a dropdown in the edit/add feed dialogues.

Screenshots
Edit feed
image
Add feed
image
Refresh frequency dropdown
image
Currently, these are the refresh frequency options, but I'm open to adding more options or even leaving it up to the user. I just figured having pre-set options would be more convenient.

Changes

  • Database: refresh_interval INTEGER DEFAULT NULL on feeds (NULL = global default)
  • Backend: effective interval resolution per feed, dynamic ticker adapts to shortest interval in use
  • API: refresh_interval field on feed create/update, validation against allowed values (15m–24h), GET /api/app exposes global pull interval
  • Frontend: "Refresh Frequency" dropdown in Advanced Settings (both add & edit dialogues)

Testing

  • All existing backend tests pass (go test ./backend/...)
  • Frontend TypeScript compiles clean
  • Manual testing: created/edited feeds with refresh overrides, verified API responses, confirmed dynamic ticker adjustment in logs

AI-written, human-supervised.

@baduhai

baduhai commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

Added translations to languages I speak.

Comment thread backend/internal/store/feed.go Outdated
Comment on lines +238 to +245
if params.RefreshInterval != nil {
if *params.RefreshInterval == -1 {
setClauses = append(setClauses, "refresh_interval = NULL")
} else {
setClauses = append(setClauses, "refresh_interval = :refresh_interval")
args = append(args, sql.Named("refresh_interval", *params.RefreshInterval))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing an interval should also reschedule the feed. This code updates refresh_interval but leaves next_check_at unchanged. For example, changing a feed from 24 hours to 15 minutes can leave it skipped for the remainder of the old schedule.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, addressed on the latest commit.

Comment thread frontend/src/lib/i18n/messages/de.ts Outdated
Comment on lines +102 to +110
"feed.add.refreshFrequencyLabel": "Aktualisierungsfrequenz",
"feed.add.refreshFrequencyDefault": "Standard ({interval})",
"feed.add.refreshFrequency.900": "Alle 15 minutes",
"feed.add.refreshFrequency.1800": "Alle 30 minutes",
"feed.add.refreshFrequency.3600": "Alle 1 hour",
"feed.add.refreshFrequency.7200": "Alle 2 hours",
"feed.add.refreshFrequency.21600": "Alle 6 hours",
"feed.add.refreshFrequency.43200": "Alle 12 hours",
"feed.add.refreshFrequency.86400": "Alle 24 hours",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds really nitpicky but my agent keeps complaining that the German translation is using English time units. Is it normal for German translations to do that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No not normal, I just didn't look at my German translations after I wrote them 🫣.

@pompydev

Copy link
Copy Markdown
Contributor

This PR will fail on CI because it's missing translations for zh, fr, es, ru, and sv. See https://github.com/0x2E/fusion/blob/1f99b1fc654baa0108e4df4577429d62d0e53cb8/.github/workflows/frontend.yml and run pnpm run check:i18n in frontend/ directory to replicate the issue.

@pompydev

pompydev commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Another pet peeve, time stored as number often mean UNIX timestamp or millisecond interval so I recommend adding Sec suffix for clarity.

- rename refresh_interval to refresh_interval_seconds across backend,
  frontend API types, and the feeds table migration for clarity
- reschedule feed when its refresh interval changes by resetting
  next_check_at so the new schedule takes effect immediately
- fix German refresh frequency labels to use native time units
- add missing zh/fr/es/ru/sv translations
@baduhai

baduhai commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

This PR will fail on CI because it's missing translations for zh, fr, es, ru, and sv. See https://github.com/0x2E/fusion/blob/1f99b1fc654baa0108e4df4577429d62d0e53cb8/.github/workflows/frontend.yml and run pnpm run check:i18n in frontend/ directory to replicate the issue.

I don't speak these languages, so I just had AI do the translations.

Another pet peeve, time stored as number often mean UNIX timestamp or millisecond interval so I recommend adding Sec suffix for clarity.

fixed.

0x2E added 5 commits October 7, 2026 19:31
Replace the dynamic min-interval ticker with a scheduler that sleeps until
the earliest next_check_at across non-suspended feeds and is woken by feed
mutations, so per-feed interval changes apply immediately instead of after
the next global tick. Renumber the migration (003 collided with main's
003_bookmark_feed_id and would be silently skipped by the version-keyed
runner), replace the -1 sentinel with an explicit ClearRefreshInterval
param, clear retry_after_until when the interval changes, and expose the
allowed interval set via GET /api/app.
Both dialogs now share one options source (the server-exposed whitelist,
with a local fallback) and one formatInterval helper in lib/refresh-interval,
replacing the per-dialog copies. formatInterval no longer renders fractional
hours for arbitrary global intervals, and unexpected stored values (legacy 0)
fall back to the Default option instead of showing a blank Select. Also fixes
the onValueChange signature against the current Base UI Select.
Store round-trip (set/clear/absent, next_check_at reset + retry-after
drop), due-feed selection and next-wake computation (retry-after
dominance, past-due clamp), puller pullDue filtering and scheduleDelay
floor/wake behavior, and handler lifecycle incl. validation, 0-clears
semantics, GET /api/app payload, and puller wake on mutations.
@0x2E

0x2E commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Thanks for the feature and the clean data model, William! The per-feed column, effectiveInterval, and the dialog work all shipped through to the final version.

While reviewing, we found the scheduler side needed a rework, so this branch now carries a redesign on top of your commits. This comment explains why, with pictures. Your authorship is untouched — the rework sits as four commits on top.

1. The old scheme couldn't wake up

The original rework made the global ticker dynamic (reset to the shortest interval in use), but it only re-evaluated after a tick — nothing told a sleeping puller that an interval had changed:

sequenceDiagram
    participant U as User
    participant P as Puller (6h ticker)
    U->>P: feed set to 15m (due now)
    Note over P: asleep — nothing wakes it
    P->>P: ≤ 6h later: pull, then ticker → 15m
Loading

The new scheme sleeps until the earliest next_check_at and is woken by feed mutations:

sequenceDiagram
    participant U as User
    participant P as Puller (due-time loop)
    U->>P: feed set to 15m + Wake()
    P->>P: pull now, sleep until next next_check_at (≈15m)
Loading
flowchart LR
    A["pull feeds where next_check_at <= now"] --> B["sleep until earliest next_check_at"]
    B -->|timer| A
    B -->|"Wake() on feed create/update/delete"| A
Loading

The key insight: next_check_at already is the schedule — every pull writes it, ShouldSkip already reads it. The old scheme polled that column at min(configured intervals); the new one just sleeps until it.

2. Load per cycle

One 15m feed + 500 feeds at the 6h global default, every 15 minutes:

OLD   ListFeeds (JOIN items + GROUP BY) × 2 per tick, filter 501 rows
NEW   ListDueFeeds (usually 0–1 rows) + one MIN() query

Two fixes rode along: an interval change now also drops retry_after_until (a Retry-After hold used to keep beating the reset in ShouldSkip), and a failed wake-time query falls back to the global interval instead of silently stretching the schedule.

3. Migration renumber: 003 → 006 (this one was fatal)

The branch predated main's own migrations. The runner keys applied migrations by the leading integer (schema_migrations.version is a PRIMARY KEY), so with main's 003_bookmark_feed_id.sql already applied, your 003_feed_refresh_interval.sql resolved to version 3 and was silently skipped — the column never got created, and every feed query dies with no such column on both fresh and existing databases. Renumbered to 006_feed_refresh_interval.sql.

4. Smaller cleanups

  • The 0 → -1 → NULL sentinel chain became an explicit ClearRefreshInterval flag; create now normalizes 0 to NULL so a stored 0 can never produce a blank dropdown in the edit dialog.
  • The whitelist lives in one place and is served by GET /api/app (allowed_refresh_intervals); both dialogs render from that response instead of two hand-copied arrays.
  • formatInterval no longer renders 1.02h for arbitrary global intervals, and the Select callbacks match the current Base UI signatures.

Backend (incl. -race), frontend typecheck/tests/i18n are all green. Happy to walk through any of the commits — the scheduler one (refactor: due-time pull scheduler with wake channel, migration 006) is the interesting one.

@0x2E

0x2E commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR! I'll make some adjustments based on your work.

Last comment was posted by the agent, this one's from me 😆

0x2E added 2 commits October 7, 2026 20:05
The project is pnpm-based; a stray package-lock.json means someone ran
npm install by accident and should surface as an untracked file, not be
silently ignored. backend/fusion (local build artifact) stays.
Drop the pure-function normalize test (every branch is exercised through
the handler lifecycle test), replace the circular whitelist loop and the
tautological wake-receive check with sharper assertions (DeepEqual against
the source list, mirrored config value, non-blocking double Wake), and
remove the trailing next_check_at probe that duplicates pullpolicy/
store coverage.
0x2E added 7 commits October 7, 2026 20:18
…i18n

The dropdown now renders bare numeric labels (15m/30m/1h/.../24h) from
formatInterval instead of one translated string per preset, removing 8
keys per locale across 9 locales. The 'Default (30m)' entry is gone too:
the option equal to the global interval means 'follow the global setting'
(stored as NULL), everything else is an explicit override, so the list is
just the /api/app whitelist merged with the global interval. An untouched
select sends nothing; picking the global value clears an override, which
also avoids pinning a feed to a stale fallback while /app is loading.
Base UI's Select renders the raw value in the trigger unless Root receives
an items mapping — the selected interval showed '43200' instead of '12h'.
Pass value/label pairs like the group select does.
The trigger collapsed to its content width (59px) next to full-width
inputs; w-full aligns it with the proxy input in the same section.
…values

State the precedence where the knob is documented (.env.example) and in
the pull strategy section: explicit per-feed overrides always win, so
adjusting the global interval only re-schedules feeds stored as NULL.
- Wake the scheduler after manual RefreshFeed/RefreshAll persist: a
  manual refresh can move next_check_at earlier than the sleeping
  timer's deadline, and without a wake the loop would oversleep (up to
  the 48h cache cap where the old ticker was bounded by one interval).
  Creation no longer wakes from the handler — the initial pull's
  RefreshFeed wakes instead, which also removes the create-time
  double-fetch window.
- Cap scheduleDelay at the global interval: a stale next_check_at
  (missed wake, host suspend on the monotonic clock, NTP step) delays
  re-evaluation by at most one interval.
- Split NextWakeTime's empty (SQL NULL) from due-now (0) so a legacy
  zero-state row cannot pin the wake time to the global interval, and
  backfill those rows in migration 006.
- Back off to the global interval when a pass dispatched feeds but the
  earliest due time is still in the past (fetch-state persist failed),
  instead of re-fetching origins every second.
- Disable the refresh select until /api/app resolves so a save cannot
  pin an interval chosen against the 1800 fallback.
- docs: update absent-vs-0 semantics per endpoint; document the wake
  coverage and the sleep cap.
- tests: real Start() loop test (sleeps to next_check_at, Wake
  interrupts), delay bounds incl. cap and persist-failure backoff,
  NextWakeTime ok-flag cases, wake accounting moved to the update path.
@0x2E
0x2E merged commit c31ade0 into 0x2E:main Oct 7, 2026
2 checks passed
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.

3 participants