Skip to content

Commit bf6e8be

Browse files
committed
better error handling when using transaction, #47
1 parent 3428717 commit bf6e8be

File tree

7 files changed

+134
-30
lines changed

7 files changed

+134
-30
lines changed

lib/mongo.ex

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,21 @@ defmodule Mongo do
376376

377377
end
378378

379+
@doc """
380+
Executes an admin command against the `admin` database using alway the primary. Retryable writes are disabled.
381+
382+
## Example
383+
384+
iex> cmd = [
385+
configureFailPoint: "failCommand",
386+
mode: "alwaysOn",
387+
data: [errorCode: 6, failCommands: ["commitTransaction"], errorLabels: ["TransientTransactionError"]]
388+
]
389+
390+
iex> {:ok, _doc} = Mongo.admin_command(top, cmd)
391+
"""
379392
def admin_command(topology_pid, cmd) do
380-
with {:ok, doc} <- issue_command(topology_pid, cmd, :write, database: "admin", retryable_writes: false) do
381-
{:ok, doc}
382-
end
393+
issue_command(topology_pid, cmd, :write, database: "admin", retryable_writes: false)
383394
end
384395

385396
@doc """

lib/mongo/error.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,12 @@ defmodule Mongo.Error do
109109
false
110110
end
111111

112-
def has_label(%Mongo.Error{error_labels: []}, _label), do: false
113-
def has_label(%Mongo.Error{error_labels: labels}, label) do
112+
def has_label(%Mongo.Error{error_labels: labels}, label) when is_list(labels)do
114113
Enum.any?(labels, fn l -> l == label end)
115114
end
115+
def has_label(_other, _label) do
116+
false
117+
end
116118
end
117119

118120
defmodule Mongo.WriteError do

lib/mongo/session.ex

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,12 @@ defmodule Mongo.Session do
193193
@doc """
194194
Commit the current transation.
195195
"""
196-
@spec commit_transaction(Session.t) :: :ok | {:error, term()}
196+
@spec commit_transaction(Session.t, DateTime.t) :: :ok | {:error, term()}
197197
def commit_transaction(pid) do
198-
call(pid, :commit_transaction)
198+
call(pid, {:commit_transaction, DateTime.utc_now()})
199+
end
200+
def commit_transaction(pid, start_time) do
201+
call(pid, {:commit_transaction, start_time})
199202
end
200203

201204
@doc """
@@ -277,37 +280,65 @@ defmodule Mongo.Session do
277280
@doc """
278281
Convenient function for running multiple write commands in a transaction.
279282
283+
In case of `TransientTransactionError` or `UnknownTransactionCommitResult` the function will retry the whole transaction or
284+
the commit of the transaction. You can specify a timeout (`:transaction_retry_timeout_s`) to limit the time of repeating.
285+
The default value is 120 seconds. If you don't wait so long, you call `with_transaction` with the
286+
option `transaction_retry_timeout_s: 10`. In this case after 10 seconds of retrying, the function will return
287+
an error.
288+
280289
## Example
290+
281291
alias Mongo.Session
282292
283293
{:ok, ids} = Session.with_transaction(top, fn opts ->
284294
{:ok, %InsertOneResult{:inserted_id => id1}} = Mongo.insert_one(top, "dogs", %{name: "Greta"}, opts)
285295
{:ok, %InsertOneResult{:inserted_id => id2}} = Mongo.insert_one(top, "dogs", %{name: "Waldo"}, opts)
286296
{:ok, %InsertOneResult{:inserted_id => id3}} = Mongo.insert_one(top, "dogs", %{name: "Tom"}, opts)
287297
{:ok, [id1, id2, id3]}
288-
end, w: 1)
298+
end, transaction_retry_timeout_s: 10)
299+
300+
From the specs:
301+
302+
The callback function may be executed multiple times
303+
304+
The implementation of `with_transaction` is based on the original examples for Retry Transactions and
305+
Commit Operation from the MongoDB Manual. As such, the callback may be executed any number of times.
306+
Drivers are free to encourage their users to design idempotent callbacks.
289307
290308
"""
291309
@spec with_transaction(Session.t, (keyword() -> {:ok, any()} | :error)) :: {:ok, any()} | :error | {:error, term}
292310
def with_transaction(topology_pid, fun, opts \\ []) do
293-
294311
with {:ok, session} <- Session.start_session(topology_pid, :write, opts),
295-
:ok <- Session.start_transaction(session) do
296-
297-
with {:ok, result} <- run_function(fun, Keyword.merge(opts, session: session)),
298-
commit_result <- commit_transaction(session) do
312+
result <- run_in_transaction(topology_pid, session, fun, DateTime.utc_now(), opts),
313+
:ok <- end_session(topology_pid, session) do
314+
result
315+
end
316+
end
317+
def run_in_transaction(topology_pid, session, fun, start_time, opts) do
318+
with :ok <- Session.start_transaction(session),
319+
{:ok, result} <- run_function(fun, Keyword.merge(opts, session: session)),
320+
commit_result <- commit_transaction(session, start_time) do
299321

