ETS-based storage implementation using per-relation tables.
This backend stores facts in ETS tables (one per relation), providing
off-heap storage that reduces GC pressure for large fact sets (>100K tuples)
and enables concurrent read access. Tables are named ex_datalog_<relation>
for observability in :observer and iex.
Key design: wrapped tuples
ETS :set tables use the first element of a tuple as the key. For Datalog
facts like {:alice, :bob} and {:alice, :carol}, the key :alice would
collide — the second would overwrite the first. To avoid this, the ETS backend
wraps every fact tuple in a 1-element tuple: {{:alice, :bob}}. The wrapped
tuple's sole element — the entire original tuple — becomes the ETS key,
guaranteeing set semantics matching Storage.Map.
Choice of :set table type
This backend uses :set ETS tables rather than :ordered_set. Although
:ordered_set would provide sorted traversal for free, it imposes a total
ordering using term comparison that differs from Elixir's Enum.sort/1.
Using :set with explicit Enum.sort/1 in stream/2 guarantees the same
deterministic ordering as Storage.Map, which is essential for the
cross-backend conformance guarantee.
Trade-offs vs Storage.Map
- Memory: Data lives off-heap, avoiding costly copying of large fact sets during the semi-naive fixpoint loop.
- Read performance:
:ets.member/2and:ets.info/2are O(1) for size. Concurrent reads are supported via:read_concurrencywhen configured. - Write performance:
:ets.insert/2is O(1) for:settables. - Iteration:
stream/2returns a deterministically sorted list viaEnum.sort(:ets.tab2list(table)), ensuring consistent output across runs. - Lifecycle: ETS tables are owned by the creating process. Call
teardown/1to delete all tables when evaluation completes.
Determinism
ETS table iteration order is not guaranteed. This backend normalizes output
by sorting the result of stream/2 and relations/1, ensuring identical
programs produce identical results regardless of backend choice.
Options
The init/2 function accepts an optional keyword list:
:access— ETS access mode,:private(default) or:public.:publicenables:read_concurrencyfor concurrent read workloads.
Summary
Functions
Builds a hash index on the specified columns for a relation.
Returns the capabilities of this storage backend.
Retrieves tuples matching an indexed key.
Initializes ETS storage for the given relation schemas.
Inserts a single tuple into a relation.
Inserts an enumerable of tuples into a relation.
Checks whether a tuple exists in a relation.
Returns a sorted list of all relation names in the storage.
Returns the number of tuples stored in a relation.
Returns all tuples in a relation as a deterministically sorted list.
Deletes all ETS tables owned by this storage backend.
Incrementally merges delta tuples into an existing index.
Types
@type t() :: %ExDatalog.Storage.ETS{ indexes: %{ required({ExDatalog.Storage.relation_name(), ExDatalog.Storage.index_key()}) => table_ref() }, options: keyword(), schemas: ExDatalog.Storage.schemas(), tables: %{required(ExDatalog.Storage.relation_name()) => table_ref()} }
@type table_ref() :: :ets.table()
Functions
@spec build_index( t(), ExDatalog.Storage.relation_name(), ExDatalog.Storage.index_key() ) :: t()
Builds a hash index on the specified columns for a relation.
Creates a new ETS table mapping projected column values to lists of tuples.
Raises ArgumentError if relation is not in the schema.
@spec capabilities(t()) :: ExDatalog.Capabilities.t()
Returns the capabilities of this storage backend.
The ETS backend supports arithmetic and comparison constraints, provenance,
and indexed lookup. concurrent_reads reflects the configured access mode.
@spec get_indexed( t(), ExDatalog.Storage.relation_name(), ExDatalog.Storage.index_key(), ExDatalog.Storage.key_values() ) :: Enumerable.t()
Retrieves tuples matching an indexed key.
Returns tuples from a pre-built index that match the given key on the
specified columns. Returns [] if the index or key does not exist.
Must call build_index/3 before calling get_indexed/4 for the same
relation and columns. Results are sorted for deterministic ordering.
@spec init(ExDatalog.Storage.schemas()) :: t()
Initializes ETS storage for the given relation schemas.
Creates one ETS table per relation with :set type and wrapped-tuple keys
for correct multi-arity fact storage. Tables are created with the configured
access mode (default: :private).
Options
:access—:private(default) or:public. When:public,:read_concurrencyis enabled for concurrent read workloads.:write_concurrency—boolean(default:false). Whentrue,:write_concurrencyis enabled on each ETS table.
@spec init( ExDatalog.Storage.schemas(), keyword() ) :: t()
@spec insert(t(), ExDatalog.Storage.relation_name(), ExDatalog.Storage.tuple_values()) :: t()
Inserts a single tuple into a relation.
The tuple is wrapped as {tuple} before insertion so the entire tuple
becomes the ETS key. Idempotent: inserting a tuple that already exists
is a no-op (:set semantics with whole-tuple key).
Raises ArgumentError if relation is not in the schema.
@spec insert_many(t(), ExDatalog.Storage.relation_name(), Enumerable.t()) :: t()
Inserts an enumerable of tuples into a relation.
More efficient than repeated insert/3 calls for bulk loading.
Idempotent for individual tuples.
Raises ArgumentError if relation is not in the schema.
@spec member?( t(), ExDatalog.Storage.relation_name(), ExDatalog.Storage.tuple_values() ) :: boolean()
Checks whether a tuple exists in a relation.
Uses :ets.member/2 for O(1) lookup by the wrapped tuple key.
Returns true if the exact tuple is present, false otherwise.
Returns false if relation is unknown.
@spec relations(t()) :: [ExDatalog.Storage.relation_name()]
Returns a sorted list of all relation names in the storage.
@spec size(t(), ExDatalog.Storage.relation_name()) :: non_neg_integer()
Returns the number of tuples stored in a relation.
Returns 0 if relation is unknown.
@spec stream(t(), ExDatalog.Storage.relation_name()) :: Enumerable.t()
Returns all tuples in a relation as a deterministically sorted list.
Unwraps the stored {tuple} entries back to plain tuples and sorts
them via Enum.sort/1 to guarantee deterministic iteration order.
Returns [] if relation is unknown.
@spec teardown(t()) :: :ok
Deletes all ETS tables owned by this storage backend.
Must be called when evaluation completes to release ETS table resources.
After teardown/1, the state is tombstoned — subsequent operations will
raise ArgumentError with a clear message rather than crashing with
opaque :badarg from the deleted ETS tables.
This function is idempotent — calling it twice does not raise an error.
@spec update_index( t(), ExDatalog.Storage.relation_name(), ExDatalog.Storage.index_key(), Enumerable.t() ) :: t()
Incrementally merges delta tuples into an existing index.
If no index exists for the given relation and columns, one is built from the current relation data first, then the delta is merged in.
Raises ArgumentError if relation is not in the schema.