diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ae726d..ecf6d3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Widgets spread their polling, so several on one dashboard no longer fetch from + your services on the same tick. + - Importing from another dashboard reads a list written level with its key, a value folded across lines, and an empty `[]` or `{}`, each of which used to refuse the whole file. diff --git a/docs/widgets.md b/docs/widgets.md index d7d11347..dc7a8b47 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -599,6 +599,9 @@ A failure keeps the last good render in place; only after `staleAfter` (default 2) consecutive failures does it surface `errorText` with how long ago the last success was. `sinceLabel(ts)` gives that "3m ago" label on its own. +The first fetch runs immediately. Each repeat is spread by up to 15%, so several +widgets on one dashboard do not fetch on the same tick. + **Links** - `openUrl(href)` opens a link in a new tab. Use this rather than `window.open`, diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index db9c26cf..a04e22f8 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -139,6 +139,12 @@ "/js/import-foreign.js": [ "./ui/js/import-foreign.js" ], + "/js/jitter.js": [ + "./ui/js/jitter.js" + ], + "/js/jitter.js?v=*": [ + "./ui/js/jitter.js" + ], "/js/import-foreign.js?v=*": [ "./ui/js/import-foreign.js" ], diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 57e03c60..7ab55e08 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -35,6 +35,7 @@ import { restorePage, } from '/js/dashboard-logic.js?v=640430ba'; import { trapFocus } from '/js/dialog.js?v=b3841546'; +import { jitter } from '/js/jitter.js?v=1'; const MOB = innerWidth <= 768 || /iPhone|iPod|Android/i.test(navigator.userAgent); @@ -715,14 +716,13 @@ async function boot() { /* Jittered, so several open clients do not poll on the same tick. */ let _pollTimers = []; - const _jit = base => Math.round(base * (1 + (Math.random() * 2 - 1) * 0.15)); const _repeat = (fn, base) => { let h; const tick = async () => { try { await fn(); } catch {} - h = setTimeout(tick, _jit(base)); + h = setTimeout(tick, jitter(base)); }; h = setTimeout(tick, Math.round(Math.random() * base)); _pollTimers.push(() => clearTimeout(h)); diff --git a/ui/js/jitter.js b/ui/js/jitter.js new file mode 100644 index 00000000..2e57ba5b --- /dev/null +++ b/ui/js/jitter.js @@ -0,0 +1,14 @@ +// @ts-check +/* Spread repeated timers, so several pollers started together do not keep + firing on the same tick. Keep it dependency-free: it is imported inside + widget frames as well as by the dashboard. */ + +const SPREAD = 0.15; + +/** A delay within ±15% of `base`. + @param {number} base milliseconds @returns {number} */ +export function jitter(base) { + const n = Number(base); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.round(n * (1 + (Math.random() * 2 - 1) * SPREAD)); +} diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index ae62b56e..cc7acbef 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -3,6 +3,7 @@ import { esc, html, setHtml } from '/js/html.js?v=ccec347c'; import { isSafeLinkUrl } from '/js/link-url.js?v=19038560'; +import { jitter } from '/js/jitter.js?v=1'; export { esc, html, setHtml }; @@ -348,7 +349,9 @@ export function poll(opts = {}) { paused = true; return; } - timer = setTimeout(loop, intervalFor(lastData)); + /* Jittered, so several widgets on one dashboard do not fetch in lockstep. + The first tick is not delayed: that one is the widget's content. */ + timer = setTimeout(loop, jitter(intervalFor(lastData))); } function onVisibility() { diff --git a/ui/test/jitter.test.mjs b/ui/test/jitter.test.mjs new file mode 100644 index 00000000..dbd799a4 --- /dev/null +++ b/ui/test/jitter.test.mjs @@ -0,0 +1,52 @@ +/* Several pollers started at the same moment keep firing at the same moment, + which lands on a service in bursts. The dashboard already spread its own + polls; widgets polled on an exact cadence. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { jitter } from '../js/jitter.js'; + +const withRandom = (value, fn) => { + const real = Math.random; + Math.random = () => value; + try { + return fn(); + } finally { + Math.random = real; + } +}; + +test('the delay stays within 15% of the interval, either way', () => { + assert.equal( + withRandom(0, () => jitter(1000)), + 850, + ); + assert.equal( + withRandom(1, () => jitter(1000)), + 1150, + ); + assert.equal( + withRandom(0.5, () => jitter(1000)), + 1000, + ); +}); + +test('every delay across many draws stays inside the band', () => { + for (let i = 0; i < 500; i++) { + const d = jitter(30_000); + assert.ok(d >= 25_500 && d <= 34_500, `${d} is outside ±15%`); + } +}); + +test('two pollers on the same interval do not keep the same delay', () => { + const draws = new Set(Array.from({ length: 50 }, () => jitter(30_000))); + assert.ok(draws.size > 1, 'a fixed delay would defeat the point'); +}); + +/* A caller that has no interval yet must not schedule into the past or be + handed NaN, which setTimeout treats as zero and turns into a busy loop. */ +test('an unusable interval gives zero, not NaN', () => { + for (const v of [0, -5, NaN, undefined, null, 'soon', {}]) { + assert.equal(jitter(v), 0, String(v)); + } +}); diff --git a/ui/test/widget-toolbox-poll.test.mjs b/ui/test/widget-toolbox-poll.test.mjs index db4ca333..b6808742 100644 --- a/ui/test/widget-toolbox-poll.test.mjs +++ b/ui/test/widget-toolbox-poll.test.mjs @@ -259,3 +259,37 @@ test('stop while hidden stays stopped when the tab returns', async () => { assert.equal(calls, after, 'a stopped poll must not resurrect on visibility'); }); }); + +/* Several widgets open together polled on the same exact cadence, so they kept + hitting the same services on the same tick. */ +test('the repeat interval is spread, and the first fetch is not delayed', async () => { + const delays = []; + const realTimeout = globalThis.setTimeout; + globalThis.setTimeout = (fn, ms, ...rest) => { + delays.push(ms); + return realTimeout(fn, ms, ...rest); + }; + try { + await run(async (_dom, start) => { + const first = Date.now(); + let firstFetchAt = 0; + start({ + interval: 1000, + fetch: async () => { + firstFetchAt = firstFetchAt || Date.now(); + return {}; + }, + }); + await tick(20); + assert.ok(firstFetchAt - first < 50, 'the first fetch waits for nothing'); + }); + } finally { + globalThis.setTimeout = realTimeout; + } + + const scheduled = delays.filter(d => d > 500); + assert.ok(scheduled.length > 0, 'the repeat was never scheduled'); + for (const d of scheduled) { + assert.ok(d >= 850 && d <= 1150, `${d}ms is outside ±15% of the interval`); + } +});