300-
end_session(topology_pid, session)
301-
case commit_result do
302-
:ok -> {:ok, result}
303-
error -> error
304-
end
305-
else
322+
## check the result
323+
case commit_result do
324+
:ok -> {:ok, result} ## everything is okay
306325
error ->
307-
abort_transaction(session)
308-
end_session(topology_pid, session)
326+
abort_transaction(session) ## the rest is an error
309327
error
310328
end
329+
else
330+
331+
{:error, error} ->
332+
abort_transaction(session) ## check in case of an error while processing transaction
333+
timeout = opts[:transaction_retry_timeout_s] || @retry_timeout_seconds
334+
case Error.has_label(error, "TransientTransactionError") && DateTime.diff(DateTime.utc_now(), start_time, :second) < timeout do
335+
true -> run_in_transaction(topology_pid, session, fun, start_time, opts)
336+
false -> {:error, error}
337+
end
338+
339+
other ->
340+
abort_transaction(session) ## everything else is an error
341+
{:error, other}
311342
end
312343
end
313344

@@ -316,7 +347,6 @@ defmodule Mongo.Session do
316347
#
317348
defp run_function(fun, opts) do
318349

319-
## todo wait max 120s
320350
try do
321351
fun.(opts)
322352
rescue
@@ -470,22 +500,28 @@ defmodule Mongo.Session do
470500
def handle_call_event({:bind_session, cmd}, _transaction, %Session{conn: conn}) do
471501
{:keep_state_and_data, {:ok, conn, cmd}}
472502
end
473-
def handle_call_event(:commit_transaction, :starting_transaction, _data) do
503+
def handle_call_event({:commit_transaction, _start_time}, :starting_transaction, _data) do
474504
{:next_state, :transaction_committed, :ok}
475505
end
476-
def handle_call_event(:commit_transaction, :transaction_in_progress, data) do
477-
with :ok <- run_commit_command(data) do
506+
def handle_call_event({:commit_transaction, start_time}, :transaction_in_progress, data) do
507+
with :ok <- run_commit_command(data, start_time) do
478508
{:next_state, :transaction_committed, :ok}
479509
else
480510
error -> {:keep_state_and_data, error}
481511
end
482512
end
513+
def handle_call_event({:commit_transaction, _start_time}, _state, _data) do ## in other cases we will ignore the commit command
514+
{:keep_state_and_data, :ok}
515+
end
483516
def handle_call_event(:abort_transaction, :starting_transaction, _data) do
484517
{:next_state, :transaction_aborted, :ok}
485518
end
486519
def handle_call_event(:abort_transaction, :transaction_in_progress, data) do
487520
{:next_state, :transaction_aborted, run_abort_command(data)}
488521
end
522+
def handle_call_event(:abort_transaction, _state, _data) do
523+
{:keep_state_and_data, :ok}
524+
end
489525
def handle_call_event(:connection, _state, %{conn: conn}) do
490526
{:keep_state_and_data, conn}
491527
end
@@ -523,8 +559,8 @@ defmodule Mongo.Session do
523559
##
524560
# Run the commit transaction command.
525561
#
526-
defp run_commit_command(session) do
527-
run_commit_command(session, DateTime.utc_now(), :first)
562+
defp run_commit_command(session, start_time) do
563+
run_commit_command(session, start_time, :first)
528564
end
529565

