fix(dgraph): commit the transaction Mutate opens - #4158
aryanmehrotra wants to merge 5 commits into
Conversation
Client.Mutate opened a transaction, mutated, and returned without ever calling Commit or Discard. dgo copies CommitNow from the caller's mutation into the request (dgo v210 txn.go:157) and marks the transaction finished only when it is set (txn.go:206), so a caller who left the field unset had the write staged into a transaction that was then abandoned: nothing was persisted, err was nil, and the response was non-nil. The transaction also stayed open on the server until Dgraph timed it out. GoFr's own migration code was such a caller, which is one of the reasons Dgraph migrations never recorded anything. Mutate now commits before returning, and defers a Discard so the non-committing paths release the transaction. A mutation that does set CommitNow is left alone: dgo has already finished that transaction and Commit on a finished one returns ErrFinished (txn.go:239). Discard is a no-op once finished (txn.go:289), so the deferred call is safe on every path. Verified against a real Dgraph v21.03.0 — before: a mutation without CommitNow persisted 0 records; after: 1, with CommitNow unaffected.
PiyushSingh-ZS
left a comment
There was a problem hiding this comment.
Read through the fix and the tests. The bug is real and this is the right fix — a silent write loss with err == nil and a non-nil response is about the worst failure shape available, so thanks for chasing it down.
Verified
- The dgo mechanics check out:
CommitNowis copied from the caller's mutation into the request and the transaction is only marked finished when it is set. A caller who omitted it had the write staged into a transaction that was then abandoned. - The decisive point, which I think is worth making explicit in the PR description, is that
Client.Mutatenever hands the caller a transaction handle. So a caller who omittedCommitNowhad no way to commit later even in principle — abandoning the write was the only reachable outcome, not one of two valid behaviors. That makes committing the only defensible semantics rather than a judgment call, and it is the strongest argument that this is a fix rather than a behavior change. mutateInTxnhandles the four paths correctly: commit whenCommitNowis unset, skip it when set (dgo has already finished the transaction andCommitwould returnErrFinished),defer Discardas a no-op once finished, and a failingDiscardlogged rather than allowed to turn a committed write into an error.
The choice to assert on a recording fake rather than gomock expectations, because setupDB calls ctrl.Finish() before the test body runs and short-circuits the t.Cleanup verification, is a good catch — and flagging the broader fix as a follow-up rather than dragging a package-wide test refactor into a data-loss fix is the right scoping call.
Two small things
1. The docs edit slightly overstates it.
CommitNow: true, // Optional: Mutate commits the write either wayIt is not quite optional in the sense a reader will take it. With CommitNow: true the commit happens inside the mutation RPC; without it there is now a second Commit round trip. Both persist, but they are not equivalent, and someone reading "optional" may drop it from a hot path and pick up an extra RPC per mutation.
Maybe: // Optional: commits within the mutation RPC. Mutate commits either way.
2. Discard on a canceled context will log noise.
defer func() {
if err := txn.Discard(ctx); err != nil {
d.logger.Error("dgraph mutation transaction discard failed: ", err)
}
}()If the caller's context is canceled between the Commit returning and the deferred Discard, that logs an error for a write that succeeded. Cosmetic, since Discard is a no-op post-commit, but context.WithoutCancel(ctx) for the discard would keep the log clean.
Ordering
Worth noting for whoever merges: #4168 proposes compiling the Dgraph migrator out behind gofr_nodgraph, and touches the same call path. This one should land first.
Review follow-ups on #4158. Discard runs after Commit has already returned, so a request canceled in that window logged "dgraph mutation transaction discard failed: context canceled" for a write that was persisted -- an error line describing a success. The discard now takes context.WithoutCancel; it only ever aborts a transaction that is already being abandoned, so dropping the deadline there costs nothing. Also corrects the docs comment: CommitNow is optional for durability but not free, since without it Mutate makes a second Commit round trip. "Optional: Mutate commits the write either way" reads as "drop it", which would quietly add an RPC per mutation on a hot path. Test_Mutate_DiscardSurvivesCallerCancellation fails with the caller's context (context canceled) and passes with the detached one.
Unrelated to this PR: a golang.org/x/net go.mod hash the workspace picked up while the tests were run locally.
|
Both fixed at
Docs comment reworded to your suggestion: CommitNow: true, // Optional: commits within the mutation RPC. Mutate commits either way.Agreed the old wording read as "drop it", which would quietly add a On Ordering noted — this should land before #4168. |
Fixes #4157
Description
Client.Mutateopened a transaction, mutated, and returned — it never calledCommitorDiscard. Whether the write landed was decided by a field the caller set on the*api.Mutation.dgo copies
CommitNowfrom the caller's mutation into the request (dgo v210 txn.go:157) and marks the transaction finished only when it is set (txn.go:206). A caller who left it unset had the write staged into a transaction that was then abandoned to the garbage collector: nothing persisted,err == nil, and a non-nil response. The transaction also stayed open on the server until Dgraph timed it out.GoFr's own migration code was exactly such a caller (
pkg/gofr/migration/dgraph.goondevelopment, noCommitNow), which is one of the three reasons Dgraph migrations never recorded anything — see #3186.Changes
Mutatenow resolves the transaction it opens, in a smallmutateInTxnhelper so the logging/metrics wrapper is untouched:CommitNow;CommitNowmutation alone — dgo has already finished that transaction, andCommiton a finished one returnsErrFinished(txn.go:239);Discard, which is a no-op once the transaction is finished (txn.go:289), so it only releases the paths that did not reach a commit;Discardrather than turning a committed write into an error.Behaviour for callers who already set
CommitNow— including the documented example — is unchanged.Testing
Unit. New table test
Test_Mutate_TransactionHandling, five cases: commits withoutCommitNow, does not commit again with it, commit failure returned, mutate failure returned without committing, discard failure does not fail a committed write.Test_Mutate_DiscardSurvivesCallerCancellationcovers the deferred discard separately: it callsMutatewith an already-canceled context and asserts the context the discard receives is not done. It fails withcontext canceledif the discard takes the caller's context, which is what it did before review.It asserts on a recording fake rather than gomock expectations, on purpose.
setupDBcallsctrl.Finish()when it returns, which marks the controller finished before the test body starts, so thet.Cleanupverificationgomock.NewControllerinstalls short-circuits (mock v0.6.0 controller.go:268) and an unmet expectation is never reported. Counting calls asserts on what happened instead. Flagged as a follow-up below rather than fixed here — turning that verification back on surfaces over-specified expectations across most of the package's existing tests, which is a much larger diff than this fix.The tests fail without the fix. Reverting
mutateInTxntodevelopment's body fails all five subtests:End to end, against a real Dgraph v21.03.0. Two mutations through
Client, one withoutCommitNowand one with, then counted back with a query.drop_allbetween runs, 3 runs:developmentCommitNowCommitNowBoth calls returned
err=<nil>and a non-nil response ondevelopment— the write simply was not there.The e2e check needed a live Dgraph, so it is not committed; the two existing
Test_Mutate_*tests gained theDiscardexpectation the new code path requires.gofmt -lgo vet ./...go test ./... -count=1go test ./... -racegolangci-lint run ./...--new-from-rev)Why this is a fix and not a behaviour change
The transaction
Mutateopens is never handed to the caller.Mutatereturns(any, error)carrying the*api.Response— there is no path by which a caller who omittedCommitNowcould reach that transaction and commit it afterwards. Abandoning the write was the only reachable outcome, not one of two defensible ones, which is what makes committing the only sensible semantics rather than a judgement call between two.Manual transaction control is a separate, untouched path:
Client.NewTxn()andClient.NewReadOnlyTxn()hand back a transaction the caller drives themselves. Nothing here changes it —mutateInTxnopens and resolves its own.Breaking Changes
None. No exported signature changes. A caller who omitted
CommitNowwas losing the write silently; now it is written. A caller who set it sees no change.Follow-up, not in this PR
setupDBinpkg/gofr/datasource/dgraph/dgraph_test.gorunsctrl.Finish()before the test body, so gomock never reports a missing call for any test in the package. Removing it makes verification live and immediately fails several existing tests that declareLog/Debugfexpectations which never fire. Worth its own change.Checklist
gofmt.