Skip to content

Commit f25e23d

Browse files
committed
fix: use workflow startTime for determinism and avoid continueAsNew hot loop
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 5d9fa19 commit f25e23d

1 file changed

Lines changed: 24 additions & 8 deletions

File tree

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export async function triggerSecurityInsightsCheckForRepos(
3333
const info = workflowInfo()
3434
const failedRepoUrls = args?.failedRepoUrls || []
3535

36+
// Use workflowInfo().startTime as the replay-safe time reference — Temporal workflows must
37+
// be deterministic, so Date.now()/new Date() are banned by services-checklist.md:9-12.
38+
// startTime is stable within a run and advances on each continueAsNew batch.
39+
const nowMs = info.startTime.getTime()
40+
const nowIso = info.startTime.toISOString()
41+
3642
// Token state is persisted in Redis via updateTokenInfos so isRateLimited (with rateLimitedAt
3743
// for expiry) and isInvalid survive across continueAsNew batches.
3844
const tokenInfos: ITokenInfo[] = await initializeTokenInfos()
@@ -53,7 +59,7 @@ export async function triggerSecurityInsightsCheckForRepos(
5359
async function processRepo(repo: (typeof repos)[0]): Promise<void> {
5460
let attempts = 0
5561
while (attempts < MAX_TOKEN_ATTEMPTS) {
56-
const tokenInfo = getNextToken(tokenInfos)
62+
const tokenInfo = getNextToken(tokenInfos, nowMs)
5763
if (!tokenInfo) {
5864
// Distinguish: are there tokens that will become free, or are all excluded?
5965
const anyRecoverable = tokenInfos.some((t) => !t.isInvalid && !t.isRateLimited)
@@ -99,7 +105,7 @@ export async function triggerSecurityInsightsCheckForRepos(
99105
const appFailure = unwrapApplicationFailure(error)
100106
if (appFailure?.type === 'Token403Error') {
101107
tokenInfo.isRateLimited = true
102-
tokenInfo.rateLimitedAt = new Date().toISOString()
108+
tokenInfo.rateLimitedAt = nowIso
103109
attempts++
104110
continue // retry with a different token
105111
} else if (appFailure?.type === 'TokenAuthError') {
@@ -136,11 +142,12 @@ export async function triggerSecurityInsightsCheckForRepos(
136142
* it will be removed from the activeTasks array and the next task will be started.
137143
* This way we don't need to wait for all tasks to finish before starting new ones.
138144
*/
145+
let deferred = false
139146
while (queue.length > 0 || activeTasks.length > 0) {
140147
while (queue.length > 0 && activeTasks.length < MAX_PARALLEL_CHILDREN) {
141148
// Only start a task if a token is currently free — avoids false "no token" failures
142149
// when all tokens are temporarily held by other concurrent tasks.
143-
refreshExpiredRateLimits(tokenInfos)
150+
refreshExpiredRateLimits(tokenInfos, nowMs)
144151
const hasFreeToken = tokenInfos.some((t) => !t.isInvalid && !t.isRateLimited && !t.inUse)
145152
if (!hasFreeToken) break
146153

@@ -161,6 +168,7 @@ export async function triggerSecurityInsightsCheckForRepos(
161168
console.warn(
162169
`No tokens available; deferring ${queue.length} repos to next batch (they will be re-fetched)`,
163170
)
171+
deferred = true
164172
break
165173
}
166174
}
@@ -169,27 +177,35 @@ export async function triggerSecurityInsightsCheckForRepos(
169177
// survive the continueAsNew handoff.
170178
await updateTokenInfos(tokenInfos)
171179

180+
if (deferred) {
181+
// All tokens rate-limited or invalid and repos still pending. Calling continueAsNew here
182+
// would spin: findObsoleteRepos returns the same repos, they defer again, etc.
183+
// Return instead — the daily schedule (scheduleCheckReposWithObsoleteSecurityInsights)
184+
// will re-trigger the workflow; rate-limit timestamps persist in Redis so recovery is
185+
// resumable once the 1h window elapses.
186+
return
187+
}
188+
172189
await continueAsNew<typeof triggerSecurityInsightsCheckForRepos>({
173190
failedRepoUrls,
174191
})
175192
}
176193

177-
function refreshExpiredRateLimits(tokenInfos: ITokenInfo[]): void {
178-
const now = Date.now()
194+
function refreshExpiredRateLimits(tokenInfos: ITokenInfo[], nowMs: number): void {
179195
for (const t of tokenInfos) {
180196
if (
181197
t.isRateLimited &&
182198
t.rateLimitedAt &&
183-
now - new Date(t.rateLimitedAt).getTime() > ONE_HOUR_MS
199+
nowMs - new Date(t.rateLimitedAt).getTime() > ONE_HOUR_MS
184200
) {
185201
t.isRateLimited = false
186202
t.rateLimitedAt = undefined
187203
}
188204
}
189205
}
190206

191-
function getNextToken(tokenInfos: ITokenInfo[]): ITokenInfo | null {
192-
refreshExpiredRateLimits(tokenInfos)
207+
function getNextToken(tokenInfos: ITokenInfo[], nowMs: number): ITokenInfo | null {
208+
refreshExpiredRateLimits(tokenInfos, nowMs)
193209

194210
const usableTokenInfos = tokenInfos.filter((t) => !t.inUse && !t.isRateLimited && !t.isInvalid)
195211

0 commit comments

Comments
 (0)