Skip to content

Commit f68fe08

Browse files
committed
fix(paging): stop calendar and Gmail listing on a repeated page token
listCalendarList now uses collectAllPages. Gmail backup ID listing rejects a repeated NextPageToken the same way Photos Picker already does, so a stuck Google token cannot hang --all or backup. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent eb85a99 commit f68fe08

4 files changed

Lines changed: 74 additions & 15 deletions

File tree

‎internal/backup/gmail/fetch.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
errCacheRequired = errors.New("gmail backup cache is required")
2121
errFetchStopped = errors.New("gmail backup fetch stopped before completion")
2222
errMessageIDMismatch = errors.New("gmail backup source returned a different message ID")
23+
errRepeatedPageToken = errors.New("repeated page token")
2324
)
2425

2526
type CacheStore interface {
@@ -85,6 +86,7 @@ func ListMessageIDs(ctx context.Context, source Source, opts ListOptions) ([]str
8586

8687
ids := make([]string, 0)
8788
seen := make(map[string]struct{})
89+
seenTokens := map[string]struct{}{}
8890
pageToken := ""
8991
if opts.UseCache && !opts.Refresh {
9092
state, found, err := opts.Cache.ReadListState(opts.Selection)
@@ -134,7 +136,8 @@ func ListMessageIDs(ctx context.Context, source Source, opts ListOptions) ([]str
134136
ids = appendUniqueIDs(ids, seen, page.IDs, opts.Selection.Max)
135137
emitEvent(opts.Progress, Event{Phase: EventPhaseList, Done: len(ids)})
136138

137-
complete := strings.TrimSpace(page.NextPageToken) == "" || reachedSelectionMax(ids, opts.Selection.Max)
139+
next := strings.TrimSpace(page.NextPageToken)
140+
complete := next == "" || reachedSelectionMax(ids, opts.Selection.Max)
138141
if opts.UseCache {
139142
nextToken := page.NextPageToken
140143
if complete {
@@ -147,6 +150,10 @@ func ListMessageIDs(ctx context.Context, source Source, opts ListOptions) ([]str
147150
if complete {
148151
break
149152
}
153+
if _, exists := seenTokens[next]; exists {
154+
return nil, fmt.Errorf("list Gmail backup messages: %w %q", errRepeatedPageToken, next)
155+
}
156+
seenTokens[next] = struct{}{}
150157
pageToken = page.NextPageToken
151158
}
152159
return ids, nil

‎internal/backup/gmail/fetch_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"strings"
89
"sync"
910
"sync/atomic"
1011
"testing"
@@ -122,6 +123,31 @@ func TestListMessageIDsMarksMaxedPartialStateComplete(t *testing.T) {
122123
}
123124
}
124125

126+
type stuckPageTokenSource struct{}
127+
128+
func (stuckPageTokenSource) Labels(context.Context) ([]Label, error) {
129+
return nil, nil
130+
}
131+
132+
func (stuckPageTokenSource) ListMessageIDs(context.Context, ListRequest) (ListPage, error) {
133+
return ListPage{IDs: []string{"m1"}, NextPageToken: "stuck"}, nil
134+
}
135+
136+
func (stuckPageTokenSource) RawMessage(context.Context, string) (Message, error) {
137+
return Message{}, nil
138+
}
139+
140+
func TestListMessageIDsRejectsRepeatedPageToken(t *testing.T) {
141+
t.Parallel()
142+
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
143+
defer cancel()
144+
_, err := ListMessageIDs(ctx, stuckPageTokenSource{}, ListOptions{})
145+
if err == nil || !strings.Contains(err.Error(), `repeated page token "stuck"`) {
146+
t.Fatalf("err = %v", err)
147+
}
148+
t.Logf("err = %v", err)
149+
}
150+
125151
func TestFetchMessagesPreservesInputOrderAcrossWorkers(t *testing.T) {
126152
t.Parallel()
127153
source := &fakeSource{

‎internal/cmd/calendar_list.go‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -361,28 +361,18 @@ func resolveCalendarIDs(ctx context.Context, store *config.ConfigStore, svc *cal
361361
}
362362

363363
func listCalendarList(ctx context.Context, svc *calendar.Service) ([]*calendar.CalendarListEntry, error) {
364-
var (
365-
items []*calendar.CalendarListEntry
366-
pageToken string
367-
)
368-
for {
364+
fetch := func(pageToken string) ([]*calendar.CalendarListEntry, string, error) {
369365
call := svc.CalendarList.List().MaxResults(250).Context(ctx)
370366
if pageToken != "" {
371367
call = call.PageToken(pageToken)
372368
}
373369
resp, err := call.Do()
374370
if err != nil {
375-
return nil, err
376-
}
377-
if len(resp.Items) > 0 {
378-
items = append(items, resp.Items...)
379-
}
380-
if resp.NextPageToken == "" {
381-
break
371+
return nil, "", err
382372
}
383-
pageToken = resp.NextPageToken
373+
return resp.Items, resp.NextPageToken, nil
384374
}
385-
return items, nil
375+
return collectAllPages("", fetch)
386376
}
387377

388378
// sortEventsBy sorts events in place by the given key (start|end|summary|calendar).

‎internal/cmd/calendar_list_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"net/http"
77
"net/http/httptest"
88
"slices"
9+
"strings"
910
"testing"
11+
"time"
1012

1113
"google.golang.org/api/calendar/v3"
1214
"google.golang.org/api/option"
@@ -80,3 +82,37 @@ func TestCalendarEventsListCall_EventTypesFilter(t *testing.T) {
8082
})
8183
}
8284
}
85+
86+
func TestListCalendarListRejectsRepeatedPageToken(t *testing.T) {
87+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+
if !(strings.Contains(r.URL.Path, "calendarList") && r.Method == http.MethodGet) {
89+
http.NotFound(w, r)
90+
return
91+
}
92+
w.Header().Set("Content-Type", "application/json")
93+
_ = json.NewEncoder(w).Encode(map[string]any{
94+
"items": []map[string]any{
95+
{"id": "c1", "summary": "One", "accessRole": "owner"},
96+
},
97+
"nextPageToken": "stuck",
98+
})
99+
}))
100+
defer srv.Close()
101+
102+
svc, err := calendar.NewService(context.Background(),
103+
option.WithHTTPClient(srv.Client()),
104+
option.WithEndpoint(srv.URL+"/"),
105+
option.WithoutAuthentication(),
106+
)
107+
if err != nil {
108+
t.Fatalf("NewService: %v", err)
109+
}
110+
111+
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
112+
defer cancel()
113+
_, err = listCalendarList(ctx, svc)
114+
if err == nil || !strings.Contains(err.Error(), "repeated page token") {
115+
t.Fatalf("err = %v", err)
116+
}
117+
t.Logf("err = %v", err)
118+
}

0 commit comments

Comments
 (0)