Skip to content

Commit d104e6e

Browse files
epipavskwowet
authored andcommitted
feat: pypi worker with downloads (#4291)
Signed-off-by: anilb <epipav@gmail.com> Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 7a9f222 commit d104e6e

45 files changed

Lines changed: 2541 additions & 76 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
2+
CREATE TABLE pypi_package_state (
3+
purl text PRIMARY KEY,
4+
metadata_first_scanned_at timestamptz NOT NULL DEFAULT now(),
5+
metadata_last_run_at timestamptz,
6+
metadata_run_result jsonb -- { status, attempts, httpStatus?, errorKind?, message? }
7+
);
8+
9+
CREATE INDEX ON pypi_package_state (metadata_last_run_at);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# ADR-0005: PyPI downloads via BigQuery bulk export, scoped in the Postgres merge
2+
3+
**Date**: 2026-07-01
4+
**Status**: accepted
5+
**Deciders**: Anil B
6+
7+
_Consolidated ADR for the PyPI downloads worker — record further PyPI-worker download decisions here rather than opening new ADRs._
8+
9+
## Context
10+
11+
We need PyPI download counts to match the npm shape: daily counts for the **Critical slice**
12+
(`downloads_daily`) and rolling 30-day **Window** counts for all tracked pypi packages
13+
(`downloads_last_30d`, mirrored to `packages.downloads_last_30d`). Unlike npm, **PyPI exposes no
14+
per-package downloads HTTP API** — the only source is the public BigQuery dataset
15+
`bigquery-public-data.pypi.file_downloads` (raw per-download events, timestamp-partitioned). The
16+
worker already has proven deps.dev BigQuery→GCS→staging→merge plumbing and a job monitor keyed on
17+
`osspckgs_ingest_jobs`. Cost is driven by bytes scanned, and a single day of the three columns we
18+
read (`file.project`, `timestamp`, `details.installer.name`) measures ~107 GB (weekend) / ~147 GB
19+
(monthly average), so a 30-day window is ~4.56 TB.
20+
21+
## Decision
22+
23+
Ingest PyPI downloads as two new `bq-dataset-ingest` job kinds (`pypi_downloads_30d`,
24+
`pypi_downloads_daily`) that run one BigQuery aggregate over a date range, export **all** projects to
25+
GCS, load to staging, and **scope to the Critical slice in the Postgres merge** (`JOIN packages …
26+
AND is_critical` for daily) — we never push our package list into BigQuery. The 30d workflow does a
27+
**Latest-window refresh** for all pypi (mirroring the latest **Window**); the daily workflow does a
28+
2-day **Trailing re-scan** for the critical subset. Both are idempotent (`ON CONFLICT DO UPDATE`),
29+
fixed-window, and gap-recovered by manual **Backfill** — they are deliberately **not** self-healing.
30+
31+
## Alternatives Considered
32+
33+
### Alternative 1: npm-style per-package HTTP fetch with watermark due-selection
34+
- **Pros**: reuses the npm downloads model exactly; source is scoped to what's due; naturally self-healing.
35+
- **Cons**: requires a per-package downloads API.
36+
- **Why not**: PyPI has no such API. The BigQuery public dataset is the only source, which forces a bulk-aggregate model.
37+
38+
### Alternative 2: Push the critical package list into BigQuery (inline `IN UNNEST([...])`) to shrink the export
39+
- **Pros**: smaller GCS export and staging load, especially for daily backfills.
40+
- **Cons**: inlines our data into the query text.
41+
- **Why not**: the critical set can grow to tens of thousands+; the inline list blows BigQuery's ~1 MB query-text limit (and Temporal's ~2 MB payload limit for the name list). Merge-scoping is unbounded and matches how every deps.dev job scopes to our data in Postgres, not at the source. A cheap `getCriticalPypiCount` guard skips the scan when there are zero critical packages.
42+
43+
### Alternative 3: Gap-filling self-healing (npm's `computeMissingLast30dWindows` model)
44+
- **Pros**: auto-recovers missed days/months without manual intervention.
45+
- **Cons**: needs per-package due-selection / existing-window diffing, extra state and complexity, and re-scans BigQuery anyway.
46+
- **Why not**: for a bulk-BQ source the simpler fixed-window + idempotent-upsert + manual **Backfill** model is sufficient; deps.dev jobs re-scan on re-run too. The daily 2-day **Trailing re-scan** already corrects a partial most-recent partition.
47+
48+
## Consequences
49+
50+
### Positive
51+
- Reuses the deps.dev BQ→GCS→staging→merge plumbing and the `monitor:osspckgs` cost/row dashboard for free.
52+
- Scoping in the merge scales to any critical-set size; our package identifiers never leave Postgres.
53+
- Idempotent upserts make re-runs and overlapping backfills safe (no duplicate rows).
54+
55+
### Negative
56+
- Re-running a date range re-scans BigQuery and re-bills — there is no "already imported" skip.
57+
- The daily 2-day window re-scans each calendar day ~2×; steady-state cost ≈ $610/yr daily + $311/yr 30d ≈ **~$920/yr** at $6.25/TiB (measured).
58+
- Not self-healing: an outage or missed schedule is recovered only by a manual **Backfill**.
59+
- Daily export carries all ~800k projects even though the merge keeps only the critical subset (larger data movement than a source-filtered approach).
60+
61+
### Risks
62+
- **BigQuery cost / runaway scans** — mitigated by per-kind `BQ_DATASET_INGEST_PYPI_DOWNLOADS_*_MAX_BQ_GB` ceilings enforced via a pre-run dry-run (aborts before billing); defaults set from measured sizes (30d = 6000 GB, daily = 2000 GB).
63+
- **Traffic growth** — the ~4.56 TB/30d figure grows with PyPI traffic; ceilings may need raising over time.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
1111
| [ADR-0001](./0001-oss-packages-design-decisions.md) | OSS packages — design decisions (living) | living | 2026-05-27 |
1212
| [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
| [ADR-0004](./0004-go-nuget-transitive-dependent-counts.md) | Compute GO/NUGET transitive dependent counts via exact reverse closure (over HLL approximation) | accepted | 2026-06-23 |
14+
| [ADR-0005](./0005-pypi-downloads-bigquery-merge-scoping.md) | PyPI downloads via BigQuery bulk export, scoped in the Postgres merge | accepted | 2026-07-01 |
1415

1516
## Why ADRs?
1617

scripts/builders/packages.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
DOCKERFILE="./services/docker/Dockerfile.packages"
22
CONTEXT="../"
33
REPO="sjc.ocir.io/axbydjxa5zuh/packages"
4-
SERVICES="github-repos-enricher bq-dataset-ingest npm-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker"
4+
SERVICES="github-repos-enricher bq-dataset-ingest npm-worker pypi-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker"

scripts/services/pypi-worker.yaml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
version: '3.1'
2+
3+
x-env-args: &env-args
4+
DOCKER_BUILDKIT: 1
5+
NODE_ENV: docker
6+
SERVICE: pypi-worker
7+
CROWD_TEMPORAL_TASKQUEUE: pypi-worker
8+
CROWD_TEMPORAL_NAMESPACE: ${CROWD_PACKAGES_TEMPORAL_NAMESPACE}
9+
SHELL: /bin/sh
10+
SUPPRESS_NO_CONFIG_WARNING: 'true'
11+
12+
services:
13+
pypi-worker:
14+
build:
15+
context: ../../
16+
dockerfile: ./scripts/services/docker/Dockerfile.packages
17+
command: 'pnpm run start:pypi-worker'
18+
working_dir: /usr/crowd/app/services/apps/packages_worker
19+
env_file:
20+
- ../../backend/.env.dist.local
21+
- ../../backend/.env.dist.composed
22+
- ../../backend/.env.override.local
23+
- ../../backend/.env.override.composed
24+
environment:
25+
<<: *env-args
26+
restart: always
27+
networks:
28+
- crowd-bridge
29+
30+
pypi-worker-dev:
31+
build:
32+
context: ../../
33+
dockerfile: ./scripts/services/docker/Dockerfile.packages
34+
command: 'pnpm run dev:pypi-worker'
35+
working_dir: /usr/crowd/app/services/apps/packages_worker
36+
# user: '${USER_ID}:${GROUP_ID}'
37+
env_file:
38+
- ../../backend/.env.dist.local
39+
- ../../backend/.env.dist.composed
40+
- ../../backend/.env.override.local
41+
- ../../backend/.env.override.composed
42+
environment:
43+
<<: *env-args
44+
hostname: pypi-worker
45+
networks:
46+
- crowd-bridge
47+
volumes:
48+
- ../../services/libs/audit-logs/src:/usr/crowd/app/services/libs/audit-logs/src
49+
- ../../services/libs/common/src:/usr/crowd/app/services/libs/common/src
50+
- ../../services/libs/common_services/src:/usr/crowd/app/services/libs/common_services/src
51+
- ../../services/libs/data-access-layer/src:/usr/crowd/app/services/libs/data-access-layer/src
52+
- ../../services/libs/database/src:/usr/crowd/app/services/libs/database/src
53+
- ../../services/libs/integrations/src:/usr/crowd/app/services/libs/integrations/src
54+
- ../../services/libs/logging/src:/usr/crowd/app/services/libs/logging/src
55+
- ../../services/libs/nango/src:/usr/crowd/app/services/libs/nango/src
56+
- ../../services/libs/opensearch/src:/usr/crowd/app/services/libs/opensearch/src
57+
- ../../services/libs/queue/src:/usr/crowd/app/services/libs/queue/src
58+
- ../../services/libs/redis/src:/usr/crowd/app/services/libs/redis/src
59+
- ../../services/libs/snowflake/src:/usr/crowd/app/services/libs/snowflake/src
60+
- ../../services/libs/telemetry/src:/usr/crowd/app/services/libs/telemetry/src
61+
- ../../services/libs/temporal/src:/usr/crowd/app/services/libs/temporal/src
62+
- ../../services/libs/types/src:/usr/crowd/app/services/libs/types/src
63+
- ../../services/apps/packages_worker/src:/usr/crowd/app/services/apps/packages_worker/src
64+
65+
networks:
66+
crowd-bridge:
67+
external: true

services/apps/packages_worker/CONTEXT.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OSS Packages
22

3-
Tracks open-source packages across ecosystems (npm, Maven). All packages live in `packages`; criticality scoring ranks them in place and marks the top-N per ecosystem as `is_critical = true`.
3+
Tracks open-source packages across ecosystems (npm, pypi, maven, go, nuget, cargo — the set grows as ecosystems are onboarded). All packages live in `packages`; criticality scoring ranks them in place and marks the top-N per ecosystem as `is_critical = true`.
44

55
## Language
66

@@ -25,7 +25,7 @@ Package URL (`pkg:npm/react`, `pkg:maven/org.apache/commons`). The canonical cro
2525
_Avoid_: package id (that's the `packages.id` bigserial)
2626

2727
**Ecosystem**:
28-
A package registry namespace — `npm`, `maven`. Lowercase.
28+
A package registry namespace — `npm`, `pypi`, `maven`, `go`, `nuget`, `cargo`. Lowercase. Open set — new ecosystems are onboarded over time.
2929
_Avoid_: system (deps.dev's term), registry
3030

3131
**Packument**:
@@ -38,9 +38,19 @@ One rolling 30-day span in `downloads_last_30d`, identified by its `end_date` (a
3838
_Avoid_: month, period, snapshot
3939

4040
**Self-healing**:
41-
A workflow that recomputes the full set of expected rows on every run, diffs against what's in the DB, and fills only the gaps. No assumption of continuity between runs.
41+
A workflow that recomputes the full set of expected rows on every run, diffs against what's in the DB, and fills only the gaps. No assumption of continuity between runs. **npm downloads only** — pypi downloads deliberately do NOT self-heal (see **Trailing re-scan** / **Latest-window refresh**).
4242
_Avoid_: backfill (that's the one-time historical fill; self-healing is the ongoing property)
4343

44+
**Trailing re-scan** (pypi daily):
45+
The pypi daily downloads workflow re-scans a fixed 2-day trailing window (`[today−2, today−1]`) every run and upserts. It corrects a partial most-recent partition but does **not** diff against the DB or fill older gaps. Missed days are recovered only by **Backfill**.
46+
_Avoid_: self-healing (that's the npm gap-filling property)
47+
48+
**Latest-window refresh** (pypi 30d):
49+
The pypi 30d workflow, given no `fromDate`, computes and ingests only the latest **Window** (idempotent upsert, mirrored to `packages.downloads_last_30d`). It does not gap-fill missed months; those are recovered by **Backfill**.
50+
51+
**Backfill**:
52+
A manual, one-time run over an explicit date range to fill history or recover gaps — pypi daily takes `{startDate, endDate}`, pypi 30d takes `{fromDate}` (enumerates every monthly **Window** from then to the latest). For pypi downloads this is the _only_ gap-recovery mechanism; scheduled runs are fixed-window, not self-healing.
53+
4454
## Relationships
4555

4656
- All packages live in `packages`; `rank_packages()` sets `is_critical = true` on the top-N per ecosystem to define the **Critical slice**.

services/apps/packages_worker/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"start:npm-worker": "CROWD_TEMPORAL_TASKQUEUE=npm-worker SERVICE=npm-worker tsx src/bin/npm-worker.ts",
2929
"dev:npm-worker": "CROWD_TEMPORAL_TASKQUEUE=npm-worker SERVICE=npm-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9236 src/bin/npm-worker.ts",
3030
"dev:npm-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=npm-worker SERVICE=npm-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9236 src/bin/npm-worker.ts",
31+
"start:pypi-worker": "CROWD_TEMPORAL_TASKQUEUE=pypi-worker SERVICE=pypi-worker tsx src/bin/pypi-worker.ts",
32+
"dev:pypi-worker": "CROWD_TEMPORAL_TASKQUEUE=pypi-worker SERVICE=pypi-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9243 src/bin/pypi-worker.ts",
33+
"dev:pypi-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=pypi-worker SERVICE=pypi-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9243 src/bin/pypi-worker.ts",
3134
"start:osv-worker": "CROWD_TEMPORAL_TASKQUEUE=osv-worker SERVICE=osv-worker tsx src/bin/osv-worker.ts",
3235
"dev:osv-worker": "CROWD_TEMPORAL_TASKQUEUE=osv-worker SERVICE=osv-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9238 src/bin/osv-worker.ts",
3336
"dev:osv-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=osv-worker SERVICE=osv-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9238 src/bin/osv-worker.ts",

services/apps/packages_worker/src/activities.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,11 @@ export {
2525
cargoCleanup,
2626
} from './cargo/activities'
2727
export { enrichGoVersionsBatch, enrichGoStatusBatch } from './go/activities'
28+
export {
29+
getUnscannedPypiBatch,
30+
ingestPypiPackageBatch,
31+
pypiStopAfterFirstPage,
32+
} from './pypi/activities'
33+
export { getCriticalPypiCount } from './pypi/downloads/getCriticalPypiCount'
2834
export { processNuGetBatch } from './nuget/activities'
2935
export { processSecurityContactsBatch } from './security-contacts/activities'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { scheduleOsspckgsBootstrap } from '../deps-dev/schedules/bootstrap'
2+
import {
3+
schedulePypiDownloads30d,
4+
schedulePypiDownloadsDaily,
5+
} from '../pypi/downloads/pypiDownloads'
26
import { svc } from '../service'
37

48
setImmediate(async () => {
59
await svc.init()
610
await scheduleOsspckgsBootstrap()
11+
await schedulePypiDownloads30d()
12+
await schedulePypiDownloadsDaily()
713
await svc.start()
814
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { schedulePypiIngest } from '../pypi/schedule'
2+
import { svc } from '../service'
3+
4+
setImmediate(async () => {
5+
await svc.init()
6+
await schedulePypiIngest()
7+
await svc.start()
8+
})

0 commit comments

Comments
 (0)