Optimistic Locking
View SourceOptimistic locking prevents lost updates when multiple processes try to modify the same record concurrently. Instead of acquiring database locks, it checks that the record has not changed since it was read.
How It Works
- Your schema has a
lock_versionfield (integer, defaults to0) - When you update,
optimistic_lock/2incrementslock_versionin the changeset - The generated
UPDATEquery includesWHERE lock_version = <old_value> - If another process updated the record first (changing
lock_version), theWHEREclause matches zero rows and Kura returns{error, stale}
Schema Setup
Add a lock_version integer field to your schema:
-module(my_product).
-behaviour(kura_schema).
-export([table/0, fields/0]).
table() -> ~"products".
fields() ->
#{
id => #{type => integer, primary_key => true},
name => #{type => string},
price => #{type => integer},
lock_version => #{type => integer, default => 0}
}.Make sure the corresponding migration includes the column:
kura_migration:create_table(~"products", [
{~"id", ~"serial PRIMARY KEY"},
{~"name", ~"text NOT NULL"},
{~"price", ~"integer NOT NULL"},
{~"lock_version", ~"integer NOT NULL DEFAULT 0"}
]).Using Optimistic Locking
Apply kura_changeset:optimistic_lock/2 to your changeset before updating:
update_price(Repo, Product, NewPrice) ->
CS = kura_changeset:cast(my_product, Product, #{~"price" => NewPrice}, [price]),
CS1 = kura_changeset:optimistic_lock(CS, lock_version),
kura_repo_worker:update(Repo, CS1).optimistic_lock/2 does two things:
- Puts a change that increments the lock field (e.g.,
lock_versiongoes from0to1) - Marks the changeset so that the repo worker generates a
WHERE lock_version = 0clause on the UPDATE
Handling Stale Records
When a conflict is detected, the update returns {error, stale}. You typically want to re-fetch the record and retry or inform the user:
case update_price(my_repo, Product, 2999) of
{ok, Updated} ->
Updated;
{error, stale} ->
%% Record was modified by another process since we read it.
%% Re-fetch and decide what to do.
{ok, Fresh} = kura_repo_worker:get(my_repo, my_product, maps:get(id, Product)),
%% Retry or return a conflict error to the caller
update_price(my_repo, Fresh, 2999)
end.Concurrent Update Example
Consider two processes reading the same product and then updating it:
Process A Process B
───────── ─────────
Read product (lock_version = 0)
Read product (lock_version = 0)
Update price to 2999
UPDATE ... SET lock_version=1
WHERE lock_version=0
→ {ok, Updated}
Update price to 3999
UPDATE ... SET lock_version=1
WHERE lock_version=0
→ {error, stale}
(no rows matched!)Process B's update fails because lock_version is now 1 in the database, not 0 as expected. This prevents Process B from silently overwriting Process A's change.
Important Notes
- The lock field can be any integer field, not just
lock_version-- pass whatever atom your schema uses tooptimistic_lock/2. - Optimistic locking only applies to updates. Inserts and deletes are unaffected.
- If you forget to call
optimistic_lock/2, the update proceeds without the lock check (standard behavior). - This approach works best when conflicts are rare. If conflicts are frequent, consider pessimistic locking (
SELECT ... FOR UPDATE) viakura_query:lock/2instead.