-
Notifications
You must be signed in to change notification settings - Fork 3
Reduce redundant intent writes while preserving liveness #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // SPDX-License-Identifier: AGPL-3.0 | ||
|
|
||
| /** Change-triggered writes with a bounded refresh and one request in flight. | ||
| * Only acknowledged writes advance the checkpoint. The latest queued state wins. | ||
| * A monotonic clock avoids missed refreshes when the system clock moves backward. | ||
| */ | ||
| export class StatePublisher { | ||
| #send; #refreshMs; #now; #lastKey; #lastAt = -Infinity; | ||
| #pending; #running; | ||
| constructor(send, { refreshMs, now = () => performance.now() }) { | ||
| if (!Number.isFinite(refreshMs) || refreshMs <= 0) throw new Error('Invalid refreshMs'); | ||
| this.#send = send; this.#refreshMs = refreshMs; this.#now = now; | ||
| } | ||
| publish(state, { force = false } = {}) { | ||
| this.#pending = { state: structuredClone(state), force }; | ||
| if (!this.#running) { | ||
| this.#running = this.#drain().finally(() => { this.#running = undefined; }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If another Useful? React with 👍 / 👎. |
||
| } | ||
| return this.#running; | ||
| } | ||
| async #drain() { | ||
| while (this.#pending) { | ||
| const { state, force } = this.#pending; | ||
| this.#pending = undefined; | ||
| const key = JSON.stringify(state); | ||
| if (!force && key === this.#lastKey && this.#now() - this.#lastAt < this.#refreshMs) continue; | ||
| await this.#send(state); | ||
| this.#lastKey = key; | ||
| this.#lastAt = this.#now(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { StatePublisher } from '../src/state-publisher.js'; | ||
| import { DesktopAdapter } from '../src/adapters/desktop.js'; | ||
|
|
||
| test('unchanged agent status: 30s sampling yields 720 writes/day, not 2880', async () => { | ||
| let now = 0, count = 0; | ||
| const p = new StatePublisher(async () => count++, { refreshMs: 120000, now: () => now }); | ||
| for (; now < 86400000; now += 30000) await p.publish({ status: 'active' }); | ||
| assert.equal(count, 720); | ||
| }); | ||
| test('task and offline transitions publish immediately', async () => { | ||
| const sent = []; | ||
| const p = new StatePublisher(async s => sent.push(s), { refreshMs: 120000, now: () => 0 }); | ||
| await p.publish({ status: 'active', task: null }); | ||
| await p.publish({ status: 'active', task: 'new' }); | ||
| await p.publish({ status: 'offline', task: null }); | ||
| assert.equal(sent.length, 3); | ||
| }); | ||
| test('failed refresh is retried without advancing successful checkpoint', async () => { | ||
| let tries = 0; | ||
| const p = new StatePublisher(async () => { if (++tries === 1) throw Error('offline'); }, { refreshMs: 60000 }); | ||
| await assert.rejects(p.publish({ a: 1 })); | ||
| await p.publish({ a: 1 }); | ||
| assert.equal(tries, 2); | ||
| }); | ||
| test('in-flight changes coalesce to the latest state without concurrent writes', async () => { | ||
| const sent = []; let release; | ||
| const p = new StatePublisher(async s => { | ||
| sent.push(s); | ||
| if (sent.length === 1) await new Promise(r => { release = r; }); | ||
| }, { refreshMs: 60000 }); | ||
| const first = p.publish({ n: 1 }); | ||
| p.publish({ n: 2 }); | ||
| const last = p.publish({ n: 3 }); | ||
| release(); await first; await last; | ||
| assert.deepEqual(sent, [{ n: 1 }, { n: 3 }]); | ||
| }); | ||
| test('desktop uses one bounded timer, not an independent heartbeat', async (t) => { | ||
| t.mock.timers.enable({ apis: ['setInterval'] }); | ||
| let patches = 0, beats = 0; | ||
| const a = new DesktopAdapter({ patchDevice: async s => { patches++; assert.equal(s.ttl_sec, 90); }, | ||
| startHeartbeat: () => beats++, stopHeartbeat() {} }, { pollIntervalMs: 120000 }); | ||
| let samples = 0; | ||
| a.publishState = async () => { samples++; }; | ||
| a.start(); await new Promise(r => setImmediate(r)); | ||
| assert.equal(samples, 1); assert.equal(beats, 0); | ||
| t.mock.timers.tick(60000); await new Promise(r => setImmediate(r)); | ||
| assert.equal(samples, 2); // bounded detection with old 120-second setting | ||
| a.stop(); | ||
| }); | ||
| test('invalid refresh intervals are rejected', () => { | ||
| for (const n of [0, -1, NaN, Infinity]) assert.throws(() => new StatePublisher(async () => {}, { refreshMs: n })); | ||
| }); | ||
|
|
||
| test('explicit liveness tick is not skipped because the last request completed late', async () => { | ||
| let now = 0, sends = 0; | ||
| const p = new StatePublisher(async () => { sends++; now += 1000; }, { refreshMs: 120000, now: () => now }); | ||
| await p.publish({ status: 'active' }); | ||
| now = 120000; | ||
| await p.publish({ status: 'active' }, { force: true }); | ||
| assert.equal(sends, 2); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the desktop payload remains unchanged,
StatePublisherrecords its checkpoint after the request completes, so any nonzero request latency makes the next 60-second timer fire less thanrefreshMsafter that checkpoint and get suppressed. The following write then occurs roughly 120 seconds after the previous one, exceeding the explicit 90-second TTL and making stable devices—such as Linux hosts or Macs whose idle sensor is unavailable—repeatedly appear stale. Use a refresh interval comfortably below the TTL or calculate refresh eligibility from the send's scheduled/start time.Useful? React with 👍 / 👎.