Skip to content

Commit 3f9e8f5

Browse files
Shivaji KharseShivaji Kharse
authored andcommitted
imporve lsbackup
1 parent 2da01c5 commit 3f9e8f5

12 files changed

Lines changed: 2204 additions & 47 deletions

File tree

backup/run.go

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/url"
1414
"os"
1515
"path/filepath"
16+
"strings"
1617
"time"
1718

1819
"github.com/golang/glog"
@@ -49,7 +50,11 @@ var opt struct {
4950
destination string
5051
format string
5152
verbose bool
52-
upgrade bool // used by export backup command.
53+
upgrade bool // used by export backup command
54+
sinceDate string // date-range filter lower bound (YYYY-MM-DD or RFC3339)
55+
untilDate string // date-range filter upper bound
56+
lastNDays int // shorthand: last N calendar days
57+
summary bool // print summary stats after the listing
5358
}
5459

5560
func init() {
@@ -158,7 +163,15 @@ func initBackupLs() {
158163
flag.StringVarP(&opt.location, "location", "l", "",
159164
"Sets the source location URI (required).")
160165
flag.BoolVar(&opt.verbose, "verbose", false,
161-
"Outputs additional info in backup list.")
166+
"Show groups and drop operations. Reads the full manifest.json instead of the lightweight summary.")
167+
flag.StringVar(&opt.sinceDate, "since-date", "",
168+
"Only show backups on or after this date (YYYY-MM-DD or RFC3339).")
169+
flag.StringVar(&opt.untilDate, "until-date", "",
170+
"Only show backups on or before this date (YYYY-MM-DD or RFC3339).")
171+
flag.IntVar(&opt.lastNDays, "last-n-days", 0,
172+
"Only show backups from the last N calendar days.")
173+
flag.BoolVar(&opt.summary, "summary", false,
174+
"Print summary statistics after the backup listing.")
162175
_ = LsBackup.Cmd.MarkFlagRequired("location")
163176
}
164177

@@ -247,11 +260,70 @@ func runRestoreCmd() error {
247260
return nil
248261
}
249262

