@@ -16,16 +16,17 @@ import { extractSecurityMd } from './extractors/securityMd'
1616import { extractSecurityTxt } from './extractors/securityTxt'
1717import { githubApiGet } from './githubToken'
1818import { reconcile } from './reconcile'
19- import { resolveCdpEmails } from './resolveCdpEmails'
19+ import { deriveGithubHandlesFromNoreplyEmails , resolveCdpEmails } from './resolveCdpEmails'
2020import {
2121 Extractor ,
2222 ExtractorDeps ,
23+ ProcessRepoResult ,
2324 RawContact ,
2425 RepoPackage ,
2526 RepoPolicies ,
2627 RepoTarget ,
2728} from './types'
28- import { markRepoAttempted , writeContacts } from './writeContacts'
29+ import { writeContactsBatch } from './writeContacts'
2930
3031const log = getServiceChildLogger ( 'security-contacts' )
3132
@@ -120,9 +121,8 @@ export function buildBaseDeps(config: Config): Omit<ExtractorDeps, 'repoTree'> {
120121export async function processRepo (
121122 target : RepoTarget ,
122123 baseDeps : Omit < ExtractorDeps , 'repoTree' > ,
123- qx : QueryExecutor ,
124124 cdpQx : QueryExecutor ,
125- ) : Promise < void > {
125+ ) : Promise < ProcessRepoResult > {
126126 // One tree fetch per repo, shared by extractors that probe well-known paths.
127127 let repoTree : ExtractorDeps [ 'repoTree' ] = { paths : null }
128128 try {
@@ -142,8 +142,7 @@ export async function processRepo(
142142 { repoId : target . repoId , errMsg : failed . reason ?. message } ,
143143 'Extractor failed — preserving existing data' ,
144144 )
145- await markRepoAttempted ( qx , target . repoId )
146- return
145+ return { repoId : target . repoId , status : 'extractor-failed' }
147146 }
148147
149148 let contacts : RawContact [ ] = [ ]
@@ -163,6 +162,8 @@ export async function processRepo(
163162 contacts = contacts . filter ( ( c ) => c . channel !== 'github-pvr' )
164163 }
165164
165+ contacts . push ( ...deriveGithubHandlesFromNoreplyEmails ( contacts ) )
166+
166167 const handleContacts = contacts . filter ( ( c ) => c . channel === 'github-handle' )
167168 if ( handleContacts . length > 0 ) {
168169 try {
@@ -176,7 +177,7 @@ export async function processRepo(
176177 }
177178
178179 const scored = reconcile ( contacts )
179- await writeContacts ( qx , target . repoId , scored , policies )
180+ return { repoId : target . repoId , status : 'ok' , contacts : scored , policies }
180181}
181182
182183export async function processBatch (
@@ -199,26 +200,57 @@ export async function processBatch(
199200 log . warn ( { errMsg : ( err as Error ) . message } , 'Heartbeat failed' )
200201 }
201202 } , 30_000 )
203+ let outcomes : ProcessRepoResult [ ]
204+ const extractionStartedAt = Date . now ( )
202205 try {
203206 // A cancelled task (superseded by a newer activity attempt) is left to throw so it stops
204207 // scheduling further repos instead of racing the new attempt.
205- await mapWithConcurrency ( targets , CONCURRENCY , async ( target ) => {
208+ //
209+ // Extraction is collected here and persisted afterward in one batched call (below) rather
210+ // than per-repo, since per-repo writes meant up to CONCURRENCY concurrent transactions
211+ // against a packages-db pool sized for far fewer connections — the actual sweep bottleneck.
212+ outcomes = await mapWithConcurrency ( targets , CONCURRENCY , async ( target ) => {
206213 if ( cancellationSignal ( ) . aborted ) {
207214 throw new Error (
208215 'Security contacts batch cancelled — superseded by a newer activity attempt' ,
209216 )
210217 }
211218 try {
212- await processRepo ( target , deps , qx , cdpQx )
219+ return await processRepo ( target , deps , cdpQx )
213220 } catch ( err ) {
214221 log . error (
215222 { repoId : target . repoId , errMsg : ( err as Error ) . message } ,
216223 'Repo processing failed' ,
217224 )
218- // Best-effort: keeps a persistently-failing repo from hot-looping the sweep.
219- await markRepoAttempted ( qx , target . repoId ) . catch ( ( ) => undefined )
225+ return { repoId : target . repoId , status : 'extractor-failed' as const }
220226 }
221227 } )
228+
229+ const extractionDurationMs = Date . now ( ) - extractionStartedAt
230+ const ok = outcomes . filter ( ( o ) => o . status === 'ok' ) . length
231+ log . info (
232+ {
233+ ok,
234+ failed : outcomes . length - ok ,
235+ durationMs : extractionDurationMs ,
236+ reposPerSec : Number ( ( outcomes . length / ( extractionDurationMs / 1000 ) ) . toFixed ( 1 ) ) ,
237+ } ,
238+ 'Security contacts batch extraction complete — persisting' ,
239+ )
240+
241+ // Heartbeat is kept alive through persistence too: a slow/lock-blocked write can outlast the
242+ // 2-minute heartbeat timeout just like extraction can, and letting the timer stop early would
243+ // let Temporal retry this activity while the original attempt is still writing.
244+ const writeStartedAt = Date . now ( )
245+ await writeContactsBatch ( qx , outcomes )
246+ const writeDurationMs = Date . now ( ) - writeStartedAt
247+ log . info (
248+ {
249+ durationMs : writeDurationMs ,
250+ reposPerSec : Number ( ( outcomes . length / ( writeDurationMs / 1000 ) ) . toFixed ( 1 ) ) ,
251+ } ,
252+ 'Security contacts batch persistence complete' ,
253+ )
222254 } finally {
223255 clearInterval ( heartbeatTimer )
224256 }
0 commit comments