Skip to content

Commit 8aa0372

Browse files
committed
feat: pypi downloads ingest
Signed-off-by: anilb <epipav@gmail.com>
1 parent 5109f98 commit 8aa0372

30 files changed

Lines changed: 1201 additions & 155 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { scheduleOsspckgsBootstrap } from '../deps-dev/schedules/bootstrap'
2+
import {
3+
schedulePypiDownloads30d,
4+
schedulePypiDownloadsDaily,
5+
} from '../deps-dev/schedules/pypiDownloads'
26
import { scheduleOsspckgsCleanup } from '../schedules/cleanup'
37
import { svc } from '../service'
48

59
setImmediate(async () => {
610
await svc.init()
711
await scheduleOsspckgsBootstrap()
812
await scheduleOsspckgsCleanup()
13+
await schedulePypiDownloads30d()
14+
await schedulePypiDownloadsDaily()
915
await svc.start()
1016
})

services/apps/packages_worker/src/deps-dev/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ The mode-specific key takes precedence over the generic key. Value must be a pos
5858
| `BQ_DATASET_INGEST_DEPENDENT_COUNTS_MAX_BQ_GB` | 2000 | `dependent_counts` | |
5959
| `BQ_DATASET_INGEST_SCORECARD_REPOS_MAX_BQ_GB` | 50 | `scorecard_repos` | |
6060
| `BQ_DATASET_INGEST_SCORECARD_CHECKS_MAX_BQ_GB` | 500 | `scorecard_checks` | |
61+
| `BQ_DATASET_INGEST_PYPI_DOWNLOADS_30D_MAX_BQ_GB` | 6000 | `pypi_downloads_30d` | Per 30-day window scan (~4.56 TB measured; set in `ingestPypiDownloads.ts`) |
62+
| `BQ_DATASET_INGEST_PYPI_DOWNLOADS_DAILY_MAX_BQ_GB` | 2000 | `pypi_downloads_daily` | Scales with backfill range; raise for long backfills |
6163

