Typical web backends tend to process write requests the same way: they first read some data from the database, change it according to the incoming parameters in memory, and then write the changed data back to the database. This works great most of the time, but if write requests like this are happening at the same time, it is race-y!
If requests are coming in one at a time, each request will be processed in order and read the most up-to-date data from the database. But if two requests start around the same time, one can accidentally overwrite the other’s writes. The potential race sequence looks like this:
The second request reads data before the first request finishes writing to the database. This means that the second request is unaware of the changes made by the first request and will erase them! Yikes!
In most user-facing scenarios this is generally okay – if two users update a blog post’s title around the same time, one of them wins the race and the blog title is set. That’s fine because the result is the same as it would be if the two requests were processed at different times, although maybe a little strange for the user whose writing was overwritten. But in other situations, like account totals or analytics counters, dropped writes can really break stuff! The database builders have a solution for us though: atomic operations that are processed inside the database instead of in-memory.
Starting today, each Gadget app’s API supports atomic, in-database operations which avoid overlapping writes! Atomic operations are available under the `_atomics` key of any internal API update mutation and are consistent within Gadget’s existing action transactions.
For example, if we want to count the number of times a customer has placed an order on an e-commerce store, we can add an Order Count field to the Customer model and then atomically increment the counter when an order is placed:
Each call can pass multiple atomic operations for different fields and/or multiple operations for the same field. In a ticketing application, we could use atomic operations to increment the sold ticket count and decrement the available ticket count at the same time for example:
You can read more about the Atomics API in Gadget for an example app here.
Ensuring data consistency in the face of simultaneous writes is a deep topic with a lot of nuance – but Gadget makes it easy with these high-level, always-available primitives for fearlessly scaling your application.
At Gadget, we want to make building these highly concurrent systems easy so that others don’t need to spend a bunch of time carefully curating SQL queries to make their applications scale. Like most things related to scaling, there are tricks that folks have picked up over time to make things like counters just work, and that’s what we’ve used under the hood for Gadget’s atomic operations.
The trick here is to do the read and write at the same time, so there is no moment for any process to read stale data before writing it. Gadget implements counter adjustments as SQL statements that read the value they’re updating all in one statement like so:
If you squint, this is actually the same read-modify-write process that you might normally implement in your backend language of choice, just happening a lot faster because it’s all inside the one query execution. When run in a transaction, this update will still lock the tickets row until the transaction commits, but that lock is key for correctness.
Because of this locking, folks often turn to a datastore that supports higher throughput for similar counters, like Redis. After all, bumping a counter in Redis is as simple as the INCR command. The downside is that Redis tends to be configured to have far worse disaster recovery semantics than our old trusty tool, Postgres.
We think that this tradeoff of durability for performance is one that some of you will want to make as well! Keep an eye out for some not-so-far-away announcements from Gadget about using Redis to store your model data.