DEV Community

Cover image for What is cool about the ChronoGraph? Part #3 - Versatile atoms
Nickolay Platonov
Nickolay Platonov

Posted on

What is cool about the ChronoGraph? Part #3 - Versatile atoms

We continue the series of posts, describing the features of ChronoGraph which make it stand out among the other reactive systems. Previous post was about support for unlimited stack depth.

In this post we'll examine the properties of ChronoGraph atoms (called identifiers in ChronoGraph1).

Before we start, need to mention, that compared to other reactive systems, ChronoGraph approaches reactivity from a different angle. Most of other libraries are built with the UI creation use case in mind. ChronoGraph's main use case is computing the data set of the Gantt project plan. That is why ChronoGraph provides different feature set than other reactive libraries.

Atoms

Atom is a main building block of every reactive system. Usually, atom encapsulates a state and a function to calculate that state. While calculating the state, atom may read the state of other atoms and will automatically recompute when that state changes (atoms composes on read). State may also be provided by user directly.

Sync / Async

Most reactive systems supports only synchronous calculation functions. ChronoGraph supports both synchronous and asynchronous calculations.

The asynchronous calculations are supported in the form of generator-based functions. If such calculation yields a Promise - that promise is "awaited" before continuing the calculation.

Currently waiting for the promise resolution is blocking, which is, however, exactly what we need for our use case (which is computing a schedule of a Gantt project plan).

The use case for this feature is that sometimes, during calculation, we want to stop it, show modal dialog for the user and ask, how to continue, possibly by making some changes in the data. This is needed when some business logic rule is violated, we need to recover and can do it in several ways. In this case we don't want the calculations to continue and instead need the blocking behavior.

Codewise it looks like:

const identifier6 = Identifier.new({
    *calculation  () : ChronoIterator<number> {
        // blocking asynchronous point
        yield new Promise(resolve => setTimeout(resolve, 100))

        const value1 : number = yield identifier1

        return value1 + 5
    },
})

Lazy / Strict

ChronoGraph supports both lazy and strict calculations.

Other libraries for some reason do not talk much about the laziness of their calculations. For example if I'm correct S.js has only strict calculations. Mobx has lazy derived values and strict effectful "reactions", but no strict derivations.

Lazy calculations are usually what the user wants and they provide the best performance, avoiding unneeded re-computations, but there are use cases when they are not enough.

For example, you have a big graph of atoms, and you change one of them. This change will propagate across the graph and may trigger a business rule violation somewhere deep in the graph. In such case you want to reject the change that caused the violation.

Obviously, in such case, lazy calculation are not suitable, since you'll miss the requirement violation deep in the graph. All calculations, that reside on the path that may produce a requirement violation, needs to be strict.

The laziness of the atom is controlled with the lazy config:

const identifier6 = Identifier.new({
    lazy : false
})

Composable on read / Composable on write

We have briefly mentioned, that usually the reactive contract describes that atoms compose on the "read" operation, which is enough for UI creation use case.

However, when building our project planning system, we found a valid case, that would be easily solved if atom would compose on "write" too. Under "compose on write" I mean that atoms may write to other atoms during the calculation of their value.

I won't go into the details describing the exact use case this feature is solving. Just will mention, that it becomes useful, when a calculated value goes outside of the valid range (like negative task duration) and it is possibly to recover from this state by modifying some other atoms.

Now this may seem as breaking the "purity" requirement for the calculations. It would be breaking indeed, if not explicitly supported by the reactive system. If it is supported, "write" becomes a special effect.

So ChronoGraph's atoms composes both on "read" and "write". An illustrating test case:

    t.it('Base case - gen', async t => {
        const graph : ChronoGraph   = ChronoGraph.new()

        const var0      = graph.variableNamed('var0', 0)
        const var1      = graph.variableNamed('var1', 0)

        const varMax    = graph.variableNamed('varMax', 10)

        const idenSum   = graph.identifierNamed('idenSum', function* () {
            const sum : number  = (yield var0) + (yield var1)

            const max : number  = yield varMax

            if (sum > max) {
                yield Write(var0, (yield var0) - (sum - max))
            }

            return sum
        })

        const spy1      = t.spyOn(idenSum, 'calculation')

        //-------------------
        graph.commit()

        t.expect(spy1).toHaveBeenCalled(1)

        t.is(graph.read(idenSum), 0, 'Correct value')


        //-------------------
        spy1.reset()

        graph.write(var0, 5)
        graph.write(var1, 7)

        graph.commit()

        t.expect(spy1).toHaveBeenCalled(2)

        t.is(graph.read(idenSum), 10, 'Correct value')
        t.is(graph.read(var0), 3, 'Correct value')
    })

User input / Derived value

Classic reactive system normally includes 2 main primitives - user-provided value (data, box), and a calculated value (derived value, computation). For UI creation use case that's enough, however, for the use case of modeling some system's data layer - may introduce significant overhead.

Consider the extremely simplified project plan, which consists from a single task. That task has 3 atoms - start date, end date and duration, plus an invariant that end = start + duration.

Naturally, we want all 3 atoms to be writable - we do want user to be able to change any of the task field, and other fields should adapt. With this requirement we need at least 6 atoms for this system - 3 data atoms for user input and 3 calculation atoms for "adapted values". That's 2x more atoms.

In our case, a Gantt project plan with 10k tasks may have up to 500k atoms. Using separate data atoms to make all of them writable, would double that number and it would become 1M. As you can imagine, even that v8 is doing extremely good code optimization job, handling millions of atoms is a significant workload on browser.

Instead, we introduce "phantom" atoms, called "ProposedOrPrevious". Reading from this atom will return either a user-provided value, or a previous value of the current atom. Phantom atoms consist from few properties on the "main" atoms and do not consume much resources.

The calculation function may choose to not read from this
"phantom" atom - in this case the atom becomes a classic, pure "derived" value. It may also choose to read from phantom, and return the value unchanged - in this case it becomes classic pure "data" atom. And finally it may read from the phantom, somehow process the input and return the processed value - this may seem as "validated" user input.

To conclude, ChronoGraph atoms can dynamically change their type, based on other atoms value - from user input to a pure derived value, with the validated value in the middle.

Illustrating code:

        const graph : ChronoGraph   = ChronoGraph.new()

        const max       = graph.variableNamed('variable', 100)

        const var1      = graph.identifier(function * () : CalculationIterator<number> {
            const proposedValue : number    = yield ProposedOrPrevious

            const maxValue : number         = yield max

            return proposedValue <= maxValue ? proposedValue : maxValue
        })

        graph.write(var1, 18)

        t.is(graph.read(var1), 18, 'Correct value #1')

        //------------------
        graph.write(var1, 180)

        t.is(graph.read(var1), 100, 'Correct value #2')

Conclusion

As demonstrated, ChronoGraph atoms are very versatile, and can be used to model data domains with complex business logic.

Having a mixed type for both user-provided and derived value allows us to avoid doubling the atoms number. Composable on write atoms elegantly solves the edge case situations. And finally, if some business rule is violated and system can not recover itself, it can stop the calculation and ask the user how to continue, using the asynchronous calculations.

Our system proves that reactivity is not only applicable for the UI creation - it can be used for the data layer as well.

P.S. This week chronograph is minimalistic, 100% green-powered clock from 1789. Photo by debs-eye

Top comments (0)