@@ -24,10 +24,17 @@ const { gcsParquetToStaging } = proxyActivities<typeof depsDevActivities>({
2424} )
2525
2626const { mergeStagingToTable } = proxyActivities < typeof depsDevActivities > ( {
27- startToCloseTimeout : '2 hours' ,
27+ // A single ~1M-row chunk merge into the live-index/live-FK package_dependencies table (incremental
28+ // does not drop indexes) can run long; 4h gives headroom over the previously-observed 2h overrun.
29+ startToCloseTimeout : '4 hours' ,
2830 retry : { maximumAttempts : 1 } ,
2931} )
3032
33+ const { getResumeExport } = proxyActivities < typeof depsDevActivities > ( {
34+ startToCloseTimeout : '1 minute' ,
35+ retry : { maximumAttempts : 3 } ,
36+ } )
37+
3138const { createVersionsLookup } = proxyActivities < typeof depsDevActivities > ( {
3239 startToCloseTimeout : '2 hours' ,
3340 retry : { maximumAttempts : 2 , initialInterval : '30 seconds' } ,
@@ -219,6 +226,9 @@ export async function ingestDependencies(opts: {
219226 depsTableOption ?: 'A' | 'B'
220227 exportName ?: string
221228 fillConstraints ?: boolean // re-export full BQ data, upsert version_constraint only where NULL
229+ // Resume a partially-merged prior job: skip the BQ export, reuse that job's exact parquet files,
230+ // and restart the chunk loop where its staging load left off. Idempotent modes only (see gate).
231+ resumeJobId ?: number
222232} ) : Promise < { rowCountBq : number } > {
223233 const ecosystems = opts . ecosystems ?? DEPS_DEFAULT_ECOSYSTEMS
224234 const isFill = opts . fillConstraints === true
@@ -228,57 +238,128 @@ export async function ingestDependencies(opts: {
228238 // source below and which source the guard probes — they must match.
229239 const fullScan = opts . syncMode === 'full' || isFill
230240
231- // Guard against corrupt deps.dev resolved-graph snapshots BEFORE the (multi-hour) export.
232- // Skip when reusing a prior export — we're re-importing already-validated parquet, not
233- // scanning the live snapshot. Both full (*Latest = newest snapshot) and incremental can hit
234- // a bad snapshot, so the guard runs for both. Probes only resolved-graph ecosystems; a clean
235- // GO/NUGET-only run finds no canaries and passes through.
236- //
237- // Option A only: the guard's canary ratios are DependencyGraphEdges-schema-specific. Option B
238- // ingests the separate Dependencies/DependenciesLatest table, which the 2026-06 corruption was
239- // never observed in and has no calibrated baseline — probing Edges there would "validate" a table
240- // we don't ingest (false confidence) and could abort a healthy Option B run when only Edges is bad.
241- // Option B is a manual, non-scheduled cost-experiment path (--deps-table-b); leave it unguarded by
242- // design rather than invent a guard for an unproven threat. Option A is the production default.
243- if ( ! opts . reuseExports && tableOption === 'A' ) {
244- const guard = await checkEdgeSnapshotQuality ( { snapshotDate : opts . today , ecosystems, fullScan } )
245- if ( ! guard . ok ) {
241+ const resume = opts . resumeJobId != null
242+
243+ // Resume is only safe for idempotent merges: incremental uses ON CONFLICT DO NOTHING and fill uses
244+ // ON CONFLICT DO UPDATE, so re-running an overlapping chunk no-ops. A full (non-fill) load uses a
245+ // plain INSERT with the UNIQUE constraint dropped, so a re-merged chunk would duplicate rows.
246+ if ( resume && fullScan && ! isFill ) {
247+ throw ApplicationFailure . nonRetryable (
248+ `resume (resumeJobId) is not supported for full loads — plain INSERT would duplicate rows. ` +
249+ `Use incremental or --fill-constraints.` ,
250+ 'RESUME_UNSUPPORTED' ,
251+ )
252+ }
253+
254+ let exportResult : { jobId : number ; gcsPrefix : string ; rowCount : number }
255+ // Files already loaded into staging by the prior (resumed) job, and rows it had already merged.
256+ let resumeProgressDone = 0
257+ let resumeRowCountPg = 0
258+ if ( opts . resumeJobId != null ) {
259+ // Reuse the prior job's exact export by id — bypasses reuse-by-kind (which excludes 'failed'
260+ // jobs and could grab a different, older export) and skips the multi-hour BQ scan entirely.
261+ // Validate hard here (non-retryable) so a bad id fails immediately instead of corrupting state.
262+ const prior = await getResumeExport ( { jobId : opts . resumeJobId } )
263+ if ( ! prior ) {
264+ throw ApplicationFailure . nonRetryable (
265+ `resume job ${ opts . resumeJobId } not found` ,
266+ 'RESUME_INVALID' ,
267+ )
268+ }
269+ if ( prior . jobKind !== 'package_dependencies' ) {
270+ throw ApplicationFailure . nonRetryable (
271+ `resume job ${ opts . resumeJobId } is kind '${ prior . jobKind } ', expected 'package_dependencies'` ,
272+ 'RESUME_INVALID' ,
273+ )
274+ }
275+ // Resumable = has an export to reuse with unfinished merge work: exported (no chunk merged yet),
276+ // loading/merging (interrupted mid-run), failed (the common case). done = nothing to do;
277+ // cleaned = parquet already deleted; pending/exporting = no export produced yet.
278+ const RESUMABLE_STATUSES = [ 'exported' , 'loading' , 'merging' , 'failed' ]
279+ if ( ! RESUMABLE_STATUSES . includes ( prior . status ) ) {
280+ throw ApplicationFailure . nonRetryable (
281+ `resume job ${ opts . resumeJobId } has status '${ prior . status } ' — not resumable ` +
282+ `(expected one of: ${ RESUMABLE_STATUSES . join ( ', ' ) } )` ,
283+ 'RESUME_INVALID' ,
284+ )
285+ }
286+ if ( ! prior . gcsPrefix ) {
246287 throw ApplicationFailure . nonRetryable (
247- `edge snapshot quality guard failed for ${ opts . today } : ${ guard . reason } . ` +
248- `Slack alert sent. Aborting before export to preserve existing package_dependencies and compute.` ,
249- 'EDGE_SNAPSHOT_GUARD' ,
288+ `resume job ${ opts . resumeJobId } has no gcs_prefix — nothing was exported to resume from` ,
289+ 'RESUME_INVALID' ,
250290 )
251291 }
292+ exportResult = { jobId : prior . jobId , gcsPrefix : prior . gcsPrefix , rowCount : 0 }
293+ resumeProgressDone = prior . progressDone
294+ resumeRowCountPg = prior . rowCountPg
295+ } else {
296+ // Guard against corrupt deps.dev resolved-graph snapshots BEFORE the (multi-hour) export.
297+ // Skip when reusing a prior export — we're re-importing already-validated parquet, not
298+ // scanning the live snapshot. Both full (*Latest = newest snapshot) and incremental can hit
299+ // a bad snapshot, so the guard runs for both. Probes only resolved-graph ecosystems; a clean
300+ // GO/NUGET-only run finds no canaries and passes through.
301+ //
302+ // Option A only: the guard's canary ratios are DependencyGraphEdges-schema-specific. Option B
303+ // ingests the separate Dependencies/DependenciesLatest table, which the 2026-06 corruption was
304+ // never observed in and has no calibrated baseline — probing Edges there would "validate" a table
305+ // we don't ingest (false confidence) and could abort a healthy Option B run when only Edges is bad.
306+ // Option B is a manual, non-scheduled cost-experiment path (--deps-table-b); leave it unguarded by
307+ // design rather than invent a guard for an unproven threat. Option A is the production default.
308+ if ( ! opts . reuseExports && tableOption === 'A' ) {
309+ const guard = await checkEdgeSnapshotQuality ( {
310+ snapshotDate : opts . today ,
311+ ecosystems,
312+ fullScan,
313+ } )
314+ if ( ! guard . ok ) {
315+ throw ApplicationFailure . nonRetryable (
316+ `edge snapshot quality guard failed for ${ opts . today } : ${ guard . reason } . ` +
317+ `Slack alert sent. Aborting before export to preserve existing package_dependencies and compute.` ,
318+ 'EDGE_SNAPSHOT_GUARD' ,
319+ )
320+ }
321+ }
322+ // Fill mode always uses full SQL — needs all rows to find which have NULL version_constraint in DB.
323+ const sql = fullScan
324+ ? buildDepsFullSql ( ecosystems , tableOption )
325+ : buildDepsIncrementalSql ( opts . today , opts . watermark ?? '' , ecosystems , tableOption )
326+
327+ exportResult = await bqExportToGcs ( {
328+ jobKind : 'package_dependencies' ,
329+ sql,
330+ runId : opts . runId ,
331+ // Report the PHYSICAL scan mode, not the requested one. A fill run (fillConstraints) forces a
332+ // full *Latest scan even when opts.syncMode is 'incremental'; passing the raw mode would record
333+ // the job as incremental and make bqExportToGcs pick the INCREMENTAL byte-ceiling env override
334+ // for a query that's actually full. fullScan already gates the SQL + maxBytesGb below.
335+ syncMode : fullScan ? 'full' : opts . syncMode ,
336+ snapshotAt : opts . today ,
337+ // Full/fill scan the *Latest views (everything) → 25000. Incremental is a snapshot edge-diff
338+ // (today vs watermark partitions of DependencyGraphEdges + GoRequirements + NuGetRequirements);
339+ // measured ~4.1TB for Option A. 10000 leaves ~2.4x headroom and still trips a runaway full-table
340+ // scan. Overridable via BQ_DATASET_INGEST_PACKAGE_DEPENDENCIES[_INCREMENTAL]_MAX_BQ_GB (see README).
341+ maxBytesGb : fullScan ? 25000 : 10000 ,
342+ reuseExports : opts . reuseExports ,
343+ exportName : opts . exportName ,
344+ ecosystems,
345+ } )
252346 }
253- // Fill mode always uses full SQL — needs all rows to find which have NULL version_constraint in DB.
254- const sql = fullScan
255- ? buildDepsFullSql ( ecosystems , tableOption )
256- : buildDepsIncrementalSql ( opts . today , opts . watermark ?? '' , ecosystems , tableOption )
257-
258- const exportResult = await bqExportToGcs ( {
259- jobKind : 'package_dependencies' ,
260- sql,
261- runId : opts . runId ,
262- // Report the PHYSICAL scan mode, not the requested one. A fill run (fillConstraints) forces a
263- // full *Latest scan even when opts.syncMode is 'incremental'; passing the raw mode would record
264- // the job as incremental and make bqExportToGcs pick the INCREMENTAL byte-ceiling env override
265- // for a query that's actually full. fullScan already gates the SQL + maxBytesGb below.
266- syncMode : fullScan ? 'full' : opts . syncMode ,
267- snapshotAt : opts . today ,
268- // Full/fill scan the *Latest views (everything) → 25000. Incremental is a snapshot edge-diff
269- // (today vs watermark partitions of DependencyGraphEdges + GoRequirements + NuGetRequirements);
270- // measured ~4.1TB for Option A. 10000 leaves ~2.4x headroom and still trips a runaway full-table
271- // scan. Overridable via BQ_DATASET_INGEST_PACKAGE_DEPENDENCIES[_INCREMENTAL]_MAX_BQ_GB (see README).
272- maxBytesGb : fullScan ? 25000 : 10000 ,
273- reuseExports : opts . reuseExports ,
274- exportName : opts . exportName ,
275- ecosystems,
276- } )
277347
278348 const { fileNames, rowCounts } = await listParquetFiles ( { gcsPrefix : exportResult . gcsPrefix } )
279349 const totalFiles = fileNames . length
280350 const totalRows = rowCounts . reduce ( ( a , b ) => a + b , 0 )
281351
352+ // Resume must find the exact parquet it recorded. Empty means GCS expired/deleted the files —
353+ // fail loudly rather than fall through to the 0-row early-return below, which would mark the
354+ // job 'done' and silently abandon the un-merged tail.
355+ if ( opts . resumeJobId != null && ( totalFiles === 0 || totalRows === 0 ) ) {
356+ throw ApplicationFailure . nonRetryable (
357+ `resume job ${ opts . resumeJobId } : no parquet files at ${ exportResult . gcsPrefix } ` +
358+ `(export expired or deleted) — run a fresh export instead of --resume-job` ,
359+ 'RESUME_INVALID' ,
360+ )
361+ }
362+
282363 if ( totalFiles === 0 || totalRows === 0 ) {
283364 await mergeStagingToTable ( {
284365 jobId : exportResult . jobId ,
@@ -305,11 +386,18 @@ export async function ingestDependencies(opts: {
305386 ? Math . max ( 1 , Math . round ( ( ROWS_PER_CHUNK * fileNames . length ) / totalRows ) )
306387 : Math . min ( fileNames . length , 2 )
307388 const totalChunks = Math . ceil ( fileNames . length / filesPerChunk )
308- let priorRowsAffected = 0
389+ // Resume: the prior job recorded files loaded (progress:done) but not a per-merge-chunk index,
390+ // and the merge lags the load by up to one chunk. Restart one chunk before where the load
391+ // stopped and let ON CONFLICT no-op the overlap. Clamp to [0, totalChunks). Seed priorRowsAffected
392+ // with the prior job's merged count so the final 'done' row_count_pg stays roughly accurate.
393+ const startChunk = resume
394+ ? Math . max ( 0 , Math . min ( Math . floor ( resumeProgressDone / filesPerChunk ) - 1 , totalChunks - 1 ) )
395+ : 0
396+ let priorRowsAffected = resume ? resumeRowCountPg : 0
309397 let priorStagingRows = 0
310398 const priorTableRowCounts : Record < string , number > = { }
311399
312- for ( let chunkIndex = 0 ; chunkIndex < totalChunks ; chunkIndex ++ ) {
400+ for ( let chunkIndex = startChunk ; chunkIndex < totalChunks ; chunkIndex ++ ) {
313401 const start = chunkIndex * filesPerChunk
314402 const chunk = fileNames . slice ( start , start + filesPerChunk )
315403 const isLastChunk = chunkIndex === totalChunks - 1
0 commit comments