Skip to content

Commit 5bfa6ab

Browse files
committed
fix: refresh wall-clock time in loop, requeue rate-limited pool
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 4f6f192 commit 5bfa6ab

3 files changed

Lines changed: 34 additions & 24 deletions

File tree

services/apps/security_best_practices_worker/src/activities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
findObsoleteRepos,
3+
getCurrentTimeMs,
34
getOSPSBaselineInsights,
45
initializeTokenInfos,
56
saveOSPSBaselineInsightsToDB,
@@ -12,4 +13,5 @@ export {
1213
findObsoleteRepos,
1314
initializeTokenInfos,
1415
updateTokenInfos,
16+
getCurrentTimeMs,
1517
}

services/apps/security_best_practices_worker/src/activities/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,10 @@ export async function updateTokenInfos(tokenInfos: ITokenInfo[]): Promise<void>
340340
const redisCache = new RedisCache(`osps-baseline-insights`, svc.redis, svc.log)
341341
await redisCache.set('tokenInfos', JSON.stringify(tokenInfos), 60 * 60 * 24) // 1 day
342342
}
343+
344+
// Wall-clock time via activity — workflow code can't call Date.now() (determinism rule),
345+
// but rate-limit cooldowns need real elapsed time to expire mid-run rather than only at
346+
// the next continueAsNew batch boundary.
347+
export async function getCurrentTimeMs(): Promise<number> {
348+
return Date.now()
349+
}

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

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@ import { ITokenInfo, ITriggerSecurityInsightsCheckForReposParams } from '../type
1313

1414
import { upsertOSPSBaselineSecurityInsights } from './upsertOSPSBaselineSecurityInsights'
1515

16-
const { findObsoleteRepos, initializeTokenInfos, updateTokenInfos } = proxyActivities<
17-
typeof activities
18-
>({
19-
startToCloseTimeout: '5 minutes',
20-
retry: { maximumAttempts: 3, backoffCoefficient: 3 },
21-
})
16+
const { findObsoleteRepos, initializeTokenInfos, updateTokenInfos, getCurrentTimeMs } =
17+
proxyActivities<typeof activities>({
18+
startToCloseTimeout: '5 minutes',
19+
retry: { maximumAttempts: 3, backoffCoefficient: 3 },
20+
})
2221

2322
const ONE_HOUR_MS = 60 * 60 * 1000
2423

@@ -32,12 +31,10 @@ export async function triggerSecurityInsightsCheckForRepos(
3231
const info = workflowInfo()
3332
const failedRepoUrls = args?.failedRepoUrls || []
3433

35-
// Use workflowInfo().startTime as the replay-safe time reference for rate-limit expiry
36-
// checks — Temporal workflows must be deterministic, so Date.now()/new Date() are banned
37-
// (services-checklist.md:9-12). Actual rate-limit failure timestamps come from the
38-
// ApplicationFailure.details payload set inside the activity, so long batches don't skew
39-
// the persisted rateLimitedAt.
40-
const nowMs = info.startTime.getTime()
34+
// Wall-clock time comes from the getCurrentTimeMs activity — workflow code can't call
35+
// Date.now() (Temporal determinism rule). Refreshed each outer-loop iteration so rate-limit
36+
// cooldowns can expire mid-run instead of only at continueAsNew boundaries.
37+
let currentTimeMs = await getCurrentTimeMs()
4138

4239
// Token state is persisted in Redis via updateTokenInfos so isRateLimited (with rateLimitedAt
4340
// for expiry) and isInvalid survive across continueAsNew batches.
@@ -63,17 +60,18 @@ export async function triggerSecurityInsightsCheckForRepos(
6360
async function processRepo(repo: (typeof repos)[0]): Promise<void> {
6461
let attempts = 0
6562
while (attempts < MAX_TOKEN_ATTEMPTS) {
66-
const tokenInfo = getNextToken(tokenInfos, nowMs)
63+
const tokenInfo = getNextToken(tokenInfos, currentTimeMs)
6764
if (!tokenInfo) {
68-
// Distinguish: are there tokens that will become free, or are all excluded?
69-
const anyRecoverable = tokenInfos.some((t) => !t.isInvalid && !t.isRateLimited)
70-
if (anyRecoverable) {
71-
// All non-excluded tokens are currently in use by other concurrent tasks.
72-
// Put the repo back and let the outer loop retry after a task finishes.
73-
queue.unshift(repo)
74-
} else {
75-
console.error(`All tokens excluded for repo ${repo.repoUrl}, skipping`)
65+
// Only mark the repo failed if every token is permanently invalid. Rate-limited
66+
// tokens recover after 1h and in-use tokens free up as concurrent tasks finish, so
67+
// in both cases requeue the repo — the outer loop's hasFreeToken gate + defer path
68+
// handles the wait.
69+
const allPermanentlyInvalid = tokenInfos.every((t) => t.isInvalid)
70+
if (allPermanentlyInvalid) {
71+
console.error(`All tokens permanently invalid for repo ${repo.repoUrl}, skipping`)
7672
failedRepoUrls.push(repo.repoUrl)
73+
} else {
74+
queue.unshift(repo)
7775
}
7876
break
7977
}
@@ -112,7 +110,7 @@ export async function triggerSecurityInsightsCheckForRepos(
112110
// Activity captures the wall-clock time of the 403 in details[0]; fall back to the
113111
// batch startTime when details are missing so we still record something.
114112
tokenInfo.rateLimitedAt =
115-
extractFailureTimestamp(appFailure) ?? info.startTime.toISOString()
113+
extractFailureTimestamp(appFailure) ?? new Date(currentTimeMs).toISOString()
116114
attempts++
117115
continue // retry with a different token
118116
} else if (appFailure?.type === 'TokenAuthError') {
@@ -131,7 +129,7 @@ export async function triggerSecurityInsightsCheckForRepos(
131129
break
132130
}
133131
} finally {
134-
await releaseToken(tokenInfos, tokenInfo.token, nowMs)
132+
await releaseToken(tokenInfos, tokenInfo.token, currentTimeMs)
135133
}
136134
}
137135

@@ -151,10 +149,13 @@ export async function triggerSecurityInsightsCheckForRepos(
151149
*/
152150
let deferred = false
153151
while (queue.length > 0 || activeTasks.length > 0) {
152+
// Refresh wall-clock time each iteration so mid-run rate-limit cooldowns can expire
153+
// without waiting for the next continueAsNew batch.
154+
currentTimeMs = await getCurrentTimeMs()
154155
while (queue.length > 0 && activeTasks.length < MAX_PARALLEL_CHILDREN) {
155156
// Only start a task if a token is currently free — avoids false "no token" failures
156157
// when all tokens are temporarily held by other concurrent tasks.
157-
refreshExpiredRateLimits(tokenInfos, nowMs)
158+
refreshExpiredRateLimits(tokenInfos, currentTimeMs)
158159
const hasFreeToken = tokenInfos.some((t) => !t.isInvalid && !t.isRateLimited && !t.inUse)
159160
if (!hasFreeToken) break
160161

0 commit comments

Comments
 (0)