Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
wip: lazy polling
Signed-off-by: Max <[email protected]>
  • Loading branch information
max-nextcloud committed Feb 18, 2023
commit a7bec4680c44d0376018f7e90b879619413693ed
127 changes: 127 additions & 0 deletions src/helpers/lazy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* @copyright Copyright (c) 2023 Max <[email protected]>
*
* @author Max <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

/**
*
* @param {Function} callback to be triggered by the timer
*/
function lazyTimer(callback) {
const fn = lazy(callback)
fn.interval = setInterval(fn, 300)
return fn
}

export { lazyTimer }

/**
* Throttle a function so it only runs in intervals that double on every run.
*
* const fn = lazy(inner)
* fn() // call inner
* fn() // skip - so interval between inner() is 2x the interval between fn()
* fn() // call inner
* fn(); fn(); fn() // skip all - so interval is 4x the interval between fn()
* fn() // call inner
*
* fn.wakeUp() // will start from scratch.
*
* fn.sleep() // will skip `skipAtMost` steps between all runs until fn.wakeUp() is called.
*
* @param {Function} inner function to be called
* @param {object} options optional
* @param {number} options.skipAtMost maximum number of calls to skip, default: 15
*/
export function lazy(inner, { skipAtMost = 15 } = {}) {
let count = 0
const result = (...args) => {
count++
if (runFor(count, skipAtMost)) {
return inner(...args)
}
}
result.wakeUp = () => {
count = 0
}
result.sleep = () => {
const previousRun = runsBefore(count)
const previousCount = countAt(previousRun)
count = lastCountAfterDoubling(skipAtMost) + count - previousCount
}
return result
}

/**
* @param {number} count time the function is being called
* @param {number} skipAtMost maximum number of calls to skip
*/
function runFor(count, skipAtMost) {
const nextRun = runsBefore(count) + 1
const skips = skipsBefore(nextRun)
if (!skipAtMost || skips < skipAtMost) {
return count === countAt(nextRun)
} else {
const runEvery = skipAtMost + 1
const result = (count - lastCountAfterDoubling(skipAtMost)) % runEvery === 0
return result
}
}

/**
* At what count does the inner function run for the nth time.
*
* @param {number} n time the inner function runs
* @return {number}
*/
function countAt(n) {
return 2 ** n - 1
}

/**
* How many runs happened before count.
*
* @param {number} count time the lazy function is being called
* @return {number}
*/
function runsBefore(count) {
return Math.floor(Math.log2(count))
}

/**
* How many calls of the lazy function are skipped before it runs the nth time.
*
* @param {number} n time the inner function runs
* @return {number}
*/
function skipsBefore(n) {
return (n === 1) ? 1 : countAt(n - 1)
}

/**
* Count when the limit to doubling the intervals was reached.
*
* @param {number} skipAtMost upper limit for doubling
* @return {number}
*/
function lastCountAfterDoubling(skipAtMost) {
const lastRunToDoubleAfter = Math.floor(Math.log2(skipAtMost + 1))
return countAt(lastRunToDoubleAfter + 1)
}
147 changes: 147 additions & 0 deletions src/tests/helpers/lazy.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { count } from 'lib0/indexeddb.js'
import { lazy, lazyTimer } from '../../helpers/lazy.js'

