Skip to content

Commit 1b1c4ae

Browse files
authored
fix: bq depsdev deadlock and a couple of fixes and optimizations (#4238)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 4bafe13 commit 1b1c4ae

24 files changed

Lines changed: 845 additions & 160 deletions

docs/adr/0003-deps-bq-table-selection.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ADR-0003: Use DependencyGraphEdgesLatest for deps ingestion; defer DependenciesLatest until NUGET needed
1+
# ADR-0003: Use DependencyGraphEdgesLatest for deps ingestion; defer DependenciesLatest until NUGET or GO needed
22

33
**Date**: 2026-05-29
44
**Status**: accepted
@@ -14,9 +14,9 @@ NPM + MAVEN only.
1414
## Decision
1515
Use `DependencyGraphEdgesLatest` (Option A) with `From.Name = Name AND
1616
From.Version = Version` as the depth-1 filter. Switch to `DependenciesLatest`
17-
(Option B) only when NUGET ingestion is required, since Option A does not
18-
support NUGET. At that point evaluate whether to migrate all ecosystems or
19-
add NUGET via Option B only.
17+
(Option B) only when NUGET or GO ingestion is required, since Option A does not
18+
support either ecosystem. At that point evaluate whether to migrate all ecosystems
19+
or add GO/NUGET via Option B only.
2020

2121
## Alternatives Considered
2222

@@ -27,7 +27,7 @@ add NUGET via Option B only.
2727
e.g. `^1.2.3`); only exposes resolved version
2828
- **Why not**: NPM and MAVEN are the only ecosystems needed now. Option A
2929
retains `version_constraint` which has security feature value. Cost delta is
30-
acceptable for the current scope. Re-evaluate when NUGET is required.
30+
acceptable for the current scope. Re-evaluate when NUGET or GO is required.
3131

3232
## Consequences
3333

@@ -36,11 +36,15 @@ add NUGET via Option B only.
3636
- No migration risk — simpler to stay on the table already coded
3737

3838
### Negative
39-
- NUGET not supported; must revisit when NUGET ingestion is added
39+
- Only NPM, MAVEN, PYPI, CARGO are present in `DependencyGraphEdgesLatest`.
40+
GO and NUGET are absent entirely — confirmed via BQ query on the live table
41+
(`SELECT System, COUNT(*) … GROUP BY System` returns exactly those 4).
42+
GO uses Minimal Version Selection (no graph resolution by deps.dev);
43+
NUGET is simply not covered. Must revisit when either ecosystem is added.
4044
- Full bootstrap ~$1,291 vs ~$494 for NPM+MAVEN (Option B cheaper)
4145
- Weekly incremental ~$26 vs ~$7 (Option B cheaper)
4246

4347
### Risks
44-
- If NUGET is added before a re-evaluation, the team may miss that Option B
48+
- If NUGET or GO is added before a re-evaluation, the team may miss that Option B
4549
is required. Mitigation: this ADR and the note in
4650
`personal/osspckgs-bq-cost-report.txt` flag the trigger condition.

docs/adr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
99
| ADR | Title | Status | Date |
1010
| --------------------------------------------------- | ---------------------------------------- | ------ | ---------- |
1111
| [ADR-0001](./0001-oss-packages-design-decisions.md) | OSS packages — design decisions (living) | living | 2026-05-27 |
12-
| [ADR-0003](./0003-deps-bq-table-selection.md) | Use DependencyGraphEdgesLatest for deps ingestion | accepted | 2026-05-29 |
12+
| [ADR-0003](./0003-deps-bq-table-selection.md) | Use DependencyGraphEdgesLatest for deps ingestion; defer DependenciesLatest until NUGET or GO needed | accepted | 2026-05-29 |
1313

1414
## Why ADRs?
1515

scripts/cli

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,55 @@ function select_services() {
255255
echo "Selected services: $selected_services"
256256
}
257257

