Skip to content

Commit 0f5b9d9

Browse files
committed
backup: add boxbackup
Added impl of box.backup.start/info/stop over net.box. Closes TNTP-8389
1 parent 21c933d commit 0f5b9d9

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

lib/backup/boxbackup/boxbackup.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Package boxbackup is a thin net.box client over the core box.backup.* API.
2+
// It wraps start/info/stop and maps the info() response into Go types.
3+
// Recovery points are created by the vshard role; tt only reads them here.
4+
package boxbackup
5+
6+
import (
7+
"fmt"
8+
"time"
9+
10+
"github.com/mitchellh/mapstructure"
11+
12+
"github.com/tarantool/tt/cli/connector"
13+
"github.com/tarantool/tt/lib/backup"
14+
)
15+
16+
const defaultTTL = time.Hour
17+
18+
// StartOpts are the parameters of box.backup.start({from_vclock, ttl}).
19+
type StartOpts struct {
20+
// FromVclock selects an incremental backup, nil means a full backup.
21+
FromVclock backup.Vclock
22+
// TTL is the backup lease duration.
23+
TTL time.Duration
24+
}
25+
26+
// Info is the decoded result of box.backup.info().
27+
type Info struct {
28+
Files []string `json:"files"`
29+
Type backup.BackupType `json:"type"`
30+
VclockBegin backup.Vclock `json:"vclock_begin"`
31+
VclockEnd backup.Vclock `json:"vclock_end"`
32+
RecoveryPoints *[]backup.RecoveryPoint `json:"recovery_points"`
33+
}
34+
35+
// Start runs box.backup.start({) on the instance.
36+
func Start(conn connector.Connector, opts StartOpts) error {
37+
ttl := opts.TTL
38+
if ttl == 0 {
39+
ttl = defaultTTL
40+
}
41+
42+
args := map[string]any{"ttl": ttl.Seconds()}
43+
if opts.FromVclock != nil {
44+
args["from_vclock"] = map[uint32]uint64(opts.FromVclock)
45+
}
46+
47+
_, err := conn.Eval("box.backup.start(...)", []any{args}, connector.RequestOpts{})
48+
if err != nil {
49+
return fmt.Errorf("failed to start backup: %w", err)
50+
}
51+
52+
return nil
53+
}
54+
55+
// GetInfo runs box.backup.info().
56+
func GetInfo(conn connector.Connector) (*Info, error) {
57+
res, err := conn.Eval("return box.backup.info()", []any{}, connector.RequestOpts{})
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to get backup info: %w", err)
60+
}
61+
if len(res) == 0 || res[0] == nil {
62+
return nil, nil
63+
}
64+
65+
var info Info
66+
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
67+
TagName: "json",
68+
WeaklyTypedInput: true,
69+
Result: &info,
70+
})
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to parse backup info: %w", err)
73+
}
74+
if err := decoder.Decode(res[0]); err != nil {
75+
return nil, fmt.Errorf("failed to parse backup info: %w", err)
76+
}
77+
78+
// mapstructure cannot tell an absent field from an empty list, so the
79+
// recovery_points state is resolved by hand.
80+
raw, ok := res[0].(map[any]any)
81+
if ok {
82+
if _, present := raw["recovery_points"]; !present {
83+
info.RecoveryPoints = nil
84+
} else if info.RecoveryPoints == nil {
85+
info.RecoveryPoints = &[]backup.RecoveryPoint{}
86+
}
87+
}
88+
89+
return &info, nil
90+
}
91+
92+
// Stop runs box.backup.stop().
93+
func Stop(conn connector.Connector) error {
94+
_, err := conn.Eval("box.backup.stop()", []any{}, connector.RequestOpts{})
95+
if err != nil {
96+
return fmt.Errorf("failed to stop backup: %w", err)
97+
}
98+
99+
return nil
100+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package boxbackup
2+
3+
import (
4+
"errors"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/tarantool/tt/cli/connector"
11+
"github.com/tarantool/tt/lib/backup"
12+
)
13+
14+
type mockEvaler struct {
15+
exprs []string
16+
args []any
17+
ret []any
18+
queue [][]any
19+
err error
20+
calls int
21+
}
22+
23+
func (m *mockEvaler) Eval(expr string, args []any,
24+
opts connector.RequestOpts,
25+
) ([]any, error) {
26+
m.exprs = append(m.exprs, expr)
27+
m.args = args
28+
m.calls++
29+
if m.queue != nil {
30+
return m.queue[m.calls-1], m.err
31+
}
32+
33+
return m.ret, m.err
34+
}
35+
36+
func (m *mockEvaler) Close() error { return nil }
37+
38+
// expr returns the expression of the last recorded Eval call.
39+
func (m *mockEvaler) expr() string {
40+
if len(m.exprs) == 0 {
41+
return ""
42+
}
43+
44+
return m.exprs[len(m.exprs)-1]
45+
}
46+
47+
func TestStart_full(t *testing.T) {
48+
m := &mockEvaler{}
49+
50+
require.NoError(t, Start(m, StartOpts{}))
51+
require.Equal(t, "box.backup.start(...)", m.expr())
52+
53+
require.Len(t, m.args, 1)
54+
got := m.args[0].(map[string]any)
55+
require.Equal(t, defaultTTL.Seconds(), got["ttl"])
56+
_, hasFromVclock := got["from_vclock"]
57+
require.False(t, hasFromVclock, "full backup must not send from_vclock")
58+
}
59+
60+
func TestStart_incremental(t *testing.T) {
61+
m := &mockEvaler{}
62+
63+
err := Start(m, StartOpts{FromVclock: backup.Vclock{1: 42}})
64+
require.NoError(t, err)
65+
66+
got := m.args[0].(map[string]any)
67+
require.Equal(t, map[uint32]uint64{1: 42}, got["from_vclock"])
68+
}
69+
70+
func TestStart_customTTL(t *testing.T) {
71+
m := &mockEvaler{}
72+
73+
require.NoError(t, Start(m, StartOpts{TTL: 30 * time.Minute}))
74+
got := m.args[0].(map[string]any)
75+
require.Equal(t, float64(1800), got["ttl"])
76+
}
77+
78+
func TestStart_error(t *testing.T) {
79+
m := &mockEvaler{err: errors.New("boom")}
80+
81+
err := Start(m, StartOpts{})
82+
require.ErrorContains(t, err, "failed to start backup")
83+
require.ErrorContains(t, err, "boom")
84+
}
85+
86+
func TestGetInfo_noBackup(t *testing.T) {
87+
m := &mockEvaler{ret: []any{nil}}
88+
89+
info, err := GetInfo(m)
90+
require.NoError(t, err)
91+
require.Nil(t, info)
92+
}
93+
94+
func TestGetInfo_allFields(t *testing.T) {
95+
m := &mockEvaler{ret: []any{map[any]any{
96+
"files": []any{"0.snap", "0.xlog"},
97+
"type": "full",
98+
"vclock_begin": map[any]any{uint64(0): uint64(1)},
99+
"vclock_end": map[any]any{uint64(0): uint64(5), uint64(1): uint64(9)},
100+
"recovery_points": []any{map[any]any{
101+
"uuid": "rp-1", "replica_id": uint64(1),
102+
"lsn": uint64(7), "timestamp": uint64(123),
103+
}},
104+
}}}
105+
106+
info, err := GetInfo(m)
107+
require.NoError(t, err)
108+
require.Equal(t, []string{"0.snap", "0.xlog"}, info.Files)
109+
require.Equal(t, backup.BackupTypeFull, info.Type)
110+
require.Equal(t, backup.Vclock{0: 1}, info.VclockBegin)
111+
require.Equal(t, backup.Vclock{0: 5, 1: 9}, info.VclockEnd)
112+
require.NotNil(t, info.RecoveryPoints)
113+
require.Len(t, *info.RecoveryPoints, 1)
114+
rp := (*info.RecoveryPoints)[0]
115+
require.Equal(t, "rp-1", rp.UUID)
116+
require.Equal(t, uint32(1), rp.ReplicaID)
117+
require.Equal(t, uint64(7), rp.LSN)
118+
require.Equal(t, int64(123), rp.Timestamp)
119+
}
120+
121+
func TestGetInfo_recoveryPointsAbsent(t *testing.T) {
122+
m := &mockEvaler{ret: []any{map[any]any{"type": "full"}}}
123+
124+
info, err := GetInfo(m)
125+
require.NoError(t, err)
126+
require.Nil(t, info.RecoveryPoints, "absent field must map to nil")
127+
}
128+
129+
func TestGetInfo_recoveryPointsEmpty(t *testing.T) {
130+
m := &mockEvaler{ret: []any{map[any]any{
131+
"type": "full",
132+
"recovery_points": []any{},
133+
}}}
134+
135+
info, err := GetInfo(m)
136+
require.NoError(t, err)
137+
require.NotNil(t, info.RecoveryPoints, "empty list must be non-nil")
138+
require.Empty(t, *info.RecoveryPoints)
139+
}
140+
141+
func TestGetInfo_error(t *testing.T) {
142+
m := &mockEvaler{err: errors.New("boom")}
143+
144+
_, err := GetInfo(m)
145+
require.ErrorContains(t, err, "failed to get backup info")
146+
}
147+
148+
func TestStop_ok(t *testing.T) {
149+
m := &mockEvaler{}
150+
151+
require.NoError(t, Stop(m))
152+
require.Equal(t, "box.backup.stop()", m.expr())
153+
}
154+
155+
func TestStop_error(t *testing.T) {
156+
m := &mockEvaler{err: errors.New("boom")}
157+
158+
err := Stop(m)
159+
require.ErrorContains(t, err, "failed to stop backup")
160+
require.ErrorContains(t, err, "boom")
161+
}
162+
163+
func TestStartInfoStop(t *testing.T) {
164+
m := &mockEvaler{queue: [][]any{
165+
nil, // start()
166+
{map[any]any{ // info()
167+
"files": []any{"0.snap"},
168+
"type": "full",
169+
"recovery_points": []any{},
170+
}},
171+
nil, // stop()
172+
}}
173+
174+
require.NoError(t, Start(m, StartOpts{}))
175+
176+
info, err := GetInfo(m)
177+
require.NoError(t, err)
178+
require.Equal(t, backup.BackupTypeFull, info.Type)
179+
require.NotNil(t, info.RecoveryPoints)
180+
181+
require.NoError(t, Stop(m))
182+
require.Equal(t,
183+
[]string{"box.backup.start(...)", "return box.backup.info()", "box.backup.stop()"},
184+
m.exprs)
185+
}

0 commit comments

Comments
 (0)