Skip to content

Commit 8f9ffa8

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

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

lib/backup/boxbackup/boxbackup.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package boxbackup
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/mitchellh/mapstructure"
8+
9+
"github.com/tarantool/tt/cli/connector"
10+
"github.com/tarantool/tt/lib/backup"
11+
)
12+
13+
const defaultTTL = time.Hour
14+
15+
// StartOpts are the parameters of box.backup.start().
16+
type StartOpts struct {
17+
// FromVclock selects an incremental backup.
18+
FromVclock backup.Vclock // nil means a full backup.
19+
// TTL is the backup lease duration.
20+
TTL time.Duration
21+
}
22+
23+
// Info is the decoded result of box.backup.info().
24+
type Info struct {
25+
Files []string `json:"files"`
26+
Type backup.BackupType `json:"type"`
27+
VclockBegin backup.Vclock `json:"vclock_begin"`
28+
VclockEnd backup.Vclock `json:"vclock_end"`
29+
RecoveryPoints *[]backup.RecoveryPoint `json:"recovery_points"`
30+
}
31+
32+
// Start runs box.backup.start() on the instance.
33+
func Start(conn connector.Connector, opts StartOpts) error {
34+
ttl := opts.TTL
35+
if ttl == 0 {
36+
ttl = defaultTTL
37+
}
38+
39+
args := map[string]any{"ttl": ttl.Seconds()}
40+
if opts.FromVclock != nil {
41+
args["from_vclock"] = map[uint32]uint64(opts.FromVclock)
42+
}
43+
44+
_, err := conn.Eval("box.backup.start(...)", []any{args}, connector.RequestOpts{})
45+
if err != nil {
46+
return fmt.Errorf("failed to start backup: %w", err)
47+
}
48+
49+
return nil
50+
}
51+
52+
// GetInfo runs box.backup.info().
53+
func GetInfo(conn connector.Connector) (*Info, error) {
54+
res, err := conn.Eval("return box.backup.info()", []any{}, connector.RequestOpts{})
55+
if err != nil {
56+
return nil, fmt.Errorf("failed to get backup info: %w", err)
57+
}
58+
if len(res) == 0 || res[0] == nil {
59+
return nil, nil
60+
}
61+
62+
var info Info
63+
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
64+
TagName: "json",
65+
WeaklyTypedInput: true,
66+
Result: &info,
67+
})
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to parse backup info: %w", err)
70+
}
71+
if err := decoder.Decode(res[0]); err != nil {
72+
return nil, fmt.Errorf("failed to parse backup info: %w", err)
73+
}
74+
75+
return &info, nil
76+
}
77+
78+
// Stop runs box.backup.stop().
79+
func Stop(conn connector.Connector) error {
80+
_, err := conn.Eval("box.backup.stop()", []any{}, connector.RequestOpts{})
81+
if err != nil {
82+
return fmt.Errorf("failed to stop backup: %w", err)
83+
}
84+
85+
return nil
86+
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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_recoveryPointsStates(t *testing.T) {
122+
tests := []struct {
123+
name string
124+
resp map[any]any
125+
wantNil bool
126+
wantLen int
127+
wantFirst string
128+
}{
129+
{
130+
name: "absent maps to nil",
131+
resp: map[any]any{"type": "full"},
132+
wantNil: true,
133+
},
134+
{
135+
name: "empty list is non-nil and empty",
136+
resp: map[any]any{"type": "full", "recovery_points": []any{}},
137+
wantNil: false,
138+
wantLen: 0,
139+
},
140+
{
141+
name: "populated list is preserved",
142+
resp: map[any]any{
143+
"type": "full",
144+
"recovery_points": []any{map[any]any{"uuid": "rp-1"}},
145+
},
146+
wantNil: false,
147+
wantLen: 1,
148+
wantFirst: "rp-1",
149+
},
150+
}
151+
152+
for _, tc := range tests {
153+
t.Run(tc.name, func(t *testing.T) {
154+
// map[any]any shape (native net.box msgpack decoding).
155+
infoAny, err := GetInfo(&mockEvaler{ret: []any{tc.resp}})
156+
require.NoError(t, err)
157+
158+
// map[string]any shape (e.g. a decoder yielding string keys).
159+
infoStr, err := GetInfo(&mockEvaler{ret: []any{stringKeyed(tc.resp)}})
160+
require.NoError(t, err)
161+
162+
for _, info := range []*Info{infoAny, infoStr} {
163+
if tc.wantNil {
164+
require.Nil(t, info.RecoveryPoints, "absent field must map to nil")
165+
continue
166+
}
167+
require.NotNil(t, info.RecoveryPoints, "present list must be non-nil")
168+
require.Len(t, *info.RecoveryPoints, tc.wantLen)
169+
if tc.wantLen > 0 {
170+
require.Equal(t, tc.wantFirst, (*info.RecoveryPoints)[0].UUID)
171+
}
172+
}
173+
})
174+
}
175+
}
176+
177+
func stringKeyed(in map[any]any) map[string]any {
178+
out := make(map[string]any, len(in))
179+
for k, v := range in {
180+
if nested, ok := v.(map[any]any); ok {
181+
v = stringKeyed(nested)
182+
}
183+
if list, ok := v.([]any); ok {
184+
converted := make([]any, len(list))
185+
for i, item := range list {
186+
if nested, ok := item.(map[any]any); ok {
187+
converted[i] = stringKeyed(nested)
188+
} else {
189+
converted[i] = item
190+
}
191+
}
192+
v = converted
193+
}
194+
out[k.(string)] = v
195+
}
196+
return out
197+
}
198+
199+
func TestGetInfo_error(t *testing.T) {
200+
m := &mockEvaler{err: errors.New("boom")}
201+
202+
_, err := GetInfo(m)
203+
require.ErrorContains(t, err, "failed to get backup info")
204+
}
205+
206+
func TestStop_ok(t *testing.T) {
207+
m := &mockEvaler{}
208+
209+
require.NoError(t, Stop(m))
210+
require.Equal(t, "box.backup.stop()", m.expr())
211+
}
212+
213+
func TestStop_error(t *testing.T) {
214+
m := &mockEvaler{err: errors.New("boom")}
215+
216+
err := Stop(m)
217+
require.ErrorContains(t, err, "failed to stop backup")
218+
require.ErrorContains(t, err, "boom")
219+
}
220+
221+
func TestStartInfoStop(t *testing.T) {
222+
m := &mockEvaler{queue: [][]any{
223+
nil, // start()
224+
{map[any]any{ // info()
225+
"files": []any{"0.snap"},
226+
"type": "full",
227+
"recovery_points": []any{},
228+
}},
229+
nil, // stop()
230+
}}
231+
232+
require.NoError(t, Start(m, StartOpts{}))
233+
234+
info, err := GetInfo(m)
235+
require.NoError(t, err)
236+
require.Equal(t, backup.BackupTypeFull, info.Type)
237+
require.NotNil(t, info.RecoveryPoints)
238+
239+
require.NoError(t, Stop(m))
240+
require.Equal(t,
241+
[]string{"box.backup.start(...)", "return box.backup.info()", "box.backup.stop()"},
242+
m.exprs)
243+
}

0 commit comments

Comments
 (0)