Skip to content

Commit dab8354

Browse files
committed
[#7] Adapter-specific transaction support with ETS-based locking
1 parent db7c711 commit dab8354

File tree

17 files changed

+1892
-188
lines changed

17 files changed

+1892
-188
lines changed

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
elixir 1.19.4-otp-28
2-
erlang 28.2
2+
erlang 28.3

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
All notable changes to this project will be documented in this file.
4+
5+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [v3.0.0-rc.3](https://github.com/elixir-nebulex/nebulex_local/tree/v3.0.0-rc.3) (2025-12-28)
8+
> [Full Changelog](https://github.com/elixir-nebulex/nebulex_local/compare/v3.0.0-rc.2...v3.0.0-rc.3)
9+
10+
### Enhancements
11+
12+
- [Nebulex.Adapters.Local] Implemented adapter-specific transaction support using
13+
`Nebulex.Locks`, a lightweight ETS-based locking mechanism optimized for
14+
single-node scenarios. This replaces the previous reliance on `:global` for
15+
distributed locking, providing significantly better performance for local
16+
cache transactions while maintaining the same public API. The locks manager
17+
can be customized via the new `:lock_opts` configuration option (e.g.,
18+
`:cleanup_interval`, `:cleanup_batch_size`). This change aligns with the
19+
removal of the default transaction implementation from Nebulex core, allowing
20+
adapters to provide implementations tailored to their specific needs.
21+
[#7](https://github.com/elixir-nebulex/nebulex_local/issues/7).
22+
323
## [v3.0.0-rc.2](https://github.com/elixir-nebulex/nebulex_local/tree/v3.0.0-rc.2) (2025-12-07)
424
> [Full Changelog](https://github.com/elixir-nebulex/nebulex_local/compare/v3.0.0-rc.1...v3.0.0-rc.2)
525

lib/nebulex/adapters/local.ex

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ defmodule Nebulex.Adapters.Local do
2727
(see `Nebulex.Adapters.Local.Generation`).
2828
* Sharding - For intensive workloads, the Cache may also be partitioned
2929
(by using `:shards` backend and specifying the `:partitions` option).
30-
* Support for transactions via Erlang global name registration facility.
31-
See `Nebulex.Adapter.Transaction`.
30+
* Support for transactions via `Nebulex.Locks`, a lightweight ETS-based
31+
locking mechanism optimized for single-node scenarios. Provides atomic
32+
lock acquisition, deadlock prevention, and automatic stale lock cleanup.
33+
See `Nebulex.Locks` and `Nebulex.Adapter.Transaction`.
3234
* Support for stats.
3335
* Automatic retry logic for handling race conditions during garbage
3436
collection (see [Concurrency and resilience](#module-concurrency-and-resilience)).
@@ -745,11 +747,19 @@ defmodule Nebulex.Adapters.Local do
745747
746748
## Transaction API
747749
748-
This adapter inherits the default implementation provided by
749-
`Nebulex.Adapter.Transaction`. Therefore, the `transaction` command accepts
750-
the following options:
750+
This adapter implements the `Nebulex.Adapter.Transaction` behaviour using
751+
`Nebulex.Locks`, a lightweight ETS-based locking mechanism optimized for
752+
single-node scenarios. This implementation provides significantly better
753+
performance compared to distributed locking mechanisms (e.g., `:global`)
754+
while maintaining the same transaction API.
751755
752-
#{Nebulex.Adapter.Transaction.Options.options_docs()}
756+
The `transaction` command accepts the following options:
757+
758+
#{Nebulex.Locks.Options.options_docs()}
759+
760+
The locks manager can be customized via the `:lock_opts` configuration
761+
option when starting the cache. See the configuration options above for
762+
more details.
753763
754764
## Extended API (extra functions)
755765
@@ -775,9 +785,7 @@ defmodule Nebulex.Adapters.Local do
775785
@behaviour Nebulex.Adapter
776786
@behaviour Nebulex.Adapter.KV
777787
@behaviour Nebulex.Adapter.Queryable
778-
779-
# Inherit default transaction implementation
780-
use Nebulex.Adapter.Transaction
788+
@behaviour Nebulex.Adapter.Transaction
781789

782790
# Inherit default info implementation
783791
use Nebulex.Adapters.Common.Info
@@ -790,6 +798,8 @@ defmodule Nebulex.Adapters.Local do
790798

791799
alias Nebulex.Adapters.Common.Info.Stats
792800
alias Nebulex.Adapters.Local.{Backend, Generation, Metadata}
801+
alias Nebulex.Locks
802+
alias Nebulex.Locks.Options, as: LockOptions
793803
alias Nebulex.Time
794804

795805
## Types & Internal definitions
@@ -1315,6 +1325,61 @@ defmodule Nebulex.Adapters.Local do
13151325
%{total: max_size, used: mem_size}
13161326
end
13171327

1328+
## Nebulex.Adapter.Transaction
1329+
1330+
@impl true
1331+
def transaction(%{cache: cache, pid: pid} = adapter_meta, fun, opts) do
1332+
opts = LockOptions.validate!(opts)
1333+
1334+
adapter_meta
1335+
|> do_in_transaction?()
1336+
|> do_transaction(
1337+
pid,
1338+
adapter_meta[:name] || cache,
1339+
adapter_meta.meta_tab,
1340+
opts,
1341+
fun
1342+
)
1343+
end
1344+
1345+
@impl true
1346+
def in_transaction?(adapter_meta, _opts) do
1347+
wrap_ok do_in_transaction?(adapter_meta)
1348+
end
1349+
1350+
defp do_in_transaction?(%{pid: pid}) do
1351+
!!Process.get({pid, self()})
1352+
end
1353+
1354+
defp do_transaction(true, _pid, _name, _meta_tab, _opts, fun) do
1355+
{:ok, fun.()}
1356+
end
1357+
1358+
defp do_transaction(false, pid, name, meta_tab, opts, fun) do
1359+
locks_table = Metadata.fetch!(meta_tab, :locks_table)
1360+
keys = Keyword.fetch!(opts, :keys)
1361+
ids = lock_ids(name, keys)
1362+
1363+
case Locks.acquire(locks_table, ids, opts) do
1364+
:ok ->
1365+
try do
1366+
_ = Process.put({pid, self()}, keys)
1367+
1368+
{:ok, fun.()}
1369+
after
1370+
_ = Process.delete({pid, self()})
1371+
1372+
Locks.release(locks_table, ids)
1373+
end
1374+
1375+
{:error, :timeout} ->
1376+
wrap_error Nebulex.Error, reason: :transaction_aborted, cache: name
1377+
end
1378+
end
1379+
1380+
defp lock_ids(name, []), do: [name]
1381+
defp lock_ids(name, keys), do: Enum.map(keys, &{name, &1})
1382+
13181383
## Helpers
13191384

13201385
# Inline common instructions

lib/nebulex/adapters/local/backend.ex

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
defmodule Nebulex.Adapters.Local.Backend do
22
@moduledoc false
33

4+
alias Nebulex.Adapters.Local.Metadata
5+
46
@doc false
57
defmacro __using__(_opts) do
68
quote do
79
alias Nebulex.Adapters.Local.Generation
810

9-
defp generation_spec(opts) do
11+
defp generation_spec(opts, extra \\ []) do
12+
opts = parse_opts(opts, extra)
13+
1014
Supervisor.child_spec({Generation, opts}, id: Module.concat([__MODULE__, GC]))
1115
end
1216

17+
defp locks_spec(opts) do
18+
meta_tab = Keyword.fetch!(opts, :adapter_meta).meta_tab
19+
{lock_opts, _opts} = Keyword.pop!(opts, :lock_opts)
20+
21+
Supervisor.child_spec(
22+
{Nebulex.Locks,
23+
[init_callback: {unquote(__MODULE__), :init_callback, [meta_tab]}] ++ lock_opts},
24+
id: Module.concat([__MODULE__, Locks])
25+
)
26+
end
27+
1328
defp sup_spec(children) do
1429
%{
1530
id: Module.concat([__MODULE__, Supervisor]),
@@ -29,13 +44,13 @@ defmodule Nebulex.Adapters.Local.Backend do
2944

3045
backend_opts =
3146
[
32-
type,
3347
:public,
34-
{:keypos, 2},
35-
{:read_concurrency, Keyword.fetch!(opts, :read_concurrency)},
36-
{:write_concurrency, Keyword.fetch!(opts, :write_concurrency)},
48+
type,
3749
compressed,
38-
extra
50+
extra,
51+
keypos: 2,
52+
read_concurrency: Keyword.fetch!(opts, :read_concurrency),
53+
write_concurrency: Keyword.fetch!(opts, :write_concurrency)
3954
]
4055
|> List.flatten()
4156
|> Enum.filter(&(&1 != :named_table))
@@ -66,6 +81,15 @@ defmodule Nebulex.Adapters.Local.Backend do
6681
get_mod(backend).delete(meta_tab, gen_tab)
6782
end
6883

84+
@doc """
85+
Helper function for initializing the locks table.
86+
"""
87+
def init_callback(table, meta_tab) do
88+
Metadata.put(meta_tab, :locks_table, table)
89+
end
90+
91+
## Private functions
92+
6993
defp get_mod(:ets), do: Nebulex.Adapters.Local.Backend.ETS
7094

7195
if Code.ensure_loaded?(:shards) do

lib/nebulex/adapters/local/backend/ets.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ defmodule Nebulex.Adapters.Local.Backend.ETS do
66

77
@doc false
88
def child_spec(opts) do
9-
opts
10-
|> parse_opts()
11-
|> generation_spec()
12-
|> List.wrap()
9+
[
10+
locks_spec(opts),
11+
generation_spec(opts)
12+
]
1313
|> sup_spec()
1414
end
1515

lib/nebulex/adapters/local/backend/shards.ex

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ if Code.ensure_loaded?(:shards) do
4040
|> Keyword.fetch!(:adapter_meta)
4141
|> Map.fetch!(:meta_tab)
4242

43-
sup_spec([
43+
[
4444
{__MODULE__.DynamicSupervisor, meta_tab},
45-
generation_spec(parse_opts(opts, partitions: partitions))
46-
])
45+
locks_spec(opts),
46+
generation_spec(opts, partitions: partitions)
47+
]
48+
|> sup_spec()
4749
end
4850

4951
@doc false

lib/nebulex/adapters/local/generation.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,11 @@ defmodule Nebulex.Adapters.Local.Generation do
240240
|> Map.merge(adapter_meta)
241241
)
242242

243+
# Create a new generation
244+
:ok = new_gen(state)
245+
243246
# Timer ref
244-
{:ok, ref} =
245-
if state.gc_interval,
246-
do: {new_gen(state), start_timer(state.gc_interval)},
247-
else: {new_gen(state), nil}
247+
ref = if state.gc_interval, do: start_timer(state.gc_interval)
248248

249249
# Update state
250250
state = %{state | gc_heartbeat_ref: ref}
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,29 @@
11
defmodule Nebulex.Adapters.Local.Metadata do
22
@moduledoc false
33

4-
@type tab :: :ets.tid() | atom
4+
@type tab() :: :ets.tid() | atom()
55

6-
@spec init :: tab
6+
@spec init() :: tab()
77
def init do
88
:ets.new(__MODULE__, [:public, read_concurrency: true])
99
end
1010

11-
@spec get(tab, term, term) :: term
11+
@spec get(tab(), term(), term()) :: term()
1212
def get(tab, key, default \\ nil) do
1313
:ets.lookup_element(tab, key, 2)
1414
rescue
1515
ArgumentError -> default
1616
end
1717

18-
@spec fetch!(tab, term) :: term
18+
@spec fetch!(tab(), term()) :: term()
1919
def fetch!(tab, key) do
2020
:ets.lookup_element(tab, key, 2)
2121
end
2222

23-
@spec put(tab, term, term) :: :ok
23+
@spec put(tab(), term(), term()) :: :ok
2424
def put(tab, key, value) do
2525
true = :ets.insert(tab, {key, value})
26+
2627
:ok
2728
end
2829
end

lib/nebulex/adapters/local/options.ex

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,51 @@ defmodule Nebulex.Adapters.Local.Options do
144144
This grace period allows ongoing operations to complete before the
145145
generation is removed.
146146
"""
147+
],
148+
lock_opts: [
149+
type: :keyword_list,
150+
required: false,
151+
default: [],
152+
doc: """
153+
Options to customize the locks manager used for cache transactions.
154+
155+
The local adapter uses `Nebulex.Locks` for implementing transactions with
156+
an ETS-based locking mechanism optimized for single-node scenarios. This
157+
option allows you to customize the behavior of the locks manager, such as
158+
adjusting the cleanup interval or batch size for stale lock cleanup.
159+
160+
**Available Options:**
161+
162+
* `:cleanup_interval` - The interval in milliseconds for periodic
163+
cleanup of stale locks (defaults to 5 minutes).
164+
* `:cleanup_batch_size` - The number of locks to process per batch
165+
during cleanup (defaults to 100).
166+
167+
Note: The `:name` and `:init_callback` options are managed internally by
168+
the adapter and should not be provided. See the "Start Options" section
169+
in `Nebulex.Locks` for all available options.
170+
171+
**Examples:**
172+
173+
# Use default lock options (recommended for most cases)
174+
MyCache.start_link()
175+
176+
# Customize cleanup for high-throughput scenarios
177+
MyCache.start_link(
178+
lock_opts: [
179+
cleanup_interval: :timer.minutes(1),
180+
cleanup_batch_size: 500
181+
]
182+
)
183+
184+
# More frequent cleanup for memory-constrained environments
185+
MyCache.start_link(
186+
lock_opts: [
187+
cleanup_interval: :timer.seconds(30)
188+
]
189+
)
190+
191+
"""
147192
]
148193
]
149194

0 commit comments

Comments
 (0)