263+
// parseFilterDate parses a date string in YYYY-MM-DD or RFC3339 format.
264+
func parseFilterDate(s string) (time.Time, error) {
265+
if s == "" {
266+
return time.Time{}, fmt.Errorf("date string must not be empty")
267+
}
268+
if t, err := time.Parse("2006-01-02", s); err == nil {
269+
return t.UTC(), nil
270+
}
271+
t, err := time.Parse(time.RFC3339, s)
272+
return t.UTC(), err
273+
}
274+
275+
// buildLsFilter constructs a BackupDateFilter from lsbackup flag values.
276+
// It validates flag combinations and returns an error for invalid inputs.
277+
func buildLsFilter(sinceDate, untilDate string, lastNDays int) (worker.BackupDateFilter, error) {
278+
filter := worker.BackupDateFilter{}
279+
if lastNDays < 0 {
280+
return filter, fmt.Errorf("--last-n-days must be a positive integer, got %d", lastNDays)
281+
}
282+
if lastNDays > 0 && sinceDate != "" {
283+
return filter, fmt.Errorf("--last-n-days and --since-date are mutually exclusive")
284+
}
285+
if lastNDays > 0 {
286+
since := time.Now().UTC().AddDate(0, 0, -lastNDays).Truncate(24 * time.Hour)
287+
filter.Since = &since
288+
}
289+
if sinceDate != "" {
290+
t, err := parseFilterDate(sinceDate)
291+
if err != nil {
292+
return filter, fmt.Errorf("invalid --since-date value %q: %w", sinceDate, err)
293+
}
294+
filter.Since = &t
295+
}
296+
if untilDate != "" {
297+
t, err := parseFilterDate(untilDate)
298+
if err != nil {
299+
return filter, fmt.Errorf("invalid --until-date value %q: %w", untilDate, err)
300+
}
301+
var end time.Time
302+
if strings.Contains(untilDate, "T") {
303+
// RFC3339 datetime: user gave an exact timestamp, respect it.
304+
end = t
305+
} else {
306+
// Plain date (YYYY-MM-DD): extend to end of that calendar day.
307+
end = t.Add(24*time.Hour - time.Millisecond)
308+
}
309+
filter.Until = &end
310+
}
311+
return filter, nil
312+
}
313+
250314
func runLsbackupCmd() error {
251-
manifests, err := worker.ListBackupManifests(opt.location, nil)
315+
// Validate and build the date filter before doing any I/O.
316+
filter, err := buildLsFilter(opt.sinceDate, opt.untilDate, opt.lastNDays)
317+
if err != nil {
318+
return err
319+
}
320+
321+
// --verbose reads the full manifest.json to include groups and drop-ops.
322+
manifests, err := worker.ListBackupManifests(opt.location, nil, opt.verbose)
252323
if err != nil {
253324
return fmt.Errorf("while listing manifests: %w", err)
254325
}
326+
manifests = worker.FilterManifestsByDate(manifests, filter)
255327

256328
type backupEntry struct {
257329
Path string `json:"path"`
@@ -269,7 +341,6 @@ func runLsbackupCmd() error {
269341

270342
var output backupOutput
271343
for _, manifest := range manifests {
272-
273344
be := backupEntry{
274345
Path: manifest.Path,
275346
Since: manifest.SinceTsDeprecated,
@@ -291,6 +362,29 @@ func runLsbackupCmd() error {
291362
}
292363
_, _ = os.Stdout.Write(b)
293364
fmt.Println()
365+
366+
if opt.summary {
367+
stats := worker.ComputeBackupListStats(manifests)
368+
if opt.sinceDate != "" || opt.untilDate != "" || opt.lastNDays > 0 {
369+
fmt.Fprintf(os.Stderr, "\n--- Backup Summary (filtered) ---\n")
370+
} else {
371+
fmt.Fprintf(os.Stderr, "\n--- Backup Summary ---\n")
372+
}
373+
fmt.Fprintf(os.Stderr, "Total backups listed : %d\n", stats.Total)
374+
fmt.Fprintf(os.Stderr, "Backup series : %d\n", stats.BackupSeriesCount)
375+
if stats.OldestBackup != nil {
376+
fmt.Fprintf(os.Stderr, "Oldest backup : %s\n", stats.OldestBackup.Format(time.RFC3339))
377+
}
378+
if stats.NewestBackup != nil {
379+
fmt.Fprintf(os.Stderr, "Newest backup : %s\n", stats.NewestBackup.Format(time.RFC3339))
380+
}
381+
if stats.LastFullBackup != nil {
382+
fmt.Fprintf(os.Stderr, "Last full backup : %s\n", stats.LastFullBackup.Format(time.RFC3339))
383+
}
384+
if stats.LastIncrBackup != nil {
385+
fmt.Fprintf(os.Stderr, "Last incr backup : %s\n", stats.LastIncrBackup.Format(time.RFC3339))
386+
}
387+
}
294388
return nil
295389
}
296390

backup/run_test.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package backup
7+
8+
import (
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/dgraph-io/dgraph/v25/worker"
15+
)
16+
17+
func TestParseFilterDate(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
input string
21+
wantErr bool
22+
wantYear int
23+
wantMonth time.Month
24+
wantDay int
25+
wantHour int
26+
wantMin int
27+
}{
28+
{
29+
name: "YYYY-MM-DD",
30+
input: "2026-04-15",
31+
wantYear: 2026,
32+
wantMonth: time.April,
33+
wantDay: 15,
34+
},
35+
{
36+
name: "RFC3339 UTC",
37+
input: "2026-01-01T00:00:00Z",
38+
wantYear: 2026,
39+
wantMonth: time.January,
40+
wantDay: 1,
41+
},
42+
{
43+
name: "RFC3339 with positive offset normalised to UTC",
44+
input: "2026-04-15T06:30:00+05:30", // 06:30 IST == 01:00 UTC
45+
wantYear: 2026,
46+
wantMonth: time.April,
47+
wantDay: 15,
48+
wantHour: 1,
49+
wantMin: 0,
50+
},
51+
{
52+
name: "RFC3339 with negative offset normalised to UTC",
53+
input: "2026-04-15T00:00:00-05:00", // midnight EST == 05:00 UTC
54+
wantYear: 2026,
55+
wantMonth: time.April,
56+
wantDay: 15,
57+
wantHour: 5,
58+
},
59+
{
60+
name: "empty string returns error",
61+
input: "",
62+
wantErr: true,
63+
},
64+
{
65+
name: "invalid string",
66+
input: "not-a-date",
67+
wantErr: true,
68+
},
69+
{
70+
name: "partial date without day",
71+
input: "2026-04",
72+
wantErr: true,
73+
},
74+
{
75+
name: "RFC3339 without timezone is invalid",
76+
input: "2026-04-15T10:00:00",
77+
wantErr: true,
78+
},
79+
}
80+
for _, tc := range tests {
81+
t.Run(tc.name, func(t *testing.T) {
82+
got, err := parseFilterDate(tc.input)
83+
if tc.wantErr {
84+
require.Error(t, err)
85+
return
86+
}
87+
require.NoError(t, err)
88+
require.Equal(t, time.UTC, got.Location(), "result must be UTC")
89+
require.Equal(t, tc.wantYear, got.Year())
90+
require.Equal(t, tc.wantMonth, got.Month())
91+
require.Equal(t, tc.wantDay, got.Day())
92+
require.Equal(t, tc.wantHour, got.Hour())
93+
require.Equal(t, tc.wantMin, got.Minute())
94+
})
95+
}
96+
}
97+
98+
func TestBuildLsFilter(t *testing.T) {
99+
t.Run("negative lastNDays returns error", func(t *testing.T) {
100+
_, err := buildLsFilter("", "", -1)
101+
require.Error(t, err)
102+
require.Contains(t, err.Error(), "--last-n-days")
103+
require.Contains(t, err.Error(), "-1")
104+
})
105+
106+
t.Run("lastNDays and sinceDate together return error", func(t *testing.T) {
107+
_, err := buildLsFilter("2026-01-01", "", 7)
108+
require.Error(t, err)
109+
require.Contains(t, err.Error(), "--last-n-days")
110+
require.Contains(t, err.Error(), "--since-date")
111+
})
112+
113+
t.Run("invalid sinceDate returns error naming the flag", func(t *testing.T) {
114+
_, err := buildLsFilter("not-a-date", "", 0)
115+
require.Error(t, err)
116+
require.Contains(t, err.Error(), "--since-date")
117+
})
118+
119+
t.Run("invalid untilDate returns error naming the flag", func(t *testing.T) {
120+
_, err := buildLsFilter("", "not-a-date", 0)
121+
require.Error(t, err)
122+
require.Contains(t, err.Error(), "--until-date")
123+
})
124+
125+
t.Run("all empty returns filter with nil Since and Until", func(t *testing.T) {
126+
f, err := buildLsFilter("", "", 0)
127+
require.NoError(t, err)
128+
require.Nil(t, f.Since)
129+
require.Nil(t, f.Until)
130+
})
131+
132+
t.Run("lastNDays=0 does not set Since", func(t *testing.T) {
133+
f, err := buildLsFilter("", "", 0)
134+
require.NoError(t, err)
135+
require.Nil(t, f.Since)
136+
})
137+
138+
t.Run("lastNDays=7 sets Since to 7 days ago midnight UTC", func(t *testing.T) {
139+
expected := time.Now().UTC().AddDate(0, 0, -7).Truncate(24 * time.Hour)
140+
f, err := buildLsFilter("", "", 7)
141+
require.NoError(t, err)
142+
require.NotNil(t, f.Since)
143+
require.Nil(t, f.Until)
144+
require.WithinDuration(t, expected, *f.Since, time.Second)
145+
require.Equal(t, 0, f.Since.Hour(), "Since must be at midnight")
146+
})
147+
148+
t.Run("sinceDate YYYY-MM-DD sets Since to midnight UTC of that date", func(t *testing.T) {
149+
f, err := buildLsFilter("2026-03-01", "", 0)
150+
require.NoError(t, err)
151+
require.NotNil(t, f.Since)
152+
require.Nil(t, f.Until)
153+
require.Equal(t, 2026, f.Since.Year())
154+
require.Equal(t, time.March, f.Since.Month())
155+
require.Equal(t, 1, f.Since.Day())
156+
require.Equal(t, 0, f.Since.Hour())
157+
})
158+
159+
t.Run("sinceDate RFC3339 uses exact time without midnight forcing", func(t *testing.T) {
160+
f, err := buildLsFilter("2026-03-01T08:30:00Z", "", 0)
161+
require.NoError(t, err)
162+
require.NotNil(t, f.Since)
163+
require.Equal(t, 8, f.Since.Hour())
164+
require.Equal(t, 30, f.Since.Minute())
165+
require.Equal(t, 0, f.Since.Second())
166+
require.Equal(t, time.UTC, f.Since.Location())
167+
})
168+
169+
t.Run("untilDate YYYY-MM-DD extends to end of that calendar day", func(t *testing.T) {
170+
f, err := buildLsFilter("", "2026-03-31", 0)
171+
require.NoError(t, err)
172+
require.Nil(t, f.Since)
173+
require.NotNil(t, f.Until)
174+
// Should be 2026-03-31 23:59:59.999 UTC
175+
require.Equal(t, 2026, f.Until.Year())
176+
require.Equal(t, time.March, f.Until.Month())
177+
require.Equal(t, 31, f.Until.Day())
178+
require.Equal(t, 23, f.Until.Hour())
179+
require.Equal(t, 59, f.Until.Minute())
180+
require.Equal(t, 59, f.Until.Second())
181+
})
182+
183+
t.Run("untilDate RFC3339 uses exact time without end-of-day extension", func(t *testing.T) {
184+
f, err := buildLsFilter("", "2026-03-31T12:00:00Z", 0)
185+
require.NoError(t, err)
186+
require.NotNil(t, f.Until)
187+
// Must be exactly 12:00:00, not extended to 23:59:59
188+
require.Equal(t, 12, f.Until.Hour())
189+
require.Equal(t, 0, f.Until.Minute())
190+
require.Equal(t, 0, f.Until.Second())
191+
})
192+
193+
t.Run("sinceDate and untilDate both set", func(t *testing.T) {
194+
f, err := buildLsFilter("2026-01-01", "2026-12-31", 0)
195+
require.NoError(t, err)
196+
require.NotNil(t, f.Since)
197+
require.NotNil(t, f.Until)
198+
require.Equal(t, time.January, f.Since.Month())
199+
require.Equal(t, 1, f.Since.Day())
200+
require.Equal(t, time.December, f.Until.Month())
201+
require.Equal(t, 31, f.Until.Day())
202+
require.Equal(t, 23, f.Until.Hour())
203+
})
204+
205+
t.Run("returned type is worker.BackupDateFilter", func(t *testing.T) {
206+
f, err := buildLsFilter("2026-01-01", "2026-12-31", 0)
207+
require.NoError(t, err)
208+
var _ worker.BackupDateFilter = f // compile-time type check
209+
})
210+
}

graphql/admin/endpoints.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,27 @@ const adminTypes = `
188188
"""
189189
anonymous: Boolean
190190
191+
"""
192+
Only return backups taken on or after this date (YYYY-MM-DD or RFC3339).
193+
"""
194+
sinceDate: String
195+
196+
"""
197+
Only return backups on or before this date. For YYYY-MM-DD the entire day is
198+
included (up to 23:59:59 UTC). For RFC3339 the exact timestamp is used as the cutoff.
199+
"""
200+
untilDate: String
201+
202+
"""
203+
Only return backups from the last N calendar days. Cannot be combined with sinceDate.
204+
"""
205+
lastNDays: Int
206+
207+
"""
208+
When true, reads the full manifest.json and includes predicate groups and drop
209+
operations. Defaults to false, which reads the lightweight manifest_summary.json.
210+
"""
211+
fullManifest: Boolean
191212
}
192213
193214
type BackupGroup {

0 commit comments

Comments
 (0)