throttle-bracket
TypeScript icon, indicating that this package has built-in type declarations

0.5.0 • Public • Published

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 handler X
  • Action B calls handler X
  • Action C calls handler X

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 handler X
          • Throttled handler X gets registered for flushing
      • Call B.
        • B calls throttled handler X
          • Throttled handler X gets registered for flushing
      • Call C.
        • C calls throttled handler X
          • Throttled handler X gets registered for flushing
    • Flush each handler only once.
      • Call X only once

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) queues g1, f3, f2
    • flush (g1, f3, f2)
      • g1
      • f3 queues g1, g2
      • f2 queues f3, g1, g2
    • flush (g1, g2, f3)
      • g1
      • g2
      • f3 queues g1, g2
    • flush (g1, g2)
      • g1
      • g2

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) queues f3, f2 into level 1, and g1 into level 2 ("later")
    • flush level 1 (f3, f2)
      • f3 queues g1, g2 into level 2
      • f2 queues f3 into level 1, and g1, g2 into level 2
    • flush level 1 (f3)
      • f3 queues g1, g2 into level 2
    • level one is now stable (nothing more to flush), so we move on
    • flush level 2 (g1, g2)
      • g1
      • g2

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()

Dependents (0)

Package Sidebar

Install

npm i throttle-bracket

Weekly Downloads

1

Version

0.5.0

License

MIT

Unpacked Size

31 kB

Total Files

41

Last publish

Collaborators

  • soul-codes