Skip to content

Commit 7ed7432

Browse files
committed
fix: persist per-repo attempted tokens across requeues
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 0636470 commit 7ed7432

1 file changed

Lines changed: 29 additions & 13 deletions

File tree

services/apps/security_best_practices_worker/src/workflows/triggerSecurityInsightsCheckForRepos.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export async function triggerSecurityInsightsCheckForRepos(
6262
const queue = [...repos]
6363
const activeTasks: Promise<void>[] = []
6464

65+
// Per-repo attempted-token state kept across in-run requeues. Only TokenRepoAccessError
66+
// records into this set — Token403Error and TokenAuthError already mark the token
67+
// isRateLimited/isInvalid, so getNextToken filters them out globally. Preserving the set
68+
// across requeues prevents the same TokenRepoAccessError token from being immediately
69+
// re-picked when other tokens are held by concurrent children. The map is function-scoped
70+
// so continueAsNew starts each batch with a clean slate.
71+
const repoAttemptedTokens = new Map<string, Set<string>>()
72+
6573
async function processRepo(repo: (typeof repos)[0]): Promise<void> {
6674
let attempts = 0
6775
// Distinguishes attempt exhaustion caused by transient rate-limits from exhaustion caused
@@ -70,33 +78,36 @@ export async function triggerSecurityInsightsCheckForRepos(
7078
// via `!tokenInfo`, so requeue decisions have to be made here too — otherwise a rate-limit
7179
// wave would falsely fail the repo for the rest of the continueAsNew chain.
7280
let sawRateLimit = false
73-
// Per-repo attempted-tokens set. TokenRepoAccessError releases the token in "clean"
74-
// global state, so without this filter `getNextToken` could re-pick the same token
75-
// while the others are held by concurrent children — burning attempts without ever
76-
// reaching a token that might have access.
77-
const attemptedTokens = new Set<string>()
81+
// Look up (or create) the persisted attempted-token set for this repo. Persistence
82+
// across requeues is the fix for a concurrency race: a requeued repo with a fresh set
83+
// could immediately re-pick the same token that just returned TokenRepoAccessError
84+
// while other tokens are held by concurrent children.
85+
let attemptedTokens = repoAttemptedTokens.get(repo.repoUrl)
86+
if (!attemptedTokens) {
87+
attemptedTokens = new Set<string>()
88+
repoAttemptedTokens.set(repo.repoUrl, attemptedTokens)
89+
}
7890
while (attempts < MAX_TOKEN_ATTEMPTS) {
7991
const tokenInfo = getNextToken(tokenInfos, currentTimeMs, attemptedTokens)
8092
if (!tokenInfo) {
81-
// No untried, usable token remains for this repo. Distinguish two cases:
82-
// - all tokens permanently invalid, OR every non-invalid token has been tried for
83-
// this repo → this repo genuinely can't be scanned, fail it
84-
// - some non-invalid, non-attempted tokens exist but are currently in-use or
85-
// rate-limited → requeue so a later iteration can retry when they free up
93+
// No untried, usable token remains for this repo. Fail only when either every token
94+
// is permanently invalid, or every non-invalid token has been tried AND none of the
95+
// attempts hit a rate-limit that could cool down. Otherwise requeue: some tokens are
96+
// rate-limited (may recover this run) or in-use by concurrent children (may free up).
8697
const allPermanentlyInvalid = tokenInfos.every((t) => t.isInvalid)
8798
const anyUnattemptedNonInvalid = tokenInfos.some(
8899
(t) => !t.isInvalid && !attemptedTokens.has(t.token),
89100
)
90-
if (allPermanentlyInvalid || !anyUnattemptedNonInvalid) {
101+
if (allPermanentlyInvalid || (!anyUnattemptedNonInvalid && !sawRateLimit)) {
91102
console.error(`No untried tokens remain for repo ${repo.repoUrl}, skipping`)
92103
failedRepoUrls.push(repo.repoUrl)
104+
repoAttemptedTokens.delete(repo.repoUrl)
93105
} else {
94106
queue.unshift(repo)
95107
}
96108
break
97109
}
98110
const token = tokenInfo.token
99-
attemptedTokens.add(token)
100111
tokenInfo.inUse = true
101112

102113
try {
@@ -120,6 +131,7 @@ export async function triggerSecurityInsightsCheckForRepos(
120131
},
121132
],
122133
})
134+
repoAttemptedTokens.delete(repo.repoUrl)
123135
return // success
124136
} catch (error) {
125137
// executeChild rejects with ChildWorkflowFailure wrapping ActivityFailure wrapping
@@ -144,8 +156,10 @@ export async function triggerSecurityInsightsCheckForRepos(
144156
continue // retry with a different token
145157
} else if (appFailure?.type === 'TokenRepoAccessError') {
146158
// Token can't access this repo (SAML/perms) but may work elsewhere — don't taint
147-
// the token's global state, just try the next one for this repo.
159+
// the token's global state. Record it in attemptedTokens (persisted across
160+
// requeues) so getNextToken won't hand this token back for this repo again.
148161
console.warn(`Token lacks access to repo ${repo.repoUrl}, trying different token`)
162+
attemptedTokens.add(token)
149163
attempts++
150164
continue
151165
} else {
@@ -154,6 +168,7 @@ export async function triggerSecurityInsightsCheckForRepos(
154168
// but it failed in all retries.
155169
// to proceed with processing, we don't wanna try this repo again in this run
156170
failedRepoUrls.push(repo.repoUrl)
171+
repoAttemptedTokens.delete(repo.repoUrl)
157172
break
158173
}
159174
} finally {
@@ -173,6 +188,7 @@ export async function triggerSecurityInsightsCheckForRepos(
173188
} else {
174189
console.error(`Exhausted token attempts for repo ${repo.repoUrl}, skipping`)
175190
failedRepoUrls.push(repo.repoUrl)
191+
repoAttemptedTokens.delete(repo.repoUrl)
176192
}
177193
}
178194
}

0 commit comments

Comments
 (0)