DEV Community

Cover image for Atomicity of Reactive State Changes
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Atomicity of Reactive State Changes

So far we have talked about emergency situations when calculating invariants. However, they can also arise on approach - during changes to several initial states at the same time. Let's look at what could go wrong here...

👻 Alone: One separate state
🦶 Base: Primary states only
🤼 Full: Whole app state

👻 Alone

As a rule, a change in one state is atomic everywhere. That is, it will either happen or it won’t happen. Let's consider a simple example: we need to update two states, but after updating the first one, an abnormal situation arose..

Name = 'John'
Count = 4

Name = 'Jin'
throw 'function is not a function'
Count = 3 // still 4
Enter fullscreen mode Exit fullscreen mode

The result is an inconsistent application state. After all, one state has been updated, but the second has not.

You can work around this problem by storing both values in the same state. But this is not always possible.

🦶 Base

It's good if the runtime supports transactions. They guarantee that either all initial states will receive their updates, and dependent states will be updated, or no one will change, and dependent states will not be updated either.

Name = 'John'
Count = 4

@transaction update() {
  this.Name = 'Jin' // will still 'John'
  throw 'function is not a function'
  this.Count = 3
}
Enter fullscreen mode Exit fullscreen mode

🤼 Full

In some libraries, a transaction can be rolled back not only by exceptions that occurred directly when changes were made, but also by exceptions in invariants that were calculated as a result of the changes made.

Name = 'John'
Count = 4

@derived get Greeting() {
  // Fails on 'Jin' name
  return this.Name.split('')[3].toUppercase()
}

@transaction update() {
  this.Name = 'Jin' // will still 'John'
  this.Count = 3 // will still 4
}
Enter fullscreen mode Exit fullscreen mode

In the example, we have a secondary state Greeting, which, with a short name, throws an exception and cannot be calculated. Runtime, seeing this, rolls back the entire transaction. As a result, we again get a situation where one crooked view somewhere in the corner of the application does not allow us to update the model and the entire application comes to a standstill.

What to choose?

As long as we work with the memory state, we have complete control over it, implementing any form of transaction. But applications that live only in RAM are of little use. And when the source of truth is taken to external storage, everything becomes much more fun. Especially if these are several heterogeneous storages, work with which cannot be combined into a transaction.

For example, let's take a simple case: an application for taking personal notes based on the zettelkasten. All data is stored in local storage, with each note under a separate key, so as not to update the entire database for every sneeze.

Now let's look at one of the main scenarios: the mutual connection of two notes with each other. To do this, you need to consistently update both notes by linking them. But the problem is, if we just write to the first one, and while writing the second one something unexpected happens (for example, the storage limit is reached), then we will get an inconsistent state: one note considers the other one to be associated with it, but the second one doesn’t know anything about it knows.

If we implement transactions only at the reactive system level, then if an error occurs, the state will, of course, return to internal consistency. But the consistency with the repository will be broken. That is, we will deceive the user that everything is fine, but when the application is reloaded, the inconsistency will return again. It turns out that we did not solve, but only aggravated the problem.

Some libraries offer to move all side effects into separate tasks. That is, first the state in memory is atomically updated, and then it is synchronized with the storage. The same problem is observed here - the interface shows that the transaction was completed, but in fact it either did not pass, or only partially passed.

We can try to rollback changes in memory if the side effect has fallen, but this can make the situation even worse, since the completed transaction becomes visible to the rest of the application and by the time the rollback affects the operation of many other tasks. Rolling back this avalanche of changes is difficult and often even harmful.

The same problem arises when trying to rollback the state of the storage, in the case when another transaction with its change got stuck between the storage change and its rollback. In this case, we will roll back not only our changes, but also, suddenly, those of others. Conversely, another transaction may undo our rollback.

In addition, not all side effects can be reversed. For example, the “launch a rocket” effect cannot be canceled after it has been launched. At best, it can be blown up in the air, but we will still lose the missile.

It turns out that in trying to solve the consistency problem, we create a bunch of tricky logic that creates situations that are increasingly difficult to debug, but in the end we still end up with inconsistency. And since we can’t win, let’s lead the way: we admit that rollbacks are generally not possible, so all changes are either applied and immediately visible to everyone, or they are not applied and we get an error message when writing.

Thus, we get an extremely simple architecture and a logic of operation that is extremely clear to the application programmer. Yes, the external state may suddenly become inconsistent. But it can become like this for many reasons beyond our control. But even in this case, the application must be able to “survive”.

For example, in the case of zettelkasten, you can show not everything in a row in the list of related notes, but only those with which there is a mutual connection. That is, it will look like a “transaction rollback” despite the fact that there was essentially no rollback, and the database is generally in an inconsistent state.

I'm not sure this is the best solution. Perhaps in the future it will be possible to solve the issue of the atomicity of many changes so that this solution brings more benefit than harm. But for now, this remains fertile ground for further research.

Top comments (0)