Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffaa1f160a |
@@ -3,7 +3,6 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,24 +60,22 @@ func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
// The panic is the failure to report, so a rollback error
|
rollbackErr := tx.Rollback()
|
||||||
// is dropped.
|
if rollbackErr != nil {
|
||||||
_ = tx.Rollback()
|
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
panic(p)
|
panic(p)
|
||||||
|
} else if err != nil {
|
||||||
|
rollbackErr := tx.Rollback()
|
||||||
|
if rollbackErr != nil {
|
||||||
|
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = fn(ctx, tx)
|
err = fn(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Once ctx is cancelled, database/sql rolls the transaction
|
|
||||||
// back itself and Rollback returns sql.ErrTxDone.
|
|
||||||
rollbackErr := tx.Rollback()
|
|
||||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
|
||||||
return fmt.Errorf("%w; rolling back transaction: %w",
|
|
||||||
err, rollbackErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -236,125 +235,6 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func TestRepositoriesReadTransaction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// groupSeparator joins an open group path to an attribute key. This
|
// groupSeparator joins an open group path to an attribute key. This
|
||||||
@@ -304,18 +306,13 @@ func formatDuration(d time.Duration) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes formats bytes in a human-readable way
|
// formatBytes formats a byte count with humanize.Bytes, as every command
|
||||||
|
// prints one. humanize.Bytes takes no negative count, so one is printed
|
||||||
|
// as it is.
|
||||||
func formatBytes(b int64) string {
|
func formatBytes(b int64) string {
|
||||||
const unit = 1024
|
if b < 0 {
|
||||||
if b < unit {
|
|
||||||
return fmt.Sprintf("%d B", b)
|
return fmt.Sprintf("%d B", b)
|
||||||
}
|
}
|
||||||
|
|
||||||
div, exp := int64(unit), 0
|
return humanize.Bytes(uint64(b))
|
||||||
for n := b / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
|
|||||||
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const oneAndAHalfKiB = 1536
|
// Printed as "1.0 MB", in powers of 1000 like every other byte count.
|
||||||
|
const oneMegabyte = 1_000_000
|
||||||
|
|
||||||
for name, testCase := range map[string]struct {
|
for name, testCase := range map[string]struct {
|
||||||
derive func(*slog.Logger) *slog.Logger
|
derive func(*slog.Logger) *slog.Logger
|
||||||
@@ -223,13 +224,13 @@ func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
||||||
testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB)
|
testCase.derive(logger).Info("uploaded", "bytes", oneMegabyte)
|
||||||
|
|
||||||
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
||||||
|
|
||||||
assert.Contains(t, line, testCase.key+"=1.5 KB",
|
assert.Contains(t, line, testCase.key+"=1.0 MB",
|
||||||
"a byte count must be human-readable however it is qualified")
|
"a byte count must be human-readable however it is qualified")
|
||||||
assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB),
|
assert.NotContains(t, line, strconv.Itoa(oneMegabyte),
|
||||||
"the raw number must not survive the formatting")
|
"the raw number must not survive the formatting")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,22 +89,6 @@ type SnapshotInfo struct {
|
|||||||
RemotePresent *bool `json:"remote_present"`
|
RemotePresent *bool `json:"remote_present"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes formats bytes in a human-readable format
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if bytes < unit {
|
|
||||||
return fmt.Sprintf("%d B", bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSnapshotTimestamp extracts the timestamp from a snapshot ID
|
// parseSnapshotTimestamp extracts the timestamp from a snapshot ID
|
||||||
// Format: hostname_snapshotname_2026-01-12T14:41:15Z
|
// Format: hostname_snapshotname_2026-01-12T14:41:15Z
|
||||||
func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
v.stdoutf(" %s (%s, %s)\n",
|
v.stdoutf(" %s (%s, %s)\n",
|
||||||
snap.ID,
|
snap.ID,
|
||||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
formatBytes(snap.CompressedSize))
|
ubytes(snap.CompressedSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -553,8 +553,8 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
|||||||
|
|
||||||
if snap.LocallyTracked {
|
if snap.LocallyTracked {
|
||||||
id = snap.ID.String()
|
id = snap.ID.String()
|
||||||
uncompressed = formatBytes(snap.UncompressedSize)
|
uncompressed = ubytes(snap.UncompressedSize)
|
||||||
newChunks = formatBytes(snap.NewChunkSize)
|
newChunks = ubytes(snap.NewChunkSize)
|
||||||
} else {
|
} else {
|
||||||
id = formatRemoteOnlyID(snap.RemoteKey)
|
id = formatRemoteOnlyID(snap.RemoteKey)
|
||||||
uncompressed = remoteOnlyCell
|
uncompressed = remoteOnlyCell
|
||||||
@@ -564,7 +564,7 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
|||||||
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
id,
|
id,
|
||||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
formatBytes(snap.CompressedSize),
|
ubytes(snap.CompressedSize),
|
||||||
uncompressed,
|
uncompressed,
|
||||||
newChunks)
|
newChunks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ const (
|
|||||||
listConfiguredName = "home"
|
listConfiguredName = "home"
|
||||||
listLocalID = "testhost_home_2026-03-01T10:00:00Z"
|
listLocalID = "testhost_home_2026-03-01T10:00:00Z"
|
||||||
listRemoteID = "otherhost_media_2026-03-02T11:22:33Z"
|
listRemoteID = "otherhost_media_2026-03-02T11:22:33Z"
|
||||||
// fiveMegabytes formats as "5.0 MB" through formatBytes.
|
// fiveMegabytes is printed as "5.0 MB" by every command.
|
||||||
fiveMegabytes = 5 * 1024 * 1024
|
fiveMegabytes = 5_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestListSnapshots_RemoteWithoutSecretKey is the regression guard for
|
// TestListSnapshots_RemoteWithoutSecretKey is the regression guard for
|
||||||
@@ -306,6 +306,48 @@ func TestListSnapshots_RemoteOnlyRowRendering(t *testing.T) {
|
|||||||
"are not in the local index")
|
"are not in the local index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSnapshotListAndPurgePrintSizesLikeRemoteInfo checks that the
|
||||||
|
// `snapshot list` table and the purge listing print a byte count the way
|
||||||
|
// `remote info` does: in powers of 1000, as the MB label says.
|
||||||
|
func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const wantSize = "5.0 MB" // fiveMegabytes
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
// A remote-only snapshot: `snapshot list` and `remote info` both take
|
||||||
|
// its size from the manifest.
|
||||||
|
remoteKey := env.addRemote(t, listRemoteID,
|
||||||
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
||||||
|
|
||||||
|
// A locally tracked snapshot of the same size for the purge to list.
|
||||||
|
// The purge prints the snapshot's blob size, the last argument here.
|
||||||
|
start := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
env.addLocal(t, listLocalID, start)
|
||||||
|
env.addRemote(t, listLocalID, start)
|
||||||
|
require.NoError(t, env.v.Repositories.Snapshots.UpdateCounts(
|
||||||
|
context.Background(), nil, listLocalID, 0, 0, 0, 0, fiveMegabytes))
|
||||||
|
|
||||||
|
require.NoError(t, env.v.RemoteInfo(false))
|
||||||
|
assert.Contains(t, findTableRow(t, env.stdout.String(), remoteKey[:16]),
|
||||||
|
wantSize, "remote info")
|
||||||
|
|
||||||
|
env.stdout.Reset()
|
||||||
|
require.NoError(t, env.v.ListSnapshots(false))
|
||||||
|
assert.Contains(t, findTableRow(t, env.stdout.String(),
|
||||||
|
"<remote only:"+remoteKey[:12]+">"), wantSize, "snapshot list")
|
||||||
|
|
||||||
|
// Without --force and with nothing on stdin the purge lists what it
|
||||||
|
// would delete, reads no confirmation, and deletes nothing.
|
||||||
|
env.stdout.Reset()
|
||||||
|
require.NoError(t, env.v.PurgeSnapshotsWithOptions(
|
||||||
|
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}))
|
||||||
|
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
|
||||||
|
wantSize, "snapshot purge")
|
||||||
|
}
|
||||||
|
|
||||||
// findTableRow returns the single output line containing needle.
|
// findTableRow returns the single output line containing needle.
|
||||||
func findTableRow(t *testing.T, out, needle string) string {
|
func findTableRow(t *testing.T, out, needle string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
Reference in New Issue
Block a user