6264
The override logic lives in `src/deps-dev/activities/bqExportToGcs.ts`.
6365

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getCriticalPypiPackageCount } from '@crowd/data-access-layer'
2+
3+
import { getPackagesDb } from '../../db'
4+
5+
// Count of critical PyPI packages, so the daily downloads workflow can skip its BigQuery scan
6+
// when there are none (the merge is scoped to is_critical, mirroring how deps.dev scopes to our
7+
// packages in the Postgres merge rather than pushing our package list into BigQuery).
8+
export async function getCriticalPypiCount(): Promise<{ count: number }> {
9+
const qx = await getPackagesDb()
10+
const count = await getCriticalPypiPackageCount(qx)
11+
return { count }
12+
}

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 './getCriticalPypiCount'
23
export * from './setJobStep'
34
export * from './createVersionsLookup'
45
export * from './managePackageDepsConstraints'
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
PYPI_EARLIEST,
5+
computeLast30dWindows,
6+
defaultDailyRange,
7+
utcFirstOfCurrentMonth,
8+
} from '../pypiDownloadsDates'
9+
import {
10+
PYPI_DOWNLOADS_30D_KIND,
11+
PYPI_DOWNLOADS_DAILY_KIND,
12+
buildPypiDownloads30dMergeSql,
13+
buildPypiDownloads30dSql,
14+
buildPypiDownloadsDailyMergeSql,
15+
buildPypiDownloadsDailySql,
16+
} from '../pypiDownloadsSql'
17+
18+
// Note: PEP 503 name normalization is done in SQL (BQ `REGEXP_REPLACE(LOWER(file.project), …)`
19+
// and the PG merge's `REGEXP_REPLACE(LOWER(p.name), …, 'g')`), asserted in the builder tests
20+
// below — there is no TS-side normalizer (we don't push our package list into BigQuery).
21+
22+
// ── Criterion 2: monthly 30-day windows (npm-identical math) ───────────────────────────────
23+
describe('computeLast30dWindows', () => {
24+
it('PYPI_EARLIEST is the 2018-07-26 Linehaul floor', () => {
25+
expect(PYPI_EARLIEST).toBe('2018-07-26')
26+
})
27+
28+
it('with no fromDate returns only the latest bucket ending at upperEndDate', () => {
29+
const w = computeLast30dWindows(null, '2026-06-01')
30+
expect(w).toEqual([{ start: '2026-05-02', end: '2026-06-01', isLatest: true }])
31+
})
32+
33+
it('enumerates a contiguous monthly series up to and including the latest', () => {
34+
const w = computeLast30dWindows('2026-04-10', '2026-06-01')
35+
expect(w.map((x) => x.end)).toEqual(['2026-04-01', '2026-05-01', '2026-06-01'])
36+
// end_date - 30 days for each bucket
37+
expect(w.map((x) => x.start)).toEqual(['2026-03-02', '2026-04-01', '2026-05-02'])
38+
// exactly one latest, and it is the final bucket
39+
expect(w.filter((x) => x.isLatest)).toEqual([
40+
{ start: '2026-05-02', end: '2026-06-01', isLatest: true },
41+
])
42+
})
43+
44+
it('clamps the lower bound and start_date to PYPI_EARLIEST', () => {
45+
const w = computeLast30dWindows('2015-01-01', '2018-09-01')
46+
// 2018-07-01 bucket is skipped (its end precedes PYPI_EARLIEST)
47+
expect(w.map((x) => x.end)).toEqual(['2018-08-01', '2018-09-01'])
48+
// first bucket's start (would be 2018-07-02) is clamped up to the floor
49+
expect(w[0]).toEqual({ start: '2018-07-26', end: '2018-08-01', isLatest: false })
50+
expect(w[1].isLatest).toBe(true)
51+
})
52+
53+
it('returns an empty array when fromDate is after upperEndDate', () => {
54+
expect(computeLast30dWindows('2026-07-01', '2026-06-01')).toEqual([])
55+
})
56+
})
57+
58+
// ── Criterion 3: daily trailing window + first-of-month helper ─────────────────────────────
59+
describe('defaultDailyRange', () => {
60+
it('defaults to a 2-day self-healing window [today-2, today-1]', () => {
61+
expect(defaultDailyRange('2026-06-30')).toEqual({
62+
startDate: '2026-06-28',
63+
endDate: '2026-06-29',
64+
})
65+
})
66+
67+
it('crosses month boundaries correctly', () => {
68+
expect(defaultDailyRange('2026-03-01')).toEqual({
69+
startDate: '2026-02-27',
70+
endDate: '2026-02-28',
71+
})
72+
})
73+
})
74+
75+
describe('utcFirstOfCurrentMonth', () => {
76+
it('returns the 1st of the month containing the reference date', () => {
77+
expect(utcFirstOfCurrentMonth('2026-06-30')).toBe('2026-06-01')
78+
expect(utcFirstOfCurrentMonth('2026-01-15')).toBe('2026-01-01')
79+
})
80+
})
81+
82+
// ── Criterion 4: 30d BigQuery aggregate query ─────────────────────────────────────────────
83+
describe('buildPypiDownloads30dSql', () => {
84+
const sql = buildPypiDownloads30dSql({ startDate: '2026-05-02', endDate: '2026-06-01' })
85+
86+
it('reads from the public file_downloads table', () => {
87+
expect(sql).toContain('bigquery-public-data.pypi.file_downloads')
88+
})
89+
90+
it('filters the date range on the timestamp partition', () => {
91+
expect(sql).toContain('DATE(timestamp)')
92+
expect(sql).toContain('BETWEEN')
93+
expect(sql).toContain('2026-05-02')
94+
expect(sql).toContain('2026-06-01')
95+
})
96+
97+
it('excludes bandersnatch mirror traffic while keeping NULL installers', () => {
98+
expect(sql).toContain("COALESCE(details.installer.name, '') <> 'bandersnatch'")
99+
})
100+
101+
it('groups by the PEP 503-normalized project and counts downloads', () => {
102+
expect(sql).toContain("REGEXP_REPLACE(LOWER(file.project), r'[-_.]+', '-')")
103+
expect(sql).toMatch(/COUNT\(\*\)\s+AS\s+downloads/i)
104+
expect(sql).toContain('GROUP BY')
105+
})
106+
})
107+
108+
// ── Criterion 5: daily BigQuery aggregate query ───────────────────────────────────────────
109+
describe('buildPypiDownloadsDailySql', () => {
110+
const sql = buildPypiDownloadsDailySql({ startDate: '2026-06-01', endDate: '2026-06-03' })
111+
112+
it('aggregates per project per day', () => {
113+
expect(sql).toContain('bigquery-public-data.pypi.file_downloads')
114+
expect(sql).toContain('DATE(timestamp) AS day')
115+
expect(sql).toContain("COALESCE(details.installer.name, '') <> 'bandersnatch'")
116+
expect(sql).toContain("REGEXP_REPLACE(LOWER(file.project), r'[-_.]+', '-')")
117+
expect(sql).toMatch(/COUNT\(\*\)\s+AS\s+downloads/i)
118+
expect(sql).toContain('GROUP BY project, day')
119+
})
120+
121+
it('never pushes our package list into BigQuery — scoping to is_critical is the merge job', () => {
122+
expect(sql).not.toContain('UNNEST')
123+
expect(sql).not.toContain('is_critical')
124+
})
125+
})
126+
127+
// ── Criterion 6: merge SQL builders ───────────────────────────────────────────────────────
128+
describe('buildPypiDownloads30dMergeSql', () => {
129+
it('emits only the insert when not mirroring', () => {
130+
const stmts = buildPypiDownloads30dMergeSql({
131+
startDate: '2026-05-02',
132+
endDate: '2026-06-01',
133+
mirrorToPackages: false,
134+
})
135+
expect(stmts).toHaveLength(1)
136+
const insert = stmts[0]
137+
expect(insert).toContain('INSERT INTO downloads_last_30d')
138+
expect(insert).toContain('staging.pypi_downloads_30d_raw')
139+
expect(insert).toContain("p.ecosystem = 'pypi'")
140+
expect(insert).toContain('2026-05-02')
141+
expect(insert).toContain('2026-06-01')
142+
expect(insert).toContain('ON CONFLICT (purl, end_date) DO UPDATE')
143+
// PG-side normalization must use the 'g' flag to replace every separator run
144+
expect(insert).toContain("REGEXP_REPLACE(LOWER(p.name), '[-_.]+', '-', 'g')")
145+
})
146+
147+
it('appends a packages mirror update when mirroring the latest window', () => {
148+
const stmts = buildPypiDownloads30dMergeSql({
149+
startDate: '2026-05-02',
150+
endDate: '2026-06-01',
151+
mirrorToPackages: true,
152+
})
153+
expect(stmts).toHaveLength(2)
154+
expect(stmts[1]).toContain('UPDATE packages')
155+
expect(stmts[1]).toContain('downloads_last_30d')
156+
expect(stmts[1]).toContain('IS DISTINCT FROM')
157+
})
158+
})
159+
160+
describe('buildPypiDownloadsDailyMergeSql', () => {
161+
it('inserts into downloads_daily scoped to critical pypi packages', () => {
162+
const sql = buildPypiDownloadsDailyMergeSql()
163+
expect(sql).toContain('INSERT INTO downloads_daily')
164+
expect(sql).toContain('package_id')
165+
expect(sql).toContain('staging.pypi_downloads_daily_raw')
166+
expect(sql).toContain("p.ecosystem = 'pypi'")
167+
expect(sql).toContain('p.is_critical')
168+
expect(sql).toContain('ON CONFLICT (package_id, date) DO UPDATE')
169+
expect(sql).toContain("REGEXP_REPLACE(LOWER(p.name), '[-_.]+', '-', 'g')")
170+
})
171+
})
172+
173+
// ── Criterion 7: job-kind registry ────────────────────────────────────────────────────────
174+
describe('PyPI downloads job kinds', () => {
175+
it('exposes the two new kind identifiers', () => {
176+
expect(PYPI_DOWNLOADS_30D_KIND).toBe('pypi_downloads_30d')
177+
expect(PYPI_DOWNLOADS_DAILY_KIND).toBe('pypi_downloads_daily')
178+
})
179+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Date math for the PyPI downloads workflows. Pure functions — no clock access; the
2+
// caller passes the reference "today" so runs are deterministic and testable.
3+
4+
// PyPI's BigQuery download data is unreliable before this date: Linehaul under-reported
5+
// downloads prior to 2018-07-26 (see the official PyPI download-analysis guide). Backfills
6+
// are clamped to this floor so we never ingest known-bad counts.
7+
export const PYPI_EARLIEST = '2018-07-26'
8+
9+
export interface Last30dWindow {
10+
start: string
11+
end: string
12+
isLatest: boolean
13+
}
14+
15+
function addDaysUTC(date: string, days: number): string {
16+
const d = new Date(date + 'T00:00:00Z')
17+
d.setUTCDate(d.getUTCDate() + days)
18+
return d.toISOString().slice(0, 10)
19+
}
20+
21+
// 1st of the calendar month (UTC) containing `today` (YYYY-MM-DD).
22+
export function utcFirstOfCurrentMonth(today: string): string {
23+
const d = new Date(today + 'T00:00:00Z')
24+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString().slice(0, 10)
25+
}
26+
27+
// Monthly 30-day windows, identical in math to npm's computeMissingLast30dWindows:
28+
// end_date = 1st of a calendar month (UTC), start_date = end_date - 30 days, isLatest only
29+
// for the bucket whose end === upperEndDate. Walks from max(fromDate, PYPI_EARLIEST) up to
30+
// upperEndDate. No fromDate → only the latest bucket.
31+
export function computeLast30dWindows(
32+
fromDate: string | null,
33+
upperEndDate: string,
34+
): Last30dWindow[] {
35+
const lower = fromDate ? (fromDate > PYPI_EARLIEST ? fromDate : PYPI_EARLIEST) : upperEndDate
36+
const lowerDate = new Date(lower + 'T00:00:00Z')
37+
const firstMonth = Date.UTC(lowerDate.getUTCFullYear(), lowerDate.getUTCMonth(), 1)
38+
const lastMonth = new Date(upperEndDate + 'T00:00:00Z').getTime()
39+
if (firstMonth > lastMonth) return []
40+
41+
const result: Last30dWindow[] = []
42+
let m = firstMonth
43+
while (m <= lastMonth) {
44+
const d = new Date(m)
45+
const endDate = d.toISOString().slice(0, 10) // 1st of the month
46+
// Skip windows whose end precedes the Linehaul floor.
47+
if (endDate >= PYPI_EARLIEST) {
48+
let start = addDaysUTC(endDate, -30)
49+
if (start < PYPI_EARLIEST) start = PYPI_EARLIEST
50+
result.push({ start, end: endDate, isLatest: m === lastMonth })
51+
}
52+
m = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
53+
}
54+
return result
55+
}
56+
57+
// The daily trailing window spans 2 days so the most-recent (possibly partial) partition is
58+
// re-scanned once on the next run and corrected, while keeping the daily BigQuery scan small.
59+
const DAILY_TRAILING_DAYS = 2
60+
61+
// Default daily trailing window: [today - 2, today - 1] inclusive (self-healing).
62+
export function defaultDailyRange(today: string): { startDate: string; endDate: string } {
63+
return { startDate: addDaysUTC(today, -DAILY_TRAILING_DAYS), endDate: addDaysUTC(today, -1) }
64+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
export const PYPI_DOWNLOADS_30D_KIND = 'pypi_downloads_30d'
2+
export const PYPI_DOWNLOADS_DAILY_KIND = 'pypi_downloads_daily'
3+
4+
// Staging tables the BQ export lands in before the merge into the final download tables.
5+
export const PYPI_DOWNLOADS_30D_STAGING = 'staging.pypi_downloads_30d_raw'
6+
export const PYPI_DOWNLOADS_DAILY_STAGING = 'staging.pypi_downloads_daily_raw'
7+
8+
const BQ_TABLE = '`bigquery-public-data.pypi.file_downloads`'
9+
// PEP 503 canonical project, BigQuery dialect (REGEXP_REPLACE is global by default).
10+
const BQ_PROJECT_NORM = "REGEXP_REPLACE(LOWER(file.project), r'[-_.]+', '-')"
11+
// Keep NULL installers; only drop known mirror traffic.
12+
const MIRROR_EXCLUDE = "COALESCE(details.installer.name, '') <> 'bandersnatch'"
13+
// PEP 503 canonical name, Postgres dialect — the 'g' flag is required so EVERY separator
14+
// run collapses (Postgres REGEXP_REPLACE otherwise replaces only the first match).
15+
const PG_NAME_NORM = "REGEXP_REPLACE(LOWER(p.name), '[-_.]+', '-', 'g')"
16+
17+
// Aggregate query: net downloads per package over [startDate, endDate], one total each.
18+
export function buildPypiDownloads30dSql({
19+
startDate,
20+
endDate,
21+
}: {
22+
startDate: string
23+
endDate: string
24+
}): string {
25+
return `
26+
SELECT
27+
${BQ_PROJECT_NORM} AS project,
28+
COUNT(*) AS downloads
29+
FROM ${BQ_TABLE}
30+
WHERE DATE(timestamp) BETWEEN DATE('${startDate}') AND DATE('${endDate}')
31+
AND ${MIRROR_EXCLUDE}
32+
GROUP BY project
33+
`
34+
}
35+
36+
// Aggregate query: per-day downloads per package over [startDate, endDate]. Like every other BQ
37+
// job here (see deps.dev), it exports all projects and lets the Postgres merge scope to our
38+
// packages (is_critical) — we never push our package list into BigQuery.
39+
export function buildPypiDownloadsDailySql({
40+
startDate,
41+
endDate,
42+
}: {
43+
startDate: string
44+
endDate: string
45+
}): string {
46+
return `
47+
SELECT
48+
${BQ_PROJECT_NORM} AS project,
49+
DATE(timestamp) AS day,
50+
COUNT(*) AS downloads
51+
FROM ${BQ_TABLE}
52+
WHERE DATE(timestamp) BETWEEN DATE('${startDate}') AND DATE('${endDate}')
53+
AND ${MIRROR_EXCLUDE}
54+
GROUP BY project, day
55+
`
56+
}
57+
58+
// Merge statements for the 30d window: insert into downloads_last_30d, and (only for the
59+
// latest window) mirror the count onto packages.downloads_last_30d.
60+
export function buildPypiDownloads30dMergeSql({
61+
startDate,
62+
endDate,
63+
mirrorToPackages,
64+
}: {
65+
startDate: string
66+
endDate: string
67+
mirrorToPackages: boolean
68+
}): string[] {
69+
const insert = `
70+
INSERT INTO downloads_last_30d (purl, start_date, end_date, count, created_at, updated_at)
71+
SELECT p.purl, DATE '${startDate}', DATE '${endDate}', s.downloads, NOW(), NOW()
72+
FROM ${PYPI_DOWNLOADS_30D_STAGING} s
73+
JOIN packages p ON p.ecosystem = 'pypi'
74+
AND ${PG_NAME_NORM} = s.project
75+
ON CONFLICT (purl, end_date) DO UPDATE SET
76+
count = EXCLUDED.count,
77+
start_date = EXCLUDED.start_date,
78+
updated_at = NOW()
79+
`
80+
if (!mirrorToPackages) return [insert]
81+
82+
const mirror = `
83+
UPDATE packages p
84+
SET downloads_last_30d = s.downloads
85+
FROM ${PYPI_DOWNLOADS_30D_STAGING} s
86+
WHERE p.ecosystem = 'pypi'
87+
AND ${PG_NAME_NORM} = s.project
88+
AND p.downloads_last_30d IS DISTINCT FROM s.downloads
89+
`
90+
return [insert, mirror]
91+
}
92+
93+
// Merge statement for daily downloads into downloads_daily, scoped to critical pypi packages.
94+
export function buildPypiDownloadsDailyMergeSql(): string {
95+
return `
96+
INSERT INTO downloads_daily (package_id, date, count, created_at, updated_at)
97+
SELECT p.id, s.day, s.downloads, NOW(), NOW()
98+
FROM ${PYPI_DOWNLOADS_DAILY_STAGING} s
99+
JOIN packages p ON p.ecosystem = 'pypi' AND p.is_critical
100+
AND ${PG_NAME_NORM} = s.project
101+
ON CONFLICT (package_id, date) DO UPDATE SET
102+
count = EXCLUDED.count,
103+
updated_at = NOW()
104+
`
105+
}

0 commit comments

Comments
 (0)