Last updated: 2019-06-04
Lets model stickmen ("users" if you must) as having bank balances. Our only state transition is to give an amount of money to a stickman. Naive approach may result in lost updates, little trace information, so we'll whip up an optimistic concurrency control with some debugging facilities to boot. First, the backbone of our data, a timeline of versions:
Our model stickman with a bank balance will transition from version 0 to 1,2,3,4,5,... . The content of each version will be stored in a versions table:
Retaining a few versions beyond the latest facilitates auditability, debugging, and overall sanity of having data change over time. There's a reason we use git for our precious source code, so anyone's "data" may be as important as that. Those familiar with "Event Sourcing" may find this approach similar in spirit, and compatible, but lighter, since we don't explicitly articulate events.
Now lets get to a test case, demonstrating the crux of the issue:
Our goal is to avoid a "lost update" anomaly. While this can be done with a pessimistic lock and without maintaining versions, lets play it grand, avoid lengthy locking, and get the upside of a kind-of explicit "time" that versions provide..
The trick above is to reduce the size of a locking / "critical section" to a small compare-and-swap. The example exaggerates the problem by having the `credit()` operation take an artificially long time. In the naive case without either optimistic or pessimistic lock, slow operation would cause a lost update. With a pessimistic lock, threads/servers are blocked waiting for a lock and the amount of concurrency is reduced. Optimistic production of the next version with a chance of failure (that triggers a retry) produces some wasted computations, but allows us to burn some cpu time for a chance at increased rate of change. Versions are produced from previous versions:
Using the dynamism of Ruby, we can serialize the transition from one version to the next, in a generic fashion. At it's best, this will allow us to debug the state of our objects by reproducing the state and the transition it undertook. If there's a bug on the read-side of the app, that's bad enough, but writing bad state is terrifying with a hint hopelessness of fixing the past.
Last but not least, we end up where all the indirection has pushed the domain-specific, meaningful body of the application. The model, it's state and transitions are captured in a PORO (Plain Old Ruby Object), something very clear and easily testable.
I hope you found this example of optimistic concurrency interesting. Please see another post for an extension of this technique, allowing us to model large objects, compacting them in Postgres rows.