530566
defp run_commit_command(%Session{conn: conn,
@@ -544,7 +580,7 @@ defmodule Mongo.Session do
544580
lsid: %{id: id},
545581
txnNumber: %BSON.LongNumber{value: txn_num},
546582
autocommit: false,
547-
writeConcern: write_concern, ## todo: w:majority
583+
writeConcern: write_concern,
548584
maxTimeMS: max_time_ms(opts),
549585
recoveryToken: recovery_token
550586
] |> filter_nils()
@@ -553,7 +589,8 @@ defmodule Mongo.Session do
553589
:ok
554590
else
555591
{:error, error} ->
556-
try_again = Error.has_label(error, "UnknownTransactionCommitResult") && DateTime.diff(DateTime.utc_now(), time, :second) < @retry_timeout_seconds
592+
timeout = opts[:transaction_retry_timeout_s] || @retry_timeout_seconds
593+
try_again = Error.has_label(error, "UnknownTransactionCommitResult") && DateTime.diff(DateTime.utc_now(), time, :second) < timeout
557594
case try_again do
558595
true -> run_commit_command(session, time, :retry)
559596
false -> {:error, error}

lib/mongo/topology.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ defmodule Mongo.Topology do
7272

7373
def checkin_session(pid, server_session) do
7474
GenServer.cast(pid, {:checkin_session, server_session})
75+
:ok
7576
end
7677

7778
def stop(pid) do

test/mongo/session_test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ defmodule Mongo.SessionTest do
223223
Mongo.insert_one(top, coll, %{name: "Wuff"})
224224
Mongo.delete_many(top, coll, %{})
225225

226-
assert :error == Session.with_transaction(top, fn opts ->
226+
assert {:error, :error} == Session.with_transaction(top, fn opts ->
227227

228228
{:ok, %InsertOneResult{:inserted_id => id}} = Mongo.insert_one(top, coll, %{name: "Greta"}, opts)
229229
assert id != nil
@@ -233,6 +233,7 @@ defmodule Mongo.SessionTest do
233233
assert id != nil
234234
:error
235235
end, w: 1)
236+
236237
assert {:ok, 0} == Mongo.count(top, coll, %{})
237238
end
238239

test/mongo/transaction_retries_test.exs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,55 @@ defmodule Mongo.TransactionRetriesTest do
103103
end)
104104
end
105105

106+
test "with_transaction, retry commit timeout", %{pid: top, catcher: catcher} do
107+
108+
coll = unique_collection()
109+
110+
:ok = Mongo.create(top, coll)
111+
112+
assert {:error, %Mongo.Error{code: 6, error_labels: ["UnknownTransactionCommitResult"]}} = Session.with_transaction(top, fn opts ->
113+
{:ok, _} = Mongo.insert_one(top, coll, %{name: "Greta"}, opts)
114+
115+
cmd = [
116+
configureFailPoint: "failCommand",
117+
mode: "alwaysOn",
118+
data: [errorCode: 6, failCommands: ["commitTransaction"], errorLabels: ["UnknownTransactionCommitResult"]]
119+
]
120+
121+
{:ok, _doc} = Mongo.admin_command(top, cmd)
122+
123+
{:ok, []}
124+
end, transaction_retry_timeout_s: 2)
125+
126+
Mongo.admin_command(top, [configureFailPoint: "failCommand", mode: "off"])
127+
128+
assert [:configureFailPoint, :abortTransaction, :configureFailPoint, :insert, :create] = EventCatcher.succeeded_events(catcher) |> Enum.map(fn event -> event.command_name end)
129+
130+
end
131+
132+
test "with_transaction, retry transaction timeout", %{pid: top, catcher: catcher} do
133+
134+
coll = unique_collection()
135+
136+
:ok = Mongo.create(top, coll)
137+
138+
cmd = [
139+
configureFailPoint: "failCommand",
140+
mode: "alwaysOn",
141+
data: [errorCode: 6, failCommands: ["commitTransaction"], errorLabels: ["TransientTransactionError"]]
142+
]
143+
144+
{:ok, _doc} = Mongo.admin_command(top, cmd)
145+
146+
assert {:error, %Mongo.Error{code: 6, error_labels: ["TransientTransactionError"]}} = Session.with_transaction(top, fn opts ->
147+
{:ok, _} = Mongo.insert_one(top, coll, %{name: "Greta"}, opts)
148+
{:ok, []}
149+
end, transaction_retry_timeout_s: 2)
150+
151+
Mongo.admin_command(top, [configureFailPoint: "failCommand", mode: "off"])
152+
153+
assert [:configureFailPoint, :abortTransaction, :insert, :configureFailPoint, :create] = EventCatcher.succeeded_events(catcher) |> Enum.map(fn event -> event.command_name end)
154+
155+
end
156+
106157
end

test/support/collection_case.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule CollectionCase do
55

66
setup_all do
77
assert {:ok, pid} = Mongo.start_link(database: "mongodb_test", seeds: @seeds, show_sensitive_data_on_connection_error: true)
8+
Mongo.admin_command(pid, [configureFailPoint: "failCommand", mode: "off"])
89
Mongo.drop_database(pid)
910
{:ok, [pid: pid]}
1011
end

0 commit comments

Comments
 (0)