-
Notifications
You must be signed in to change notification settings - Fork 0
CXP-533 fix event feed DeadlineExceeded from unbounded per-user Reports API fan-out #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9657f14
1b92062
54e0bce
6f27963
aed8ca3
2c381ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ import ( | |||||||||||||||||||||||||||||
| "encoding/base64" | ||||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" | ||||||||||||||||||||||||||||||
| "github.com/conductorone/baton-sdk/pkg/pagination" | ||||||||||||||||||||||||||||||
|
|
@@ -29,11 +30,28 @@ import ( | |||||||||||||||||||||||||||||
| gwclient "github.com/conductorone/baton-google-workspace/pkg/client" | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // usersPerEventFeedCall bounds how many users are processed per ListEvents invocation, so a | ||||||||||||||||||||||||||||||
| // single call issues at most this many Reports API filter-queries and returns quickly instead | ||||||||||||||||||||||||||||||
| // of blocking on the shared 250/min quota for an entire directory page (up to 500 users). | ||||||||||||||||||||||||||||||
| // usersPerEventFeedCall bounds how many users are considered per ListEvents invocation. This | ||||||||||||||||||||||||||||||
| // alone does not bound the number of Reports API calls issued (see maxLookupCallsPerEventFeedCall | ||||||||||||||||||||||||||||||
| // below), but keeps the directory-side bookkeeping (ListTokens, cursor size) proportional. | ||||||||||||||||||||||||||||||
| const usersPerEventFeedCall = 25 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // maxLookupCallsPerEventFeedCall caps Reports API calls per ListEvents invocation. Bounds cost, | ||||||||||||||||||||||||||||||
| // not time (shared rate limiter, retries with backoff) — see maxEventFeedCallDuration for that. | ||||||||||||||||||||||||||||||
| const maxLookupCallsPerEventFeedCall = 60 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // maxEventFeedCallDuration is a soft wall-clock budget per ListEvents invocation. It bounds the | ||||||||||||||||||||||||||||||
| // gap between users (see the loop in scanUsersForEvents) and is also handed to each user's own | ||||||||||||||||||||||||||||||
| // lookup as a deadline, so a single user's internal fan-out (e.g. usageEventFeed.lookupUser's | ||||||||||||||||||||||||||||||
| // per-app Reports calls) stops starting new work once it's spent, instead of only being checked | ||||||||||||||||||||||||||||||
| // again after that one user's lookup already returned. | ||||||||||||||||||||||||||||||
| const maxEventFeedCallDuration = 45 * time.Second | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // eventFeedDeadlineSafetyMargin is subtracted from ctx's deadline (when the caller set one) to | ||||||||||||||||||||||||||||||
| // derive the effective per-call deadline passed to lookup, so this connector stops issuing new | ||||||||||||||||||||||||||||||
| // Reports calls with enough margin to unwind and return a partial result before the RPC itself | ||||||||||||||||||||||||||||||
| // times out — rather than getting DeadlineExceeded mid-call with nothing to show for it. | ||||||||||||||||||||||||||||||
| const eventFeedDeadlineSafetyMargin = 5 * time.Second | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| type pendingUser struct { | ||||||||||||||||||||||||||||||
| Email string `json:"email"` | ||||||||||||||||||||||||||||||
| ID string `json:"id"` | ||||||||||||||||||||||||||||||
|
|
@@ -45,9 +63,13 @@ type pendingUser struct { | |||||||||||||||||||||||||||||
| // When both are empty/exhausted, the walk is complete and the cursor resets to nil so the | ||||||||||||||||||||||||||||||
| // next call starts a fresh pass — there is no "since last poll" time window to track, since | ||||||||||||||||||||||||||||||
| // each lookup always asks for the current latest login, not a delta. | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // ResumeState is opaque, feed-defined progress for PendingUsers[0]'s own lookup (e.g. which | ||||||||||||||||||||||||||||||
| // authorized app to resume from), set when a call exhausts its budget mid-user. | ||||||||||||||||||||||||||||||
| type userScanCursor struct { | ||||||||||||||||||||||||||||||
| PendingUsers []pendingUser `json:"pending_users,omitempty"` | ||||||||||||||||||||||||||||||
| DirectoryPageToken string `json:"directory_page_token,omitempty"` | ||||||||||||||||||||||||||||||
| ResumeState string `json:"resume_state,omitempty"` | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func unmarshalUserScanCursor(pToken *pagination.StreamToken) (*userScanCursor, error) { | ||||||||||||||||||||||||||||||
|
|
@@ -84,8 +106,23 @@ func (c *userScanCursor) marshal() (string, error) { | |||||||||||||||||||||||||||||
| return base64.StdEncoding.EncodeToString(data), nil | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // userEventLookup fetches events for a single user via at most one Reports API call. | ||||||||||||||||||||||||||||||
| type userEventLookup func(ctx context.Context, client *gwclient.GoogleWorkspaceClient, user pendingUser) ([]*v2.Event, error) | ||||||||||||||||||||||||||||||
| // userEventLookup fetches events for one user, spending at most `budget` Reports API calls. | ||||||||||||||||||||||||||||||
| // resumeState picks up where a prior call for this user left off ("" = start fresh); if more | ||||||||||||||||||||||||||||||
| // than `budget` calls are needed, it returns a non-empty nextResumeState instead of finishing. | ||||||||||||||||||||||||||||||
| // consumed is how many calls this invocation issued (meaningful only when err == nil). deadline | ||||||||||||||||||||||||||||||
| // is a soft wall-clock cutoff: a lookup with internal fan-out (multiple Reports calls per user) | ||||||||||||||||||||||||||||||
| // should stop starting new calls once it's past deadline and return whatever it has, with | ||||||||||||||||||||||||||||||
| // nextResumeState reflecting the last unit of work it actually finished — never a zero-value | ||||||||||||||||||||||||||||||
| // time.Time in practice, but implementations that only ever issue one call per user (there is no | ||||||||||||||||||||||||||||||
| // fan-out to bound) are free to ignore it. | ||||||||||||||||||||||||||||||
| type userEventLookup func( | ||||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||||
| client *gwclient.GoogleWorkspaceClient, | ||||||||||||||||||||||||||||||
| user pendingUser, | ||||||||||||||||||||||||||||||
| resumeState string, | ||||||||||||||||||||||||||||||
| budget int, | ||||||||||||||||||||||||||||||
| deadline time.Time, | ||||||||||||||||||||||||||||||
| ) (events []*v2.Event, nextResumeState string, consumed int, err error) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // scanUsersForEvents drives one bounded step of the rolling user-directory walk shared by all | ||||||||||||||||||||||||||||||
| // three "last login" event feeds. | ||||||||||||||||||||||||||||||
|
|
@@ -142,37 +179,76 @@ func scanUsersForEvents( | |||||||||||||||||||||||||||||
| batch = batch[:usersPerEventFeedCall] | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // finish marshals the cursor and returns the call's result; shared by every exit path. | ||||||||||||||||||||||||||||||
| finish := func(events []*v2.Event, hasMore bool) ([]*v2.Event, *pagination.StreamState, error) { | ||||||||||||||||||||||||||||||
| if !hasMore { | ||||||||||||||||||||||||||||||
| cursor = &userScanCursor{} | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| cursorToken, err := cursor.marshal() | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| return nil, nil, fmt.Errorf("failed to marshal cursor token in event feed: %w", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return events, &pagination.StreamState{Cursor: cursorToken, HasMore: hasMore}, nil | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| events := []*v2.Event{} | ||||||||||||||||||||||||||||||
| for _, u := range batch { | ||||||||||||||||||||||||||||||
| userEvents, err := lookup(ctx, client, u) | ||||||||||||||||||||||||||||||
| budget := maxLookupCallsPerEventFeedCall | ||||||||||||||||||||||||||||||
| resumeState := cursor.ResumeState | ||||||||||||||||||||||||||||||
| start := time.Now() | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // deadline is handed to each user's lookup so a single heavy user's internal fan-out (e.g. | ||||||||||||||||||||||||||||||
| // many Reports calls for one user's authorized apps) also respects the wall-clock budget, | ||||||||||||||||||||||||||||||
| // not just the gap between users below. If the caller's ctx carries its own deadline (the | ||||||||||||||||||||||||||||||
| // real RPC deadline), stop earlier than that with a safety margin instead of racing it. | ||||||||||||||||||||||||||||||
| deadline := start.Add(maxEventFeedCallDuration) | ||||||||||||||||||||||||||||||
| if ctxDeadline, ok := ctx.Deadline(); ok { | ||||||||||||||||||||||||||||||
| if safe := ctxDeadline.Add(-eventFeedDeadlineSafetyMargin); safe.Before(deadline) { | ||||||||||||||||||||||||||||||
| deadline = safe | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| for i, u := range batch { | ||||||||||||||||||||||||||||||
| if budget <= 0 || !time.Now().Before(deadline) { | ||||||||||||||||||||||||||||||
| // Budget or time spent before starting u; resume here fresh next call. | ||||||||||||||||||||||||||||||
| cursor.PendingUsers = cursor.PendingUsers[i:] | ||||||||||||||||||||||||||||||
| cursor.ResumeState = "" | ||||||||||||||||||||||||||||||
| return finish(events, true) | ||||||||||||||||||||||||||||||
|
Comment on lines
+211
to
+215
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion (confidence: high): switching this guard to the ctx-derived Also note that this exit makes zero forward progress:
Suggested change
|
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| userEvents, nextResume, consumed, err := lookup(ctx, client, u, resumeState, budget, deadline) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| // Don't remove `batch` from cursor.PendingUsers until every lookup in it has | ||||||||||||||||||||||||||||||
| // succeeded, so a single user's Reports API blip doesn't lose the remaining | ||||||||||||||||||||||||||||||
| // unprocessed users and restart the walk from the beginning on retry. | ||||||||||||||||||||||||||||||
| // The SDK drops this StreamState on error (connectorbuilder.ListEvents returns nil, | ||||||||||||||||||||||||||||||
| // err without ever reading it), so what we marshal here is moot either way; return | ||||||||||||||||||||||||||||||
| // cursor as it currently stands — which may already include a fresh directory-page | ||||||||||||||||||||||||||||||
| // advance from the len(cursor.PendingUsers) == 0 branch above — rather than trying to | ||||||||||||||||||||||||||||||
| // preserve or roll back per-user progress for no effect. | ||||||||||||||||||||||||||||||
| cursorToken, marshalErr := cursor.marshal() | ||||||||||||||||||||||||||||||
| if marshalErr != nil { | ||||||||||||||||||||||||||||||
| return nil, nil, fmt.Errorf("failed to marshal cursor token in event feed: %w", marshalErr) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return nil, &pagination.StreamState{Cursor: cursorToken, HasMore: true}, err | ||||||||||||||||||||||||||||||
|
JavierCarnelli-ConductorOne marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| budget -= consumed | ||||||||||||||||||||||||||||||
| resumeState = "" // only the batch's first (possibly-resumed) user carries one in | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| for _, e := range userEvents { | ||||||||||||||||||||||||||||||
| if earliestEvent != nil && e.GetOccurredAt() != nil && e.GetOccurredAt().AsTime().Before(earliestEvent.AsTime()) { | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| events = append(events, e) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| cursor.PendingUsers = cursor.PendingUsers[len(batch):] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| hasMore := len(cursor.PendingUsers) > 0 || cursor.DirectoryPageToken != "" | ||||||||||||||||||||||||||||||
| if !hasMore { | ||||||||||||||||||||||||||||||
| cursor = &userScanCursor{} | ||||||||||||||||||||||||||||||
| if nextResume != "" { | ||||||||||||||||||||||||||||||
| // u isn't finished; keep it at the front and stop the batch here. | ||||||||||||||||||||||||||||||
| cursor.PendingUsers = cursor.PendingUsers[i:] | ||||||||||||||||||||||||||||||
| cursor.ResumeState = nextResume | ||||||||||||||||||||||||||||||
| return finish(events, true) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| cursorToken, err := cursor.marshal() | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| return nil, nil, fmt.Errorf("failed to marshal cursor token in event feed: %w", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| cursor.PendingUsers = cursor.PendingUsers[len(batch):] | ||||||||||||||||||||||||||||||
| cursor.ResumeState = "" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return events, &pagination.StreamState{Cursor: cursorToken, HasMore: hasMore}, nil | ||||||||||||||||||||||||||||||
| hasMore := len(cursor.PendingUsers) > 0 || cursor.DirectoryPageToken != "" | ||||||||||||||||||||||||||||||
| return finish(events, hasMore) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion (test gap): the ctx-derived deadline is the behavior this commit exists to activate, and nothing exercises it — no test in
pkg/connectorconstructs acontext.WithTimeout/WithDeadlineforscanUsersForEvents, so neither theeventFeedDeadlineSafetyMarginsubtraction, thesafe.Before(deadline)min-selection, nor the new!time.Now().Before(deadline)between-users guard is covered. A table test with ctx deadlines of (say) 3s and 120s asserting which ofmaxEventFeedCallDurationvsctxDeadline - marginwins, plus one asserting the between-users loop stops early under a near-expired ctx, would lock this in cheaply and would have surfaced theResumeStatereset flagged below.