Add a throttle

This commit is contained in:
Manav Rathi
2024-07-13 17:58:43 +05:30
parent ebe76cbd1b
commit 166474a1b5
3 changed files with 86 additions and 5 deletions

View File

@@ -46,7 +46,7 @@ The root `package.json` also has a convenience dev dependency:
- [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
parallel tasks when we invoke various yarn scripts.
## Crypto
## Cryptography
We use [libsodium](https://libsodium.gitbook.io/doc/) for encryption, key
generation etc. Specifically, we use its WebAssembly and JS wrappers made using
@@ -85,9 +85,8 @@ our apps are regular React SPAs, and are not particularly tied to Next.
### Vite
For some of our newer code, we have started to use [Vite](https://vitejs.dev).
It is more lower level than Next, but the bells and whistles it doesn't have are
the bells and whistles (and the accompanying complexity) that we don't need in
some cases.
It is likely the future (both generally, and for our code) since Next is
becoming less suitable for SSR and SPAs with their push towards SSG.
## UI
@@ -179,6 +178,11 @@ For more details, see [translations.md](translations.md).
- [zod](https://github.com/colinhacks/zod) is used for runtime typechecking
(e.g. verifying that API responses match the expected TypeScript shape).
- [debounce](https://github.com/sindresorhus/debounce) and its
promise-supporting sibling
[pDebounce](https://github.com/sindresorhus/p-debounce) are used for
debouncing operations (See also: `[Note: Throttle and debounce]`).
## Media
- [jszip](https://github.com/Stuk/jszip) is used for reading zip files in

View File

@@ -9,6 +9,7 @@ import { blobCache } from "@/next/blob-cache";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { ComlinkWorker } from "@/next/worker/comlink-worker";
import { throttled } from "@/utils/promise";
import { proxy } from "comlink";
import { isBetaUser, isInternalUser } from "../feature-flags";
import { getRemoteFlag, updateRemoteFlag } from "../remote-store";
@@ -421,7 +422,7 @@ const setInterimScheduledStatus = () => {
setMLStatusSnapshot({ phase: "scheduled", nSyncedFiles, nTotalFiles });
};
const workerDidProcessFile = triggerStatusUpdate;
const workerDidProcessFile = throttled(updateMLStatusSnapshot, 2000);
/**
* Return the IDs of all the faces in the given {@link enteFile} that are not

View File

@@ -7,6 +7,82 @@
export const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Throttle invocations of an underlying function, coalescing pending calls.
*
* Take a function that returns a promise, and return a new function that can be
* called an any number of times while still ensuring that the underlying
* function is only called a maximum of once per the specified period.
*
* The underlying function is immediately called if there were no calls to the
* throttled function in the last period.
*
* Otherwise we start waiting. Multiple calls to the throttled function while
* we're waiting (either for the original promise to resolve, or after that, for
* the specified cooldown period to elapse) will all be coalesced into a single
* call to the underlying function when we're done waiting.
*
* ---
*
* [Note: Throttle and debounce]
*
* There are many throttle/debounce libraries, and ideally I'd have liked to
* just use one of them instead of reinventing such a basic and finicky wheel.
* Then why write a bespoke one?
*
* - "debounce" means that the underlying function will only be called when a
* particular wait time has elapsed since the last call to the _debounced_
* function.
*
* - This behaviour, while useful sometimes, is not what we want always. If
* the debounced function is continuously being called, then the underlying
* function might never get called (since the wait time does not elapse).
*
* - To avoid this starvation, some debounce implementations like lodash
* provide a "maxWait" option, which tells the debounced function to always
* call the underlying function if maxWait has elapsed.
*
* - The debounced functions can trigger the underlying in two ways: leading
* (aka immediate) and trailing which control if the underlying should be
* called at the leading or the trailing edge of the time period.
*
* - "throttle" can be conceptually thought of as just maxWait + leading. In
* fact, this is how lodash actually implements it too. So we could've used
* lodash, except that is a big dependency to pull for a small function.
*
* - Alternatively, pThrottle is a micro-library that provide such a
* "throttle" primitive. However, its implementation enqueues all incoming
* requests to the throttled function: it still calls the underlying once
* per period, but eventually underlying will get called once for each call
* to the throttled function.
*
* - There are circumstances where that would be the appropriate behaviour,
* but that's not what we want. We wish to trigger an async action,
* coalescing multiple triggers into a single one, one per period.
*
* - Perhaps there are other focused and standard library that'd have what we
* want, but instead of spending more time searching I just wrote it from
* scratch for now. Indeed, I've spent more time writing about the function
* than the function itself.
*/
export const throttled = (underlying: () => Promise<void>, period: number) => {
let pending = 0;
const f = () => {
pending += 1;
if (pending > 1) return;
void underlying()
.then(() => wait(period))
.then(() => {
const retrigger = pending > 1;
pending = 0;
if (retrigger) f();
});
};
return f;
};
/**
* Await the given {@link promise} for {@link timeoutMS} milliseconds. If it
* does not resolve within {@link timeoutMS}, then reject with a timeout error.