check / check (push) Waiting to run
WithReadTx ended the process when its rollback failed, including after Ctrl-C, when database/sql has already rolled the transaction back and Rollback returns sql.ErrTxDone. It now handles the rollback as WithTx does. The query functions ended the process when closing their rows failed; they now ignore the deferred close, because database/sql reports a close failure through rows.Err(), which each returns, once the rows are read to the end. The unused database.Fatalf is removed. cleanupBlobTempFile returns its close or remove failure and the upload step returns it, so such a failure now fails the backup. No test makes a rows close fail: SQLite fails it only with the error the last read already returned. Model: opus-5-5
452 lines
10 KiB
Go
452 lines
10 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// errIntentionalRollback forces a transaction rollback in tests.
|
|
var errIntentionalRollback = errors.New("intentional rollback")
|
|
|
|
// createTxTestData returns a transaction body that creates a file with
|
|
// two chunks packed into one blob.
|
|
func createTxTestData(
|
|
repos *database.Repositories,
|
|
) func(context.Context, *sql.Tx) error {
|
|
return func(ctx context.Context, tx *sql.Tx) error {
|
|
file := &database.File{
|
|
Path: testTxFile,
|
|
MTime: time.Now().Truncate(time.Second),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, tx, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = createTxFileChunks(ctx, tx, repos, file.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return createTxBlob(ctx, tx, repos)
|
|
}
|
|
}
|
|
|
|
// createTxFileChunks creates the two test chunks and maps them to the file.
|
|
func createTxFileChunks(
|
|
ctx context.Context, tx *sql.Tx,
|
|
repos *database.Repositories, fileID types.FileID,
|
|
) error {
|
|
// Create chunks
|
|
chunk1 := &database.Chunk{
|
|
ChunkHash: types.ChunkHash("tx_chunk1"),
|
|
Size: 512,
|
|
}
|
|
|
|
err := repos.Chunks.Create(ctx, tx, chunk1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
chunk2 := &database.Chunk{
|
|
ChunkHash: types.ChunkHash("tx_chunk2"),
|
|
Size: 512,
|
|
}
|
|
|
|
err = repos.Chunks.Create(ctx, tx, chunk2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Map chunks to file
|
|
fc1 := &database.FileChunk{
|
|
FileID: fileID,
|
|
Idx: 0,
|
|
ChunkHash: chunk1.ChunkHash,
|
|
}
|
|
|
|
err = repos.FileChunks.Create(ctx, tx, fc1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fc2 := &database.FileChunk{
|
|
FileID: fileID,
|
|
Idx: 1,
|
|
ChunkHash: chunk2.ChunkHash,
|
|
}
|
|
|
|
return repos.FileChunks.Create(ctx, tx, fc2)
|
|
}
|
|
|
|
// createTxBlob creates the test blob and maps both chunks into it.
|
|
func createTxBlob(
|
|
ctx context.Context, tx *sql.Tx, repos *database.Repositories,
|
|
) error {
|
|
blob := &database.Blob{
|
|
ID: types.NewBlobID(),
|
|
Hash: types.BlobHash("tx_blob1"),
|
|
CreatedTS: time.Now().Truncate(time.Second),
|
|
}
|
|
|
|
err := repos.Blobs.Create(ctx, tx, blob)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Map chunks to blob
|
|
bc1 := &database.BlobChunk{
|
|
BlobID: blob.ID,
|
|
ChunkHash: types.ChunkHash("tx_chunk1"),
|
|
Offset: 0,
|
|
Length: 512,
|
|
}
|
|
|
|
err = repos.BlobChunks.Create(ctx, tx, bc1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bc2 := &database.BlobChunk{
|
|
BlobID: blob.ID,
|
|
ChunkHash: types.ChunkHash("tx_chunk2"),
|
|
Offset: 512,
|
|
Length: 512,
|
|
}
|
|
|
|
return repos.BlobChunks.Create(ctx, tx, bc2)
|
|
}
|
|
|
|
func TestRepositoriesTransaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := database.NewRepositories(db)
|
|
|
|
err := repos.WithTx(ctx, createTxTestData(repos))
|
|
if err != nil {
|
|
t.Fatalf("transaction failed: %v", err)
|
|
}
|
|
|
|
// Verify all data was committed
|
|
file, err := repos.Files.GetByPath(ctx, testTxFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to get file: %v", err)
|
|
}
|
|
|
|
if file == nil {
|
|
t.Error("expected file after transaction")
|
|
}
|
|
|
|
chunks, err := repos.FileChunks.GetByFile(ctx, testTxFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to get file chunks: %v", err)
|
|
}
|
|
|
|
if len(chunks) != 2 {
|
|
t.Errorf("expected 2 file chunks, got %d", len(chunks))
|
|
}
|
|
|
|
blob, err := repos.Blobs.GetByHash(ctx, "tx_blob1")
|
|
if err != nil {
|
|
t.Fatalf("failed to get blob: %v", err)
|
|
}
|
|
|
|
if blob == nil {
|
|
t.Error("expected blob after transaction")
|
|
}
|
|
}
|
|
|
|
func TestRepositoriesTransactionRollback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := database.NewRepositories(db)
|
|
|
|
// Test transaction rollback
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
// Create a file
|
|
file := &database.File{
|
|
Path: "/test/rollback_file.txt",
|
|
MTime: time.Now().Truncate(time.Second),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, tx, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create a chunk
|
|
chunk := &database.Chunk{
|
|
ChunkHash: types.ChunkHash("rollback_chunk"),
|
|
Size: 1024,
|
|
}
|
|
|
|
err = repos.Chunks.Create(ctx, tx, chunk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Return error to trigger rollback
|
|
return errIntentionalRollback
|
|
})
|
|
if !errors.Is(err, errIntentionalRollback) {
|
|
t.Fatalf("expected rollback error, got: %v", err)
|
|
}
|
|
|
|
// Verify nothing was committed
|
|
file, err := repos.Files.GetByPath(ctx, "/test/rollback_file.txt")
|
|
if err != nil {
|
|
t.Fatalf("error checking for file: %v", err)
|
|
}
|
|
|
|
if file != nil {
|
|
t.Error("file should not exist after rollback")
|
|
}
|
|
|
|
chunk, err := repos.Chunks.GetByHash(ctx, "rollback_chunk")
|
|
if err != nil {
|
|
t.Fatalf("error checking for chunk: %v", err)
|
|
}
|
|
|
|
if chunk != nil {
|
|
t.Error("chunk should not exist after rollback")
|
|
}
|
|
}
|
|
|
|
// When the context is cancelled during the body, database/sql rolls the
|
|
// transaction back itself, so WithTx's own rollback finds it already
|
|
// done. WithTx must return the body's error alone, without that rollback.
|
|
func TestRepositoriesTransactionContextCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
file := &database.File{
|
|
Path: "/test/cancelled_file.txt",
|
|
MTime: time.Now().Truncate(time.Second),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, tx, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cancel()
|
|
|
|
// database/sql rolls back from a goroutine of its own; wait
|
|
// until it has, so the test does not depend on that race.
|
|
for {
|
|
_, err = tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
|
if errors.Is(err, sql.ErrTxDone) {
|
|
return ctx.Err()
|
|
}
|
|
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
|
}
|
|
|
|
if errors.Is(err, sql.ErrTxDone) {
|
|
t.Fatalf("the already-done rollback was reported: %v", err)
|
|
}
|
|
|
|
file, err := repos.Files.GetByPath(
|
|
context.Background(), "/test/cancelled_file.txt",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("error checking for file: %v", err)
|
|
}
|
|
|
|
if file != nil {
|
|
t.Error("file should not exist after the cancelled transaction")
|
|
}
|
|
}
|
|
|
|
// A body that ends the transaction itself with ROLLBACK makes WithTx's
|
|
// own rollback fail. WithTx must return that failure together with the
|
|
// body's error.
|
|
func TestRepositoriesTransactionRollbackFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := database.NewRepositories(db)
|
|
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return errIntentionalRollback
|
|
})
|
|
if !errors.Is(err, errIntentionalRollback) {
|
|
t.Fatalf("expected the body's error, got: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "rolling back transaction") {
|
|
t.Errorf("expected the failed rollback in the error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// When the body ends the transaction itself with ROLLBACK and then
|
|
// panics, WithTx's own rollback fails; the panic must still reach the
|
|
// caller.
|
|
func TestRepositoriesTransactionPanicRollbackFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := database.NewRepositories(db)
|
|
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("expected the body's panic to reach the caller")
|
|
}
|
|
}()
|
|
|
|
_ = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
panic(errIntentionalRollback)
|
|
})
|
|
}
|
|
|
|
func TestRepositoriesReadTransaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := database.NewRepositories(db)
|
|
|
|
// First, create some data
|
|
file := &database.File{
|
|
Path: "/test/read_file.txt",
|
|
MTime: time.Now().Truncate(time.Second),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, nil, file)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file: %v", err)
|
|
}
|
|
|
|
// Test read-only transaction
|
|
var retrievedFile *database.File
|
|
|
|
err = repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var err error
|
|
|
|
retrievedFile, err = repos.Files.GetByPathTx(ctx, tx, "/test/read_file.txt")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Try to write in read-only transaction (should fail)
|
|
_ = repos.Files.Create(ctx, tx, &database.File{
|
|
Path: "/test/should_fail.txt",
|
|
MTime: time.Now(),
|
|
Size: 0,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
})
|
|
// SQLite might not enforce read-only at this level, but we test the pattern
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("read transaction failed: %v", err)
|
|
}
|
|
|
|
if retrievedFile == nil {
|
|
t.Error("expected to retrieve file in read transaction")
|
|
}
|
|
}
|
|
|
|
// When the context is cancelled during a read transaction's body,
|
|
// database/sql rolls the transaction back itself, so WithReadTx's own
|
|
// rollback finds it already done. WithReadTx must return the body's error
|
|
// alone, without that rollback.
|
|
func TestRepositoriesReadTransactionContextCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
err := repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
cancel()
|
|
|
|
// database/sql rolls back from a goroutine of its own; wait
|
|
// until it has, so the test does not depend on that race.
|
|
for {
|
|
_, err := tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
|
if errors.Is(err, sql.ErrTxDone) {
|
|
return ctx.Err()
|
|
}
|
|
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
|
}
|
|
|
|
if errors.Is(err, sql.ErrTxDone) {
|
|
t.Fatalf("the already-done rollback was reported: %v", err)
|
|
}
|
|
}
|