Controller-aware wrappers around the most-used :ets operations.
ETS itself is BEAM-level shared state -- the operations are atomic per-row but multiple operations don't compose atomically. The classic example is read-modify-write:
v = :ets.lookup_element(t, :counter, 2)
:ets.insert(t, {:counter, v + 1})Two processes running this concurrently can both read the same
v, then both write v + 1, losing one update. Under bare
:ets, this race depends on scheduler timing -- rare in tests,
common at scale. With Lockstep.ETS.lookup_element/3 +
Lockstep.ETS.insert/2, every operation is a sync point, so the
scheduler can interleave between A's lookup and A's insert. The
race surfaces on the first iteration that picks that interleaving.
Usage
Either call Lockstep.ETS.* directly, or let Lockstep.Rewriter
rewrite :ets.* calls in your code under use Lockstep.Test, rewrite: true or via the project-level Mix compiler.
The underlying :ets table itself is unchanged -- this module just
inserts a sync point before delegating. Your existing tables are
fully usable.
Coverage
We wrap the operations most likely to participate in races:
new/2,delete/1,delete_all_objects/1insert/2,insert_new/2lookup/2,lookup_element/3,lookup_element/4member/2delete/2,match_delete/2update_counter/3,update_counter/4,update_element/3select/2,match/2,match_object/2tab2list/1,info/1,info/2first/1,last/1,next/2,prev/2
Operations not in this list (e.g., :ets.give_away/3) are still
callable as :ets.give_away(...) directly -- they just don't yield
to the controller. File an issue if you need one wrapped.
Summary
Functions
Sync point + :ets.delete/1.
Sync point + :ets.delete/2.
Sync point + :ets.delete_all_objects/1.
Sync point + :ets.first/1.
Sync point + :ets.info/1.
Sync point + :ets.info/2.
Sync point + :ets.insert/2.
Sync point + :ets.insert_new/2.
Sync point + :ets.last/1.
Sync point + :ets.lookup/2.
Sync point + :ets.lookup_element/3.
Sync point + :ets.match/2.
Sync point + :ets.match_delete/2.
Sync point + :ets.match_object/2.
Sync point + :ets.member/2.
Sync point + :ets.new/2.
Sync point + :ets.next/2.
Sync point + :ets.prev/2.
Sync point + :ets.safe_fixtable/2.
Sync point + :ets.select/1 (continuation form).
Sync point + :ets.select/2.
Sync point + :ets.select/3.
Sync point + :ets.select_count/2.
Sync point + :ets.tab2list/1.
Sync point + :ets.take/2.
Sync point + :ets.update_counter/3.
Sync point + :ets.update_counter/4.
Sync point + :ets.update_element/3.
Functions
Sync point + :ets.delete/1.
Sync point + :ets.delete/2.
Sync point + :ets.delete_all_objects/1.
Sync point + :ets.first/1.
Sync point + :ets.info/1.
Sync point + :ets.info/2.
Sync point + :ets.insert/2.
Sync point + :ets.insert_new/2.
Sync point + :ets.last/1.
Sync point + :ets.lookup/2.
Sync point + :ets.lookup_element/3.
Sync point + :ets.lookup_element/4.
Sync point + :ets.match/2.
Sync point + :ets.match_delete/2.
Sync point + :ets.match_object/2.
Sync point + :ets.member/2.
Sync point + :ets.new/2.
Per-node isolation: when name is an atom AND the options request
:named_table, the underlying :ets.new is called WITHOUT
:named_table so BEAM doesn't claim the atom globally. The
resulting tid is registered with the controller against name on
the calling process's node, so subsequent lookups via atom name
resolve to the per-node tid. Returns name (matching real ETS
named-table behavior) so call sites that store the return value
continue to work.
Sync point + :ets.next/2.
Sync point + :ets.prev/2.
Sync point + :ets.safe_fixtable/2.
Sync point + :ets.select/1 (continuation form).
Sync point + :ets.select/2.
Sync point + :ets.select/3.
Sync point + :ets.select_count/2.
Sync point + :ets.tab2list/1.
Sync point + :ets.take/2.
Sync point + :ets.update_counter/3.
Sync point + :ets.update_counter/4.
Sync point + :ets.update_element/3.