Skip to content

Commit 901d1ce

Browse files
committed
Address PR #1749 review B1/B8/m1 — best-effort put_batch with short-circuit
- Switch put_batch/3 loop to reduce_while (B1 Path A) - Prepend + reverse effects accumulator (m1, removes O(n²)) - Walk back "atomic" claim in moduledoc, docstring, controller, guide - Replace toothless atomicity test with short-circuit proof (B8)
1 parent b8f60e2 commit 901d1ce

5 files changed

Lines changed: 83 additions & 32 deletions

File tree

guides/live_resource/user-preferences.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ the session; every setting is routed independently.
4949
│ Router → adapter(s) → side effects │
5050
│ │ │
5151
│ ▼ │
52-
All-or-nothing apply: {ok: true} or {ok: false, errors: […]}
52+
Best-effort apply: {ok: true} or {ok: false, error: {key, reason}}
5353
│ │
5454
└──────────────────────────────────────────────────────────────────────────┘
5555
```
@@ -204,10 +204,19 @@ Implement `Backpex.Preferences.Adapter`. Three callbacks:
204204
map}]` when you need the caller to update the session).
205205

206206
The side-effect protocol is what keeps adapters pure. They don't touch
207-
`Plug.Conn` — they describe what the caller should do. This is what lets the
208-
controller implement all-or-nothing batch writes and lets server-side code
207+
`Plug.Conn` — they describe what the caller should do. This is what lets
208+
the controller compose cross-adapter batch writes and lets server-side code
209209
dispatch the same adapters without an HTTP request.
210210

211+
Batch writes are **best-effort, first-error-wins**: on the first adapter
212+
error the dispatcher halts, returns `{:error, {key, reason}}`, and the
213+
controller responds `422 {ok: false, error: %{key: _, reason: _}}` without
214+
applying any session-backed side effects collected earlier in the batch.
215+
Adapters that persist eagerly (e.g. a DB-backed adapter that wrote via
216+
`Repo.insert!`) may have already committed earlier writes — the adapter
217+
behaviour has no rollback primitive, so callers should treat partial
218+
success as possible.
219+
211220
### In-memory test adapter
212221

213222
Useful when exercising preferences in integration tests without spinning up

lib/backpex/controllers/preferences_controller.ex

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@ defmodule Backpex.PreferencesController do
2121
{"key": "global.sidebar_open", "value": true}
2222
]}
2323
24-
The batch form is **all-or-nothing**: if any adapter refuses a write, no
25-
side effects are applied, the response is `422 {ok: false, errors: [...]}`,
26-
and the session cookie is left unchanged. A partial-success state for
27-
preferences is more confusing than a clean failure.
24+
The batch form is **best-effort, first-error-wins**: if any adapter refuses
25+
a write, the dispatcher halts at that entry, no further adapters are
26+
called, and the response is `422 {ok: false, error: %{key: _, reason: _}}`.
27+
Session-backed effects from earlier successful entries in the same batch
28+
are also dropped (the controller never applies them on the error path), so
29+
the session cookie is left unchanged. However, adapters that persist
30+
eagerly (e.g. a DB-backed adapter that wrote via `Repo.insert!`) may have
31+
already committed earlier writes — the adapter behaviour has no rollback
32+
primitive, so callers should treat partial success as possible.
2833
"""
2934

3035
use Phoenix.Controller, formats: [:json]
@@ -51,10 +56,10 @@ defmodule Backpex.PreferencesController do
5156
|> Preferences.apply_effects_on_conn(effects)
5257
|> json(%{ok: true})
5358

54-
{:error, errors} ->
59+
{:error, {key, reason}} ->
5560
conn
5661
|> put_status(422)
57-
|> json(%{ok: false, errors: Enum.map(errors, &format_error/1)})
62+
|> json(%{ok: false, error: format_error({key, reason})})
5863
end
5964
end
6065

lib/backpex/preferences.ex

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ defmodule Backpex.Preferences do
3939
* `get/3` — read a single preference.
4040
* `get_map/3` — read every value under a prefix as a nested map.
4141
* `put_async/4` — write from a LiveView socket or `%Plug.Conn{}`.
42+
* `put_batch/3` — dispatch a list of writes (best-effort, first-error-wins;
43+
see the function docs for the partial-success caveat).
4244
"""
4345