describe('lazy timer', () => {

test('runs on intervals that double', () => {
jest.useFakeTimers()
const callback = jest.fn();
const timer = lazyTimer(callback);
expect(callback).not.toBeCalled();
jest.advanceTimersByTime(400);
expect(callback).toHaveBeenCalledTimes(1); // 300
jest.advanceTimersByTime(800);
expect(callback).toHaveBeenCalledTimes(2); // 900 (300 + 600)
jest.advanceTimersByTime(1600);
expect(callback).toHaveBeenCalledTimes(3); // 2100 (900 + 1200)
jest.advanceTimersByTime(3200);
expect(callback).toHaveBeenCalledTimes(4); // 4500 (2100 + 2400)
jest.advanceTimersByTime(6400);
expect(callback).toHaveBeenCalledTimes(5); // 9300 (4500 + 4800)
jest.useRealTimers()
})

test('stays at same interval once it reached maxInterval', () => {
jest.useFakeTimers()
const callback = jest.fn();
const timer = lazyTimer(callback);
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(5); // see above
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(7); // 14100 (9300 + 4800) and 18900 (14100 + 4800)
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(9); // roughly every 5 seconds
jest.useRealTimers()
})

test('starts from scratch after wakeUp', () => {
jest.useFakeTimers()
const callback = jest.fn();
const timer = lazyTimer(callback);
jest.advanceTimersByTime(20000);
expect(callback).toHaveBeenCalledTimes(7); // see above
timer.wakeUp()
jest.advanceTimersByTime(400);
expect(callback).toHaveBeenCalledTimes(8); // 300
jest.advanceTimersByTime(800);
expect(callback).toHaveBeenCalledTimes(9); // 900 (300 + 600)
jest.advanceTimersByTime(1600);
expect(callback).toHaveBeenCalledTimes(10); // 2100 (900 + 1200)
jest.useRealTimers()
})

test('goes to maxInterval when sleep is called', () => {
jest.useFakeTimers()
const callback = jest.fn();
const timer = lazyTimer(callback);
jest.advanceTimersByTime(400);
expect(callback).toHaveBeenCalledTimes(1); // see above
timer.sleep()
jest.advanceTimersByTime(4000);
expect(callback).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(800);
expect(callback).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(4800);
expect(callback).toHaveBeenCalledTimes(3);
jest.useRealTimers()
})

test('allows to clear interval', () => {
jest.useFakeTimers()
const callback = jest.fn();
const timer = lazyTimer(callback);
jest.advanceTimersByTime(400);
expect(callback).toHaveBeenCalledTimes(1); // see above
clearInterval(timer.interval)
jest.advanceTimersByTime(10000);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers()
})

test('can stay awake by calling .wakeUp in callback', () => {
jest.useFakeTimers()
let timer
const callback = jest.fn();
const energize = () => {
callback()
timer.wakeUp()
}
timer = lazyTimer(energize);
jest.advanceTimersByTime(3200);
expect(callback).toHaveBeenCalledTimes(10);
jest.useRealTimers()
})

})

describe('lazy function', () => {

test('skips 1 than 3 than 7 than 15', () => {
const inner = jest.fn()
const fn = lazy(inner)
callNTimes(32, fn)
expect(inner.mock.calls.map(call => call[0])).toEqual([1,3,7,15,31])
})

test('starts again after being waken up', () => {
const inner = jest.fn()
const fn = lazy(inner)
callNTimes(3, fn)
fn.wakeUp()
callNTimes(10, fn)
expect(inner.mock.calls.map(call => call[0])).toEqual([1,3,1,3,7])
})

test('respects skipAtMost option', () => {
const inner = jest.fn()
const fn = lazy(inner, { skipAtMost: 3 })
callNTimes(20, fn)
expect(inner.mock.calls.map(call => call[0])).toEqual([1,3,7,11,15,19])
})

test('skipAtMost defaults to 15', () => {
const inner = jest.fn()
const fn = lazy(inner)
callNTimes(64, fn)
expect(inner.mock.calls.map(call => call[0])).toEqual([1,3,7,15,31,47,63])
})

test('skips skipAtMost after sleep was called', () => {
const inner = jest.fn()
let count = 0
const lazyFn = lazy(() => inner(count), { skipAtMost: 5 })
const trigger = () => {
count++
lazyFn()
}
callNTimes(4, trigger)
lazyFn.sleep()
callNTimes(20, trigger)
expect(inner.mock.calls.map(call => call[0])).toEqual([1,3,9,15,21])
})

})

function callNTimes(n, fn) {
for (let i = 1; i <= n; i++) { fn(i) }
}