DEV Community

Daniel Neveux
Daniel Neveux

Posted on

Wizar devlog 01: Ravioli, ACID transaction implementation (almost)

Mutate the model is a critical task

Ravioli wraps all the model mutations in transaction. A few days ago I have refactored the Ravioli transaction system to make them ACID.

Why transaction?

Ravioli implements the SAM pattern. This pattern works as a cycle (a bit like the flux pattern).
During this cycle, there is a critical phase called the "proposal presentation" where mutations happen.

Here there is a crucial point of the SAM specs to implement:

The Model is solely responsible for the decision of accepting (or rejecting, or partially rejecting) these values

In Ravioli that means:

  • If some mutations happen, the cycle shall continue.
  • If an exception is thrown during the mutations, the step shall be canceled and the data of the model shall not change.

So this is a good candidate for transaction!

ACIDity

Let's check if my implementation respects the ACID principles and what are the benefits.

Atomicity

A transaction's changes to the model state are atomic: either all happen or none happen.

Indeed, in Ravioli the transaction just wraps a function.

STManager.transaction(() => {
  model.stats.health = 5
  model.stats.xp++
  model.inventory.push({id: 'potion', quantity: 2})
})
Enter fullscreen mode Exit fullscreen mode

Consistency

A transaction is a correct transformation of the model state.

In SAM, It is the responsibility of the model to accept, reject or throw to any changes.
Indeed, if something goes wrong during proposal acceptance (aka: if it throws) the step will be canceled and the previous data will be restored.

That makes the app less prone to freezing and crashes as it reduces the surface of risk. Exceptions can only happen in other areas (action or view)

Example. In this test, the player picks an object but an error occurs. The model data as not changed, the app can continue to run.
Note that: as the model did not change, the SAM pattern won't update the view.

test("State restauration after an exception during transaction", function() {
  const model = observable({
    name: "Fraktar",
    inventory: [
      { id: "sword", quantity: 1 },
      { id: "shield", quantity: 2 }
    ]
  })
  transaction(() => {
    // Try to add an item
    model.inventory.push({id: "potion", quantity: 1})
    // Sacrebleu!
    throw new Error("Muhahahaaaa")
  })

  // Model has not change
  expect(model.inventory.length).toBe(2)
})
Enter fullscreen mode Exit fullscreen mode

Isolation

The effects of an incomplete transaction might not even be visible to other transactions.

The transactions are executed synchronously. So it guarantees that:

  • parallel transaction execution is not possible.
  • if a transaction triggers another transaction, they will be merged together as a bigger transaction.
  • the outside world (eg. the view) will see the app in the previous step until the transaction execution is ended. (more on that in a next blog)
function pickLoot(loot) {
  transaction(function () {
    model.inventory.push(loot)
  })
}

function completeQuest() {
  model.stats.completeQuests++
  pickLoot({id: 'epicSword', quantity: 1}) // will merge this transaction in the current one.
})

STManager.transaction(completeQuest)
Enter fullscreen mode Exit fullscreen mode

Durability

Durability guarantees that once a transaction has been committed, it will remain committed even in the case of a system failure

Once a transaction completes successfully, its changes are saved as a patch (a rollback/forward command list) and a snapshot of the new model.
So this part may not be completely compliant to the ACID spec as the persistence is not implemented out of the box. It is up to the developer to store the snapshot in DB.

  const world = app.create([
    {
      name: 'Fraktar',
      stats: {
        health: 10,
        force: 4,
      },
      inventory: [{ id: 'sword', quantity: 1 }],
    },
  ])

  Manager.transaction(() => {
    world[0].inventory.push({
      id: 'shield',
      quantity: 1,
    })
  })

  expect(getSnapshot(world)).toEqual([
    {
      name: 'Fraktar',
      stats: {
        health: 10,
        force: 4,
      },
      inventory: [
        {
          id: 'sword',
          quantity: 1,
        },
        {
          id: 'shield',
          quantity: 1,
        },
      ],
    },
  ])
Enter fullscreen mode Exit fullscreen mode

Note that snapshots are immutable and relatively cheap to compute, thanks to a structural sharing. (need a blog about this too)

Limitations

So I think we can say that my implementations is near ACID, except for the non out of the box persistence.
However, some limitations exist and may be implemented if needed in the future.

  • No parallel execution.
  • No async transaction.

Top comments (2)

Collapse
 
bobylito profile image
Alexandre Valsamou-Stanislawski

Nice article! You might be interested in this video youtube.com/watch?v=xDuwrtwYHu8 about distributed transaction using saga. Maybe you've seen it already 😅

Collapse
 
dagatsoin profile image
Daniel Neveux

Thx, I will !