4446
alias Backpex.Preferences.Adapters
@@ -174,16 +176,25 @@ defmodule Backpex.Preferences do
174176

175177
@doc """
176178
Dispatches a batch of writes through their adapters and returns the
177-
collected side effects, or an error list (all-or-nothing).
179+
collected side effects, or the first error encountered.
178180
179-
Used by `Backpex.PreferencesController` to implement cross-adapter batch
180-
writes with a clean failure mode.
181+
Used by `Backpex.PreferencesController` to dispatch cross-adapter batch
182+
writes.
181183
182184
Threads the accumulated session state through each adapter call so that
183185
writes under the same session key compose correctly. The caller applies
184186
the returned effects in order; for `:put_session` effects targeting the
185187
same key, the last effect holds the fully-merged value.
186188
189+
## Semantics
190+
191+
This is **best-effort, first-error-wins**. On the first adapter that
192+
returns `{:error, reason}` the loop halts and returns
193+
`{:error, {key, reason}}` — subsequent entries are not dispatched. Earlier
194+
successful writes may already have been committed by their adapters (e.g.
195+
a DB-backed adapter that writes eagerly). The adapter behaviour has no
196+
rollback primitive, so callers should treat partial success as possible.
197+
187198
## Examples
188199
189200
ctx = Backpex.Preferences.Context.from_conn(conn)
@@ -195,26 +206,29 @@ defmodule Backpex.Preferences do
195206
#=> {:ok, [{:put_session, "backpex_preferences", %{...}}]}
196207
"""
197208
@spec put_batch(Context.t(), [{String.t(), term()}], keyword()) ::
198-
{:ok, [Backpex.Preferences.Adapter.side_effect()]} | {:error, [term()]}
209+
{:ok, [Backpex.Preferences.Adapter.side_effect()]} | {:error, {String.t(), term()}}
199210
def put_batch(%Context{} = ctx, entries, opts \\ []) when is_list(entries) do
200211
ctx = resolve_identity(ctx)
201212

