Repository navigation
feat(dbkit): add transaction runner #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bdce942
feat(dbkit): add transaction runner
KeuqS 041da9c
test(dbkit): check transaction runner error
KeuqS b0343b3
fix(dbkit): roll back an InTx whose body never returned
SirLouen 8ae0aeb
docs(dbkit): note InTx under Unreleased
SirLouen 577e87d
docs(dbkit): say InTx rolls back only when its body fails
SirLouen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package dbkit | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "errors" | ||
| ) | ||
|
|
||
| // InTx runs body in a write transaction on db, rolling back when body does not return nil and committing otherwise. | ||
| func InTx(ctx context.Context, db *sql.DB, body func(*sql.Tx) error) error { | ||
| tx, err := db.BeginTx(ctx, nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer func() { _ = tx.Rollback() }() | ||
| if err := body(tx); err != nil { | ||
| return rollbackAfter(err, tx) | ||
| } | ||
| if err := ended(ctx, tx.Commit()); err != nil { | ||
| return rollbackAfter(err, tx) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // rollbackAfter rolls tx back and returns err, joined with the rollback error when the rollback fails. | ||
| func rollbackAfter(err error, tx *sql.Tx) error { | ||
| if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | ||
| return errors.Join(err, rollbackErr) | ||
| } | ||
| return err | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package dbkit_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "errors" | ||
| "runtime" | ||
| "slices" | ||
| "testing" | ||
| "testing/synctest" | ||
| "time" | ||
|
|
||
| "github.com/gopherium/framework/dbkit" | ||
| ) | ||
|
|
||
| // intxTimeout is the deadline of the InTx tests whose driver holds a call until the context ends. | ||
| const intxTimeout = time.Second | ||
|
|
||
| // errBody is what a failing InTx body returns. | ||
| var errBody = errors.New("body failed") | ||
|
|
||
| // wantCalls fails t unless the fake driver received exactly ops, in order. | ||
| func wantCalls(t *testing.T, fake *fakeDriver, ops ...string) { | ||
| t.Helper() | ||
| if got := fake.log(); !slices.Equal(got, ops) { | ||
| t.Errorf("driver calls = %v, want %v", got, ops) | ||
| } | ||
| } | ||
|
|
||
| func TestInTxCommitsWhenBodySucceeds(t *testing.T) { | ||
| t.Parallel() | ||
| db, fake := shareHandle(t) | ||
|
|
||
| err := dbkit.InTx(t.Context(), db, func(*sql.Tx) error { return nil }) | ||
|
|
||
| if err != nil { | ||
| t.Errorf("InTx() error = %v, want nil", err) | ||
| } | ||
| wantCalls(t, fake, "begin", "commit") | ||
| } | ||
|
|
||
| func TestInTxRollsBackWhenBodyReturnsAnError(t *testing.T) { | ||
| t.Parallel() | ||
| db, fake := shareHandle(t) | ||
|
|
||
| err := dbkit.InTx(t.Context(), db, func(*sql.Tx) error { return errBody }) | ||
|
|
||
| if !errors.Is(err, errBody) { | ||
| t.Errorf("InTx() error = %v, want %v", err, errBody) | ||
| } | ||
| wantCalls(t, fake, "begin", "rollback") | ||
| } | ||
|
|
||
| func TestInTxRollsBackBeforeRethrowingAPanic(t *testing.T) { | ||
| t.Parallel() | ||
| db, fake := shareHandle(t) | ||
| want := "body panicked" | ||
|
|
||
| defer func() { | ||
| if got := recover(); got != want { | ||
| t.Errorf("panic = %v, want %q", got, want) | ||
| } | ||
| wantCalls(t, fake, "begin", "rollback") | ||
| }() | ||
| _ = dbkit.InTx(t.Context(), db, func(*sql.Tx) error { panic(want) }) | ||
| } | ||
|
|
||
| func TestInTxRollsBackWhenBodyNeverReturns(t *testing.T) { | ||
| t.Parallel() | ||
| db, fake := shareHandle(t) | ||
| done := make(chan struct{}) | ||
|
|
||
| go func() { | ||
| defer close(done) | ||
| _ = dbkit.InTx(t.Context(), db, func(*sql.Tx) error { | ||
| runtime.Goexit() | ||
| return nil | ||
| }) | ||
| }() | ||
| <-done | ||
|
|
||
| wantCalls(t, fake, "begin", "rollback") | ||
| } | ||
|
|
||
| func TestInTxAnswersTheEndOfItsContext(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| synctest.Test(t, func(t *testing.T) { | ||
| db, fake := shareHandle(t) | ||
| ctx, cancel := context.WithCancel(t.Context()) | ||
|
|
||
| err := dbkit.InTx(ctx, db, func(*sql.Tx) error { | ||
| cancel() | ||
| synctest.Wait() | ||
| return nil | ||
| }) | ||
|
|
||
| if !errors.Is(err, context.Canceled) { | ||
| t.Errorf("InTx() error = %v, want it to match context.Canceled", err) | ||
| } | ||
| wantCalls(t, fake, "begin", "rollback") | ||
| }) | ||
| } | ||
|
|
||
| func TestInTxAnswersABeginFailure(t *testing.T) { | ||
| t.Parallel() | ||
| db, fake := shareHandle(t) | ||
| fake.fail("") | ||
| ran := false | ||
|
|
||
| err := dbkit.InTx(t.Context(), db, func(*sql.Tx) error { | ||
| ran = true | ||
| return nil | ||
| }) | ||
|
|
||
| if !errors.Is(err, fakeBroken) { | ||
| t.Errorf("InTx() error = %v, want %v", err, fakeBroken) | ||
| } | ||
| if ran { | ||
| t.Error("the body ran, want it never to run once the begin failed") | ||
| } | ||
| } | ||
|
|
||
| func TestInTxAnswersACommitFailure(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| synctest.Test(t, func(t *testing.T) { | ||
| db, fake := shareHandle(t) | ||
| fake.holdCommit() | ||
| ctx, cancel := context.WithTimeout(t.Context(), intxTimeout) | ||
| defer cancel() | ||
|
|
||
| err := dbkit.InTx(ctx, db, func(*sql.Tx) error { return nil }) | ||
|
|
||
| wantFailedCommit(t, err) | ||
| wantCalls(t, fake, "begin", "commit") | ||
| }) | ||
| } | ||
|
|
||
| // wantFailedCommit fails t unless err is the driver's commit error marked with the deadline, never sql.ErrTxDone. | ||
| func wantFailedCommit(t *testing.T, err error) { | ||
| t.Helper() | ||
| if !errors.Is(err, fakeInterrupted) { | ||
| t.Errorf("InTx() error = %v, want the driver error %v", err, fakeInterrupted) | ||
| } | ||
| if !errors.Is(err, context.DeadlineExceeded) { | ||
| t.Errorf("InTx() error = %v, want it to match context.DeadlineExceeded", err) | ||
| } | ||
| if errors.Is(err, sql.ErrTxDone) { | ||
| t.Errorf("InTx() error = %v, want no %v joined", err, sql.ErrTxDone) | ||
| } | ||
| } | ||
|
|
||
| func TestInTxJoinsAFailedRollbackToTheBodysError(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| synctest.Test(t, func(t *testing.T) { | ||
| db, fake := shareHandle(t) | ||
| fake.holdRollback() | ||
| ctx, cancel := context.WithTimeout(t.Context(), intxTimeout) | ||
| defer cancel() | ||
|
|
||
| err := dbkit.InTx(ctx, db, func(*sql.Tx) error { return errBody }) | ||
|
|
||
| if !errors.Is(err, errBody) { | ||
| t.Errorf("InTx() error = %v, want the body's %v", err, errBody) | ||
| } | ||
| if !errors.Is(err, fakeInterrupted) { | ||
| t.Errorf("InTx() error = %v, want the rollback's %v joined", err, fakeInterrupted) | ||
| } | ||
| wantCalls(t, fake, "begin", "rollback") | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Do not claim that this path rolls back a failed commit.
When the driver's commit returns an error,
database/sqlhas already markedtxdone.rollbackAfterthen receivessql.ErrTxDone; it cannot call the driver's rollback method. This does not meet issue#80’s rollback-on-commit-failure requirement. The commit-failure test expects only"begin", "commit", so it does not establish that rollback occurred. Clarify the contract if a plain*sql.Txcannot provide the required guarantee, or use a transaction mechanism that can provide it. (go.dev)🤖 Prompt for AI Agents