Repository navigation
fix(dgraph): commit the transaction Mutate opens - #4158
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. |
Umang01-hash
left a comment
There was a problem hiding this comment.
Verified against a real dgraph (v21.03): pre-fix, a Mutate without CommitNow returns no error but the write is gone on read-back ({"q":[]}); with this PR it persists. Cross-checked every dgo/v210 claim in the comments against the pinned source (finished-on-CommitNow, Commit->ErrFinished, Discard no-op-when-finished) — all accurate, and the if mutation.CommitNow guard is necessary. Matches the migration path's txn pattern, no API change, tests fail on revert, lint/race clean. Two minor notes below.
Review follow-up. The mutateInTxn comments pinned dgo v210's txn.go at lines 157/206/239/289 and gomock v0.6.0's controller.go at 268. Those are accurate against the pinned versions and rot silently on any bump, so they now name Txn.Mutate, Txn.Do, Txn.Commit, Txn.commitOrAbort and Controller.finish instead. Comments only; no behavior change.
Umang01-hash
left a comment
There was a problem hiding this comment.
Re-verified at the new head: both notes resolved (dgo line numbers replaced with method-name refs, all re-checked accurate vs dgo v210; behavior change documented in page.md). Delta is comment-only, so the earlier real-dgraph E2E still holds. Re-ran the dgraph module locally — gofmt clean, vet/build ok, go test -race passes at 71.7%, golangci-lint new-issues 0, interfaces.go unchanged (no API break). Good to merge.
The comment justified the deferred Discard as a no-op once the transaction is finished, but left open when it does fire. At the pinned dgo (v210.0.0-20230328113526-b66f8ae53a2d) every path out of mutateInTxn leaves the transaction finished before the defer runs: Txn.Do sets finished for a CommitNow mutation (txn.go:206), Txn.Commit reaches commitOrAbort which sets it (txn.go:292), and Txn.Do discards the transaction itself when the mutation fails (txn.go:215). mergeContext cannot fail on a fresh single-use transaction, so there is no fourth path. The discard therefore adds no round trip to a mutation; it is there so the transaction is still released if any of those paths stops finishing it. Say so, so the next reader does not have to re-derive it.
…rface Mutate now commits the transaction it opens, but the guarantee was only written on the dgraph package's implementation and in the datasource docs. The container.Dgraph interface is what handlers program against, so that is where the contract is read from. Point callers who want several mutations in one transaction at NewTxn, which is on the same interface.
akshat-kumar-singhal
left a comment
There was a problem hiding this comment.
Approve with nits. Checked against dgo at the pinned version: with CommitNow the txn is finished so Commit is skipped (no ErrFinished); without it commitOrAbort marks finished before the RPC so the deferred Discard is a no-op even on commit failure; commit conflicts (ErrAborted) now reach the caller instead of being dropped; migrations use NewTxn+Commit directly and are unaffected. Reverting dgraph.go fails Test_Mutate_TransactionHandling and Test_Mutate_DiscardSurvivesCallerCancellation.
Nit (arguably should-fix) — dgraph.go:265: context.WithoutCancel(ctx) also drops the deadline. Unreachable today, but the defer exists for a future dgo that doesn't finish the txn — then the discard RPC would have no timeout. Suggest context.WithTimeout(context.WithoutCancel(ctx), <few seconds>). (Relatedly, the "canceled request logged discard failed" scenario in 8503937 can only happen under the mock.)
Nit / design — dgraph.go:284: every non-CommitNow mutation now costs a second RPC. Same semantics in one RPC: send a shallow copy of the mutation with CommitNow = true (caller's proto untouched); the defer and branch go away.
Nit — docs/datasources/dgraph/page.md:117: "Optional … Mutate commits either way" invites dropping CommitNow, which costs the extra RPC. Suggest "Recommended: commits within the mutation RPC; without it Mutate issues a separate Commit."
Nit: the container.Dgraph interface doc now promises a commit for every implementation, but only GoFr's client enforces it. And 3cb0903's message says migrations call Mutate without CommitNow; on development they use NewTxn+Mutate+Commit.
Pre-existing (follow-up) — dgraph_test.go:24: setupDB runs defer ctrl.Finish() inside the helper, so missing gomock expectations are never reported in this package — the new Discard expectations in Test_Mutate_Success/Test_Mutate_Error pass with the fix reverted. The hand-written recordingTxn is the right workaround.
Umang01-hash
left a comment
There was a problem hiding this comment.
Deep-reviewed and independently verified at head a33664ea. Correct root-cause fix — Client.Mutate opened a txn and never resolved it, so a mutation without CommitNow staged the write into an abandoned txn (persisted nothing, err==nil, non-nil resp, txn left open). mutateInTxn now commits when CommitNow is unset, skips the redundant commit when it's set, and defers a context.WithoutCancel Discard so a canceled caller ctx can't make a committed write log a discard failure.
Verified:
- dgo v210 semantics in source: Mutate copies CommitNow (txn.go:157), Do finishes only when set (206), Commit-on-finished → ErrFinished (239), Discard-on-finished is a no-op (289). Every claim holds.
- Fail-on-revert: reverting
mutateInTxnfails all 5 subtests + the cancellation test. - Real Dgraph v21.03.0 E2E: no-CommitNow write persists 0 on
development, 1 with the fix; CommitNow path unchanged. - gofmt/vet/build clean,
-raceclean, golangci-lint 0 issues, coverage up. datasources.gois a godoc-only change — no signature change, not breaking. Migration uses the manualNewTxnpath (bypassesClient.Mutate), so no double-commit and no in-repo caller relied on the silent drop.
Tests are behavior-asserting (recording fake), and the gomock ctrl.Finish() early-verify gotcha is honestly documented as a follow-up. LGTM.
Resolve the conflicts with gofr-dev#4158 (commit the transaction Mutate opens) by keeping both changes: its doc comment and mutateInTxn call with this branch's named err result and deferred finishSpan, and its Discard expectation alongside the RecordHistogram one in Test_Mutate_Error. The span table's mutate rows now also expect the transaction Discard that mutateInTxn performs.
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.Release note
Important
dgraph.Client.Mutatenow persists writes that omitCommitNow. The signature is unchanged. A mutation sent withoutCommitNowpreviously returnederr == niland a non-nil response while persisting nothing and leaving the transaction open on the server until Dgraph timed it out; it is now committed beforeMutatereturns. Callers who already setCommitNoware unaffected. No action is required, but a caller who was relying on the silent drop as a dry run will now see the write land.Same-signature semantic changes do not show up in a diff review of the call sites, so this is stated here for the notes rather than only in the godoc on
Mutateand indocs/datasources/dgraph/page.md. There is noCHANGELOGin the repo — release notes are compiled from merged PRs — so the PR body is the only place this can be picked up from.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.