1+ import { cancellationSignal , heartbeat } from '@temporalio/activity'
2+
13import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
24import { getServiceChildLogger } from '@crowd/logging'
35
46import { getSecurityContactsConfig } from '../config'
7+ import { parseGithubUrl } from '../enricher/fetchLightRepo'
58import { mapWithConcurrency } from '../utils/concurrency'
69
10+ import { fetchRepoTree } from './extractors/gitTree'
711import { extractPvr } from './extractors/pvr'
812import { extractManifest } from './extractors/registry'
913import { extractSecurityContactsFile } from './extractors/securityContactsFile'
@@ -31,18 +35,11 @@ export interface BatchResult {
3135 processed : number
3236}
3337
34- // Two-tier refresh cadence (the worker runs on a daily cron). Just under 24h/168h so a repo
35- // processed at ~06:00 is eligible again at the next daily/weekly tick rather than slipping a day.
36- const DAILY_INTERVAL_HOURS = 20 // repos never evaluated or with no contacts yet
37- const WEEKLY_INTERVAL_HOURS = 156 // already-enriched repos (have contacts)
38-
39- // Tuned for throughput within the platform ceilings:
40- // - CONCURRENCY: parallel repos. GitHub calls go through the authed Contents API via a
41- // rate-limit-aware pool (per-installation budget parking + app-wide concurrency gate +
42- // Retry-After backoff in githubToken), so high repo concurrency won't trip GitHub limits.
43- // - FETCH_TIMEOUT_MS: generous enough for slow registries (Maven metadata/POM) without hanging slots.
44- // - BATCH_SIZE: bounded by the 30-min activity timeout (worst case: an all-cargo batch throttled
45- // to crates.io's 1 req/s finishes ~8 min).
38+ // Just under 24h/168h so a repo processed at ~06:00 is still eligible at the next tick.
39+ const DAILY_INTERVAL_HOURS = 20 // no contacts found yet
40+ const WEEKLY_INTERVAL_HOURS = 156 // already has contacts
41+
42+ // GitHub calls are rate-limit-aware (see githubToken.ts), so high repo concurrency is safe.
4643const CONCURRENCY = 100
4744const FETCH_TIMEOUT_MS = 15000
4845const BATCH_SIZE = 500
@@ -81,12 +78,12 @@ async function fetchBatch(qx: QueryExecutor): Promise<SweepRow[]> {
8178 r.contacts_last_refreshed IS NULL
8279 -- evaluated but no contacts found yet → retry on the daily cadence
8380 OR (
84- NOT EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id)
81+ NOT EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id AND sc.deleted_at IS NULL )
8582 AND r.contacts_last_refreshed < NOW() - INTERVAL '$(dailyIntervalHours) hours'
8683 )
8784 -- already enriched (has contacts) → refresh on the weekly cadence
8885 OR (
89- EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id)
86+ EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id AND sc.deleted_at IS NULL )
9087 AND r.contacts_last_refreshed < NOW() - INTERVAL '$(weeklyIntervalHours) hours'
9188 )
9289 )
@@ -114,14 +111,22 @@ function toTarget(row: SweepRow): RepoTarget {
114111
115112async function processRepo (
116113 target : RepoTarget ,
117- deps : ExtractorDeps ,
114+ baseDeps : Omit < ExtractorDeps , 'repoTree' > ,
118115 qx : QueryExecutor ,
119116) : Promise < void > {
117+ // One tree fetch per repo, shared by extractors that probe well-known paths.
118+ let repoTree : ExtractorDeps [ 'repoTree' ] = { paths : null }
119+ try {
120+ const { owner, name } = parseGithubUrl ( target . url )
121+ repoTree = await fetchRepoTree ( owner , name , baseDeps . githubGet )
122+ } catch {
123+ // not a github.com URL
124+ }
125+ const deps : ExtractorDeps = { ...baseDeps , repoTree }
126+
120127 const results = await Promise . allSettled ( EXTRACTORS . map ( ( extract ) => extract ( target , deps ) ) )
121128
122- // Replace the repo's contacts only when every extractor succeeded. If any failed (transient
123- // error — non-200s throw), a destructive rewrite would drop contacts a failed tier-A/B extractor
124- // still has, so preserve existing data and just record the attempt; retried next cadence.
129+ // Write only when every extractor succeeded, so a failed one can't wipe contacts it didn't see.
125130 const failed = results . find ( ( r ) => r . status === 'rejected' ) as PromiseRejectedResult | undefined
126131 if ( failed ) {
127132 log . warn (
@@ -144,8 +149,7 @@ async function processRepo(
144149 }
145150 }
146151
147- // A2 veto: B1 may emit a github-pvr contact from redirect language; drop it when A2
148- // authoritatively reports PVR disabled (Option C from the design discussion).
152+ // A2 veto: drop B1's github-pvr guess when A2 authoritatively reports PVR disabled.
149153 if ( policies . pvrEnabled === false ) {
150154 contacts = contacts . filter ( ( c ) => c . channel !== 'github-pvr' )
151155 }
@@ -158,26 +162,45 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
158162 const batch = await fetchBatch ( qx )
159163 if ( batch . length === 0 ) return { processed : 0 }
160164
161- const deps : ExtractorDeps = {
165+ const deps : Omit < ExtractorDeps , 'repoTree' > = {
162166 fetchTimeoutMs : FETCH_TIMEOUT_MS ,
163167 userAgent : config . userAgent ,
164168 githubGet : ( path , opts ) => githubApiGet ( path , FETCH_TIMEOUT_MS , opts ) ,
165169 }
166170
167171 const targets = batch . map ( toTarget )
168- // Isolate per-repo failures: mapWithConcurrency is fail-fast, so a single repo's DB error must
169- // not abort the batch (and, across Temporal retries, halt the whole sweep). Unmarked repos on
170- // error are simply retried next sweep.
171- await mapWithConcurrency ( targets , CONCURRENCY , async ( target ) => {
172+ // Fixed-cadence heartbeat: a slow repo can outlast the 2-minute heartbeatTimeout even while
173+ // every concurrency slot is still busy, so this can't rely on task completions alone.
174+ const heartbeatTimer = setInterval ( ( ) => {
172175 try {
173- await processRepo ( target , deps , qx )
176+ heartbeat ( )
174177 } catch ( err ) {
175- log . error ( { repoId : target . repoId , errMsg : ( err as Error ) . message } , 'Repo processing failed' )
176- // Best-effort mark so a persistently-failing repo drains on cadence rather than making the
177- // sweep hot-loop (it would otherwise stay eligible and keep processed > 0 forever).
178- await markRepoAttempted ( qx , target . repoId ) . catch ( ( ) => undefined )
178+ log . warn ( { errMsg : ( err as Error ) . message } , 'Heartbeat failed' )
179179 }
180- } )
180+ } , 30_000 )
181+ try {
182+ // A cancelled task (superseded by a newer activity attempt) is left to throw so it stops
183+ // scheduling further repos instead of racing the new attempt.
184+ await mapWithConcurrency ( targets , CONCURRENCY , async ( target ) => {
185+ if ( cancellationSignal ( ) . aborted ) {
186+ throw new Error (
187+ 'Security contacts batch cancelled — superseded by a newer activity attempt' ,
188+ )
189+ }
190+ try {
191+ await processRepo ( target , deps , qx )
192+ } catch ( err ) {
193+ log . error (
194+ { repoId : target . repoId , errMsg : ( err as Error ) . message } ,
195+ 'Repo processing failed' ,
196+ )
197+ // Best-effort: keeps a persistently-failing repo from hot-looping the sweep.
198+ await markRepoAttempted ( qx , target . repoId ) . catch ( ( ) => undefined )
199+ }
200+ } )
201+ } finally {
202+ clearInterval ( heartbeatTimer )
203+ }
181204
182205 log . info ( { processed : targets . length } , 'Security contacts batch complete' )
183206 return { processed : targets . length }
0 commit comments