202-
{effects, errors, _final_ctx} =
203-
Enum.reduce(entries, {[], [], ctx}, fn {key, value}, {effects_acc, errors_acc, current_ctx} ->
213+
# Accumulate effects by prepending each adapter's effects in reverse, then
214+
# reverse the whole list at the end — preserves the original left-to-right
215+
# order while staying O(n) in batch size.
216+
result =
217+
Enum.reduce_while(entries, {[], ctx}, fn {key, value}, {reversed_acc, current_ctx} ->
204218
{module, adapter_opts} = Router.resolve(key)
205219

206220
case module.put(current_ctx, key, value, merge_opts(adapter_opts, opts)) do
207221
{:ok, fx} ->
208-
{effects_acc ++ fx, errors_acc, apply_effects_to_ctx(current_ctx, fx)}
222+
{:cont, {:lists.reverse(fx, reversed_acc), apply_effects_to_ctx(current_ctx, fx)}}
209223

210224
{:error, reason} ->
211-
{effects_acc, [{key, reason} | errors_acc], current_ctx}
225+
{:halt, {:error, {key, reason}}}
212226
end
213227
end)
214228

215-
case errors do
216-
[] -> {:ok, effects}
217-
errs -> {:error, Enum.reverse(errs)}
229+
case result do
230+
{:error, _reason} = err -> err
231+
{reversed_acc, _ctx} -> {:ok, Enum.reverse(reversed_acc)}
218232
end
219233
end
220234

test/controllers/preferences_controller_test.exs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule Backpex.PreferencesControllerTest do
55

66
alias Backpex.Preferences
77
alias Backpex.PreferencesController
8+
alias Backpex.Test.InMemoryPreferencesAdapter, as: InMemory
89

910
setup do
1011
conn =
@@ -57,7 +58,7 @@ defmodule Backpex.PreferencesControllerTest do
5758
end
5859

5960
describe "update/2 with a batch" do
60-
test "persists all entries atomically", %{conn: conn} do
61+
test "persists every entry", %{conn: conn} do
6162
params = %{
6263
"preferences" => [
6364
%{"key" => "global.theme", "value" => "dark"},
@@ -108,14 +109,19 @@ defmodule Backpex.PreferencesControllerTest do
108109
end
109110
end
110111

111-
describe "update/2 with an adapter that refuses the write (all-or-nothing)" do
112+
describe "update/2 when an adapter refuses the write (best-effort, first-error-wins)" do
112113
setup do
113-
# Route :test prefix to a stub adapter that always errors.
114+
InMemory.reset()
114115
prior = Application.get_env(:backpex, Backpex.Preferences)
115116

117+
# Route:
118+
# ok.* → in-memory adapter (eager: writes to ETS on put/4)
119+
# fail.* → rejecting adapter (always errors)
120+
# :default → session
116121
Application.put_env(:backpex, Backpex.Preferences,
117122
adapters: [
118-
{"test.*", Backpex.PreferencesControllerTest.RejectingAdapter, []},
123+
{"ok.*", InMemory, []},
124+
{"fail.*", Backpex.PreferencesControllerTest.RejectingAdapter, []},
119125
{:default, Backpex.Preferences.Adapters.Session, []}
120126
]
121127
)
@@ -130,11 +136,16 @@ defmodule Backpex.PreferencesControllerTest do
130136
:ok
131137
end
132138

133-
test "returns 422 with {ok: false, errors: [...]} and leaves the session untouched", %{conn: conn} do
139+
test "halts at the first error and does not dispatch later entries (short-circuit)", %{conn: conn} do
140+
# Three-entry batch:
141+
# 1. ok.before → InMemory (succeeds, writes ETS row eagerly)
142+
# 2. fail.middle → Rejecting (fails)
143+
# 3. ok.after → InMemory (MUST NOT be dispatched — would write ETS row)
134144
params = %{
135145
"preferences" => [
136-
%{"key" => "global.theme", "value" => "dark"},
137-
%{"key" => "test.thing", "value" => "nope"}
146+
%{"key" => "ok.before", "value" => "committed"},
147+
%{"key" => "fail.middle", "value" => "nope"},
148+
%{"key" => "ok.after", "value" => "should_not_run"}
138149
]
139150
}
140151

@@ -143,9 +154,22 @@ defmodule Backpex.PreferencesControllerTest do
143154
assert conn.status == 422
144155
body = Jason.decode!(conn.resp_body)
145156
assert body["ok"] == false
146-
assert [%{"key" => "test.thing", "reason" => _reason}] = body["errors"]
157+
assert body["error"] == %{"key" => "fail.middle", "reason" => "rejected"}
158+
159+
stored = InMemory.dump()
160+
161+
# Path A: the earlier eager write committed — we do NOT claim rollback.
162+
assert Map.has_key?(stored, "ok.before")
163+
assert stored["ok.before"] == "committed"
164+
165+
# Short-circuit: the entry after the failure was never dispatched, so
166+
# its ETS row was never written. Under the old Enum.reduce/3 it would
167+
# have been written and this assertion would fail.
168+
refute Map.has_key?(stored, "ok.after")
147169

148-
# Because the batch failed, nothing was written — session is untouched.
170+
# Session-backed effects collected before the failure are never applied
171+
# (the controller only calls apply_effects_on_conn on the :ok branch),
172+
# so the cookie is untouched.
149173
assert Plug.Conn.get_session(conn, Preferences.session_key()) == nil
150174
end
151175
end

test/preferences_test.exs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,10 @@ defmodule Backpex.PreferencesTest do
9393
assert merged == %{"global" => %{"theme" => "dark", "sidebar_open" => true}}
9494
end
9595

96-
test "returns {:error, list} when any adapter refuses a write (all-or-nothing)" do
96+
test "returns {:error, {key, reason}} on the first adapter refusal (best-effort, first-error-wins)" do
9797
# :mount source → Session adapter returns :requires_http.
9898
ctx = Context.from_mount(%{})
99-
assert {:error, errors} = Preferences.put_batch(ctx, [{"global.theme", "dark"}])
100-
assert [{"global.theme", :requires_http}] = errors
99+
assert {:error, {"global.theme", :requires_http}} = Preferences.put_batch(ctx, [{"global.theme", "dark"}])
101100
end
102101
end
103102

0 commit comments

Comments
 (0)