258+
function monitor_workflow() {
259+
local REPOSITORY="$1"
260+
local WORKFLOW_FILE="$2"
261+
local EXISTING_IDS="${3:-}"
262+
263+
yell "Waiting for workflow run to appear..."
264+
265+
local RUN_ID=""
266+
local ATTEMPTS=0
267+
268+
while [[ -z "$RUN_ID" && $ATTEMPTS -lt 24 ]]; do
269+
sleep 5
270+
local CANDIDATE
271+
CANDIDATE=$(gh run list \
272+
--repo "$REPOSITORY" \
273+
--workflow "$WORKFLOW_FILE" \
274+
--limit 1 \
275+
--json databaseId \
276+
--jq '.[0].databaseId // empty' 2>/dev/null || echo "")
277+
278+
if [[ -n "$CANDIDATE" ]] && ! echo "$EXISTING_IDS" | grep -q "^${CANDIDATE}$"; then
279+
RUN_ID="$CANDIDATE"
280+
fi
281+
282+
ATTEMPTS=$((ATTEMPTS + 1))
283+
done
284+
285+
if [[ -z "$RUN_ID" ]]; then
286+
error "Could not find new workflow run after $((ATTEMPTS * 5)) seconds."
287+
return 1
288+
fi
289+
290+
say "Monitoring run #$RUN_ID — https://github.com/$REPOSITORY/actions/runs/$RUN_ID"
291+
nl
292+
293+
gh run watch "$RUN_ID" --repo "$REPOSITORY" || true
294+
295+
local CONCLUSION
296+
CONCLUSION=$(gh run view "$RUN_ID" --repo "$REPOSITORY" --json conclusion --jq '.conclusion')
297+
298+
nl
299+
if [[ "$CONCLUSION" == "success" ]]; then
300+
say "✓ Deployment succeeded!"
301+
else
302+
error "✗ Deployment failed: $CONCLUSION"
303+
return 1
304+
fi
305+
}
306+
258307
function deploy_staging() {
259308
REPOSITORY="CrowdDotDev/crowd.dev"
260309
WORKFLOW_FILE="lf-oracle-staging-deploy.yaml"
@@ -264,7 +313,11 @@ function deploy_staging() {
264313
echo "${SELECTED_SERVICES[*]}"
265314
)"
266315

316+
local EXISTING_IDS
317+
EXISTING_IDS=$(gh run list --repo "$REPOSITORY" --workflow "$WORKFLOW_FILE" --limit 10 --json databaseId --jq '.[].databaseId' 2>/dev/null || echo "")
318+
267319
gh workflow run $WORKFLOW_FILE --repo $REPOSITORY --ref $CURRENT_BRANCH -f services="$SERVICES"
320+
monitor_workflow "$REPOSITORY" "$WORKFLOW_FILE" "$EXISTING_IDS"
268321
}
269322

270323
function deploy_production() {
@@ -276,7 +329,11 @@ function deploy_production() {
276329
echo "${SELECTED_SERVICES[*]}"
277330
)"
278331

332+
local EXISTING_IDS
333+
EXISTING_IDS=$(gh run list --repo "$REPOSITORY" --workflow "$WORKFLOW_FILE" --limit 10 --json databaseId --jq '.[].databaseId' 2>/dev/null || echo "")
334+
279335
gh workflow run $WORKFLOW_FILE --repo $REPOSITORY --ref $CURRENT_BRANCH -f services="$SERVICES"
336+
monitor_workflow "$REPOSITORY" "$WORKFLOW_FILE" "$EXISTING_IDS"
280337
}
281338

282339
function reset_selected_services() {

services/apps/packages_worker/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"backfill:stewardship:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=stewardship-backfill LOG_LEVEL=info tsx src/bin/stewardship-backfill.ts",
4040
"monitor:osspckgs": "SERVICE=bq-dataset-ingest tsx src/scripts/monitorOsspckgs.ts",
4141
"monitor:osspckgs:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=bq-dataset-ingest tsx src/scripts/monitorOsspckgs.ts",
42+
"dedup-package-deps": "SERVICE=bq-dataset-ingest tsx src/scripts/dedupPackageDeps.ts",
43+
"dedup-package-deps:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=bq-dataset-ingest tsx src/scripts/dedupPackageDeps.ts",
4244
"lint": "npx eslint --ext .ts src --max-warnings=0",
4345
"format": "npx prettier --write \"src/**/*.ts\"",
4446
"format-check": "npx prettier --check .",

services/apps/packages_worker/src/criticality/activities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export async function rankPackages(): Promise<{ scoredRows: number; rankedRows:
7878

7979
const jobId = existing?.id ?? (await createIngestJob(qx, 'ranking', 'ranking', null))
8080
try {
81+
await markJobStatus(qx, jobId, 'merging')
8182
const [result] = await qx.select(`SELECT * FROM rank_packages()`)
8283
const scoredRows = Number(result.scored_rows ?? 0)
8384
const rankedRows = Number(result.ranked_rows ?? 0)

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface BqExportToGcsInput {
2424
maxBytesGb: number
2525
reuseExports?: boolean
2626
exportName?: string
27+
ecosystems?: string[]
2728
}
2829

2930
export interface BqExportToGcsOutput {
@@ -34,7 +35,17 @@ export interface BqExportToGcsOutput {
3435
}
3536

3637
export async function bqExportToGcs(input: BqExportToGcsInput): Promise<BqExportToGcsOutput> {
37-
const { jobKind, sql, runId, syncMode, snapshotAt, maxBytesGb, reuseExports, exportName } = input
38+
const {
39+
jobKind,
40+
sql,
41+
runId,
42+
syncMode,
43+
snapshotAt,
44+
maxBytesGb,
45+
reuseExports,
46+
exportName,
47+
ecosystems,
48+
} = input
3849

3950
// Named exports use a stable GCS path independent of runId so they survive across bootstrap runs.
4051
const namedGcsPrefix = exportName
@@ -93,7 +104,10 @@ export async function bqExportToGcs(input: BqExportToGcsInput): Promise<BqExport
93104
gcsPrefix: namedPrefix,
94105
rowCountBq: 0,
95106
bqBytesBilled: 0,
96-
tableRowCounts: { 'bq:export': 0 },
107+
tableRowCounts: {
108+
'bq:export': 0,
109+
...(ecosystems ? { 'meta:ecosystems': ecosystems } : {}),
110+
},
97111
})
98112
return { gcsPrefix: namedPrefix, rowCount: 0, bqBytesBilled: 0, jobId }
99113
}
@@ -177,8 +191,10 @@ export async function bqExportToGcs(input: BqExportToGcsInput): Promise<BqExport
177191
const provisionalDate = snapshotAt ? new Date(snapshotAt) : null
178192
const jobId = await createIngestJob(qx, jobKind, syncMode, provisionalDate, exportName)
179193

180-
// H7: mark exporting before we start the BQ job
181-
await markJobStatus(qx, jobId, 'exporting')
194+
// H7: mark exporting before we start the BQ job; store ecosystems filter in table_row_counts JSONB.
195+
await markJobStatus(qx, jobId, 'exporting', {
196+
...(ecosystems ? { tableRowCounts: { 'meta:ecosystems': ecosystems } } : {}),
197+
})
182198

183199
// B9: wrap in SELECT * FROM (...) so QUALIFY / top-level set ops don't break EXPORT DATA syntax.
184200
// CREATE TEMP TABLE first so BQ materializes the result before exporting — direct EXPORT DATA

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './bqExportToGcs'
2+
export * from './setJobStep'
23
export * from './createVersionsLookup'
34
export * from './managePackageDepsConstraints'
45
export * from './managePackageDepsIndexes'

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

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,20 @@ export async function rebuildPackageDepsIndexes(): Promise<{
9696
const existing: Array<{ indexdef: string }> = await qx.select(NON_CONSTRAINT_INDEXES_SQL)
9797
const existingDefs = existing.map((r: { indexdef: string }) => r.indexdef.toLowerCase())
9898

99+
const toRebuild = SECONDARY_INDEXES.filter(
100+
(idx) => !existingDefs.some((def: string) => def.includes(`(${idx.columns})`)),
101+
)
99102
const rebuilt: string[] = []
100103

101-
for (const idx of SECONDARY_INDEXES) {
102-
const alreadyExists = existingDefs.some((def: string) => def.includes(`(${idx.columns})`))
103-
if (alreadyExists) {
104-
log.info({ columns: idx.columns }, 'Index already exists, skipping')
105-
continue
106-
}
107-
log.info({ columns: idx.columns }, 'Creating index on package_dependencies')
108-
await qx.result(idx.createSql)
109-
rebuilt.push(idx.columns)
110-
}
104+
// Build indexes in parallel — each on its own connection so they run concurrently.
105+
await Promise.all(
106+
toRebuild.map(async (idx) => {
107+
const conn = await getPackagesDb()
108+
log.info({ columns: idx.columns }, 'Creating index on package_dependencies')
109+
await conn.result(idx.createSql)
110+
rebuilt.push(idx.columns)
111+
}),
112+
)
111113

112114
// Remove cross-chunk duplicates before rebuilding the UNIQUE constraint.
113115
// DISTINCT ON deduplicates within a single chunk, but the same (root, dep) pair can appear in
@@ -118,27 +120,43 @@ export async function rebuildPackageDepsIndexes(): Promise<{
118120
// Run per partition (depends_on_id % 64 = p) so each iteration prunes to one of the 64
119121
// partitions instead of scanning all 1.15B rows at once. Same total rows read; each pass
120122
// fits in work_mem and avoids cross-partition sort.
123+
// Run in parallel batches of DEDUP_CONCURRENCY to cut wall-clock from ~10h to ~1-2h.
121124
const NUM_PARTITIONS = 64
125+
const DEDUP_CONCURRENCY = 8
122126
let totalDedupDeleted = 0
123-
for (let p = 0; p < NUM_PARTITIONS; p++) {
124-
const result = await qx.result(`
125-
DELETE FROM package_dependencies pd
126-
USING (
127-
SELECT id, depends_on_id
128-
FROM (
129-
SELECT id, depends_on_id,
130-
ROW_NUMBER() OVER (
131-
PARTITION BY version_id, depends_on_id, dependency_kind
132-
ORDER BY id
133-
) AS rn
134-
FROM package_dependencies
135-
WHERE depends_on_id % ${NUM_PARTITIONS} = ${p}
136-
) sub
137-
WHERE rn > 1
138-
) dupes
139-
WHERE pd.id = dupes.id AND pd.depends_on_id = dupes.depends_on_id
140-
`)
141-
totalDedupDeleted += result
127+
for (let batch = 0; batch < NUM_PARTITIONS; batch += DEDUP_CONCURRENCY) {
128+
const partitions = Array.from(
129+
{ length: Math.min(DEDUP_CONCURRENCY, NUM_PARTITIONS - batch) },
130+
(_, i) => batch + i,
131+
)
132+
const counts = await Promise.all(
133+
partitions.map(async (p) => {
134+
const conn = await getPackagesDb()
135+
// work_mem (not maintenance_work_mem) controls sort memory for window functions.
136+
// 2GB avoids disk spill during the ROW_NUMBER() sort on each partition.
137+
return conn.tx(async (tx) => {
138+
await tx.result(`SET LOCAL work_mem = '2GB'`)
139+
return tx.result(`
140+
DELETE FROM package_dependencies pd
141+
USING (
142+
SELECT id, depends_on_id
143+
FROM (
144+
SELECT id, depends_on_id,
145+
ROW_NUMBER() OVER (
146+
PARTITION BY version_id, depends_on_id, dependency_kind
147+
ORDER BY id
148+
) AS rn
149+
FROM package_dependencies
150+
WHERE depends_on_id % ${NUM_PARTITIONS} = ${p}
151+
) sub
152+
WHERE rn > 1
153+
) dupes
154+
WHERE pd.id = dupes.id AND pd.depends_on_id = dupes.depends_on_id
155+
`)
156+
})
157+
}),
158+
)
159+
totalDedupDeleted += counts.reduce((sum, n) => sum + n, 0)
142160
}
143161
log.info(
144162
{ rowsDeleted: totalDedupDeleted },

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,25 @@ export async function rebuildVersionsIndexes(): Promise<{
8686
const existing: Array<{ indexdef: string }> = await qx.select(NON_CONSTRAINT_INDEXES_SQL)
8787
const existingDefs = existing.map((r) => r.indexdef.toLowerCase())
8888

89+
const toRebuild = SECONDARY_INDEXES.filter(
90+
(idx) => !existingDefs.some((def) => def.includes(idx.matchString ?? `(${idx.columns})`)),
91+
)
8992
const rebuilt: string[] = []
9093

91-
for (const idx of SECONDARY_INDEXES) {
92-
const alreadyExists = existingDefs.some((def) =>
93-
def.includes(idx.matchString ?? `(${idx.columns})`),
94-
)
95-
if (alreadyExists) {
96-
log.info({ columns: idx.columns }, 'Index already exists, skipping')
97-
continue
98-
}
99-
log.info({ columns: idx.columns }, 'Creating index on versions')
100-
await qx.result(idx.createSql)
101-
rebuilt.push(idx.columns)
102-
}
94+
// Build indexes in parallel — each on its own connection so they run concurrently.
95+
// maintenance_work_mem per connection: with 32 partitions and default 64MB, PG spills to
96+
// disk on every partition; 2GB lets the sort fit in RAM and cuts build time dramatically.
97+
await Promise.all(
98+
toRebuild.map(async (idx) => {
99+
const conn = await getPackagesDb()
100+
await conn.tx(async (t) => {
101+
await t.result(`SET LOCAL maintenance_work_mem = '2GB'`)
102+
log.info({ columns: idx.columns }, 'Creating index on versions')
103+
await t.result(idx.createSql)
104+
})
105+
rebuilt.push(idx.columns)
106+
}),
107+
)
103108

104109
// Remove cross-chunk duplicates before rebuilding the UNIQUE constraint.
105110
// versions is HASH-partitioned by package_id (32 partitions). Loop over each partition table
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { mergeJobTableRowCounts } from '@crowd/data-access-layer'
2+
3+
import { getPackagesDb } from '../../db'
4+
5+
export async function setJobStep(input: { jobId: number; step: string }): Promise<void> {
6+
const qx = await getPackagesDb()
7+
await mergeJobTableRowCounts(qx, input.jobId, { 'meta:step': input.step })
8+
}

0 commit comments

Comments
 (0)