throttle-bracket
v0.5.0
Published
A simple transactional effect batching system.
Downloads
14
Maintainers
Readme
throttle-bracket
is a simple transaction/effect batching and throttling
system. It can create a "transactional" context (bracket
) within which multiple
effectful calls are batched together and flushed at the end (throttle
).
This package essentially isolates the concept of transactional batching, an inherent feature in popular UI frameworks with state management capabilities such as React and MobX. This package provides the minimal batching/flushing mechanism that can be used in other contexts, and in framework agnostic ways.
Concept
Suppose we have the following entities:
- Action
A
calls handlerX
- Action
B
calls handlerX
- Action
C
calls handlerX
Suppose further that handler X
has the following characteristics:
- It is expensive to perform (like rendering, publishing, or other IO)
- If called in succession, the effect of the latter always and immediately neutralizes the utility of the effect of the former (again, like rendering and publishing most up to date information).
In this situation, calling actions A
, B
and C
in succession can
fairly quick blow-up in efficiency.
To overcome this problem, we throttle handler X
to fire only once in a
any given "transaction" Then, we define a "transaction bracket" which
includes calling A
, B
and C
. The following happens:
- Enter transaction
- Run action
- Call
A
.A
calls throttled handlerX
- Throttled handler
X
gets registered for flushing
- Throttled handler
- Call
B
.B
calls throttled handlerX
- Throttled handler
X
gets registered for flushing
- Throttled handler
- Call
C
.C
calls throttled handlerX
- Throttled handler
X
gets registered for flushing
- Throttled handler
- Call
- Flush each handler only once.
- Call
X
only once
- Call
- Run action
Example
import { bracket, throttle } from "throttle-bracket";
const X = throttle(() => console.log("I'm expensive."));
const A = () => { console.log("A called"); X(); };
const B = () => { console.log("B called"); X(); };
const C = () => { console.log("C called"); X(); };
bracket(() => { A(); B(); C(); })();
// logs synchronously:
// "A called"
// "B called"
// "C called"
// "I'm expensive."
Brackets should be put as close to the input side of IO as possible, while expensive operations ultimately reacting to these inputs should be throttled.
In React, all your on
* callbacks on components are essentially "bracketed"
deep in the framework, so it is trasparent, and multiple render requests are
batched and flushed (throttled) automatically. (Such is the wonder of frameworks!)
This package provides you the ability to the same thing elsewhere.
Bracket-free asynchronous transactions
If you don't mind that throttled callbacks are flushed asynchronously (with
Promise.resolve()
), you can use throttleAsync
. instead of throttle
.
The advantage is that it requries no bracket, because the synchronous window
implicitly makes up the transaction bracket. This is still sufficient for
effects requiring repaints in the browser, because an animation frame comes
after all promise resolutions.
Note that a throttleAsync
-wrapped function will ignore bracket
and will
still be flushed asynchronously even if called in a bracket
context.
import { throttleAsync } from "throttle-bracket";
const X = throttleAsync(() => console.log("I'm expensive."));
const A = () => { console.log("A called"); X(); };
const B = () => { console.log("B called"); X(); };
const C = () => { console.log("C called"); X(); };
// No bracketing needed
A(); B(); C();
// logs:
// "A called"
// "B called"
// "C called"
// Then asynchronously (await Promise.resolve()), you will get this:
// "I'm expensive."
Nested brackets
You can nest brackets. It is not consequential, which means you don't need to worry about whether terrible things would happen.
const fn1 = bracket(() => { A(); B(); C(); });
const fn2 = bracket(fn1);
fn2(); // all good!
Effect recursion
It can be that a throttled effect then invokes another throttled effect while itself being flushed. In this case, the flushing phase is itself a transactional bracket. So all such calls will be flushed synchronously and immediately in the next cycle, again and again.
Effect recursion is one way that the same throttled function may be called multiple times during a flush. The package will check if too many iterations have been reached, which would likely indicate unintended infinite synchronous recursion.
Causal isolation
It can be that the throttled effects are isolated into multiple "tiers" of causality. For instance, in the context of a UI, a set of effects updating some intermediate computations should be isolated from a set of effects that perform presentation. Without such isolation, the causally posterior set of effects would be mixed with the prior set, resulting in the posterior set being potentially called multiple times over multiple rounds of flushing.
To illustrate this, let's suppose that all f*
functions below are intermediate
computations and should be fully flushed before all g*
functions, that are
presentation functions.
import { throttle } from 'throttle-bracket'
const f1 = throttle(() => { g1(); f3(); f2(); });
const f2 = throttle(() => { f3(); g1(); g2(); });
const f3 = throttle(() => { g1(); g2(); });
const g1 = throttle(() => { /*... */ })
const g2 = throttle(() => { /*... */ })
First, let's imagine two alternative scenarios. The first scenario is without
throttle
at all. If we call f1()
, we would have calls in the following order:
- (
f1
) g1
f3
g1
g2
f2
f3
g1
g2
g1
g2
This results in multiple calls to g1
and g2
, which is highly undesirable.
The second scenario is to use throttle
everywhere:
- (
f1
) queuesg1
,f3
,f2
- flush (
g1
,f3
,f2
)g1
f3
queuesg1
,g2
f2
queuesf3
,g1
,g2
- flush (
g1
,g2
,f3
)g1
g2
f3
queuesg1
,g2
- flush (
g1
,g2
)g1
g2
- flush (
Because of the lack of isolation, the g*
functions are being queued multiple
times during recursive flushing. throttle
alone therefore cannot achieve
what we want, i.e. all f*
s call before all g*
s.
Isolation can be achieved like this:
// create a throttler at a "later" isolation level.
const throttleG = throttle.later();
const g1 = throttleG(() => { /*... */ })
const g2 = throttleG(() => { /*... */ })
throttle.later
creates a new throttler function that uses a "higher"
flush isolation level. All throttled functions of the "lower" isolation level
will first be flushed to stability, then the higher isolation level will flush.
throttle.later
itself can be used to create successively higher isolation
levels (throttle.later().later()
, and so on).
Now, with throttle.later()
, we have two isolation levels.
- (
f1
) queuesf3
,f2
into level 1, andg1
into level 2 ("later")- flush level 1 (
f3
,f2
)f3
queuesg1
,g2
into level 2f2
queuesf3
into level 1, andg1
,g2
into level 2
- flush level 1 (
f3
)f3
queuesg1
,g2
into level 2
- level one is now stable (nothing more to flush), so we move on
- flush level 2 (
g1
,g2
)g1
g2
- flush level 1 (
Here, invoking f1()
will guarantee that f2
and f3
will be fired before
g1
and g2
. Notably, g1
and g2
are now fired once only.
I don't like singletons
Since batching requires the use of some register of callbacks to be saved and then flushed, you may wonder where it that register lies, and (Oh Horror!), if the register is a singleton somewhere.
Using throttle
/bracket
/asyncThrottle
does makes use of the built-in
transaction system singletons provided by the package. It usually should be
good enough for most uses.
If, for whatever reason, you need multiple independent transaction systems (I'm not sure why you would), or you just feel irked by presumtuous frameworks providing singletons, you can instantiate transaction systems yourself:
import { sync, async } from 'throttle-bracket';
const [throttle, bracket] = sync();
const throttleAsync = async()