Skip to content

Commit 66c89b7

Browse files
committed
feat: resume partially-merged package_dependencies ingest by job id
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 7c45233 commit 66c89b7

6 files changed

Lines changed: 272 additions & 48 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { getIngestJobForResume } from '@crowd/data-access-layer'
2+
3+
import { getPackagesDb } from '../../db'
4+
5+
export interface GetResumeExportInput {
6+
jobId: number
7+
}
8+
9+
export interface GetResumeExportOutput {
10+
jobId: number
11+
jobKind: string
12+
status: string
13+
syncMode: string
14+
gcsPrefix: string | null
15+
progressDone: number
16+
progressTotal: number
17+
rowCountPg: number
18+
}
19+
20+
// Pure fetch of a prior job's resume-relevant fields (export path, status, file-load progress,
21+
// rows merged). Returns null if the job id does not exist. All validation — kind, status, presence
22+
// of an export, and that the parquet files still exist — is done by the caller (ingestDependencies)
23+
// so it can fail fast with non-retryable errors instead of retrying a bad-input activity.
24+
export async function getResumeExport(
25+
input: GetResumeExportInput,
26+
): Promise<GetResumeExportOutput | null> {
27+
const qx = await getPackagesDb()
28+
const job = await getIngestJobForResume(qx, input.jobId)
29+
if (!job) {
30+
return null
31+
}
32+
return {
33+
jobId: job.id,
34+
jobKind: job.jobKind,
35+
status: job.status,
36+
syncMode: job.syncMode,
37+
gcsPrefix: job.gcsPrefix,
38+
progressDone: job.progressDone,
39+
progressTotal: job.progressTotal,
40+
rowCountPg: job.rowCountPg,
41+
}
42+
}

services/apps/packages_worker/src/deps-dev/activities/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './listParquetFiles'
99
export * from './gcsParquetToStaging'
1010
export * from './mergeStagingToTable'
1111
export * from './getLastSnapshot'
12+
export * from './getResumeExport'
1213
export * from './checkDependentCountsGuard'
1314
export * from './checkEdgeSnapshotQuality'
1415
export * from './probePartitionExists'

services/apps/packages_worker/src/deps-dev/workflows/bootstrapOsspckgs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,17 @@ export async function bootstrapOsspckgs(opts: {
6969
exportName?: string
7070
snapshotDate?: string // YYYY-MM-DD — override BQ snapshot resolution for all partition-filtered kinds
7171
fillConstraints?: boolean // re-export full deps BQ data, upsert version_constraint where NULL
72+
resumeJobId?: number // resume a partially-merged package_dependencies job by id (skips its BQ export)
7273
}): Promise<void> {
7374
// B3: deterministic timestamps — workflowInfo().startTime is replay-stable; new Date() is not.
7475
const start = workflowInfo().startTime
7576
const runId = start.toISOString().replace(/[:.]/g, '-')
7677
const today = start.toISOString().slice(0, 10)
7778

79+
// Resume mode reuses a prior job's export, so there is no fresh BQ export to validate. Skip the
80+
// incremental watermark/partition checks below — the resumed partition may not match `today`.
81+
const resume = opts.resumeJobId != null
82+
7883
// Recovery: each child workflow updates osspckgs_ingest_jobs independently.
7984
// If a child fails mid-bootstrap, re-run with the SAME mode.
8085
// Already-done kinds skip naturally (today→today diff = 0 rows).
@@ -119,7 +124,7 @@ export async function bootstrapOsspckgs(opts: {
119124

120125
// Validate all watermarks up-front before touching BQ (fail fast, not mid-run)
121126
for (const jobKind of jobKinds) {
122-
if (opts.mode === 'incremental') {
127+
if (opts.mode === 'incremental' && !resume) {
123128
const { snapshotAt } = await getLastSnapshot({ jobKind })
124129
if (!snapshotAt) {
125130
throw new ApplicationFailure(`No watermark for ${jobKind} — run full bootstrap first`)
@@ -262,6 +267,7 @@ export async function bootstrapOsspckgs(opts: {
262267
depsTableOption: opts.depsTableOption,
263268
exportName: opts.exportName,
264269
fillConstraints: opts.fillConstraints,
270+
resumeJobId: opts.resumeJobId,
265271
},
266272
],
267273
})

services/apps/packages_worker/src/deps-dev/workflows/ingestDependencies.ts

Lines changed: 133 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ const { gcsParquetToStaging } = proxyActivities<typeof depsDevActivities>({
2424
})
2525

2626
const { 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+
3138
const { 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

Comments
 (0)