Skip to content

Commit f96972a

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/advisory-packages-bq-ceiling-CM-1362
2 parents aca67db + c279f16 commit f96972a

20 files changed

Lines changed: 1767 additions & 58 deletions

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,16 @@ Millions of rows. Every query matters.
6969
- Define types properly — extend and reuse existing types. Don't sprinkle `any`.
7070
- Don't touch working code outside the scope of the current task.
7171
- Prefer doing less over introducing risk. Weigh trade-offs before acting.
72-
- Default to no comments. When one is warranted (non-obvious WHY), keep it to 2 lines max.
72+
- No comments. Code must be self-explanatory: name things well and extract
73+
functions instead of explaining them.
74+
- A comment is allowed ONLY if one of these is true, and the reason cannot be
75+
expressed in code:
76+
- workaround for an external bug/API quirk (link it)
77+
- non-obvious invariant or ordering the caller must respect
78+
- performance/concurrency constraint that looks removable but isn't
79+
- genuinely convoluted logic (e.g. inherited/legacy complexity) that can't
80+
be simplified or extracted right now — prefer refactoring over commenting
81+
- `TODO(CM-XXX):` with a ticket
82+
- Never comment: what the code does, section headers, JSDoc on obvious
83+
functions, notes about the change you just made.
84+
- When allowed: 2 lines max.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Run ledger for the packagist transitive-dependents lane: one row per run;
2+
-- prepare retries reuse 'pending', and 'merging' rows end 'done' or 'failed'.
3+
CREATE TABLE packagist_transitive_runs (
4+
id serial PRIMARY KEY,
5+
status text NOT NULL DEFAULT 'pending'
6+
CHECK (status IN ('pending', 'merging', 'done', 'failed')),
7+
edge_count bigint, -- distinct package-level direct edges snapshotted
8+
packages_with_dependents bigint, -- rows in the closure output
9+
processed_rows bigint, -- packagist packages visited by the merge drain
10+
changed_rows bigint, -- rows whose transitive_dependent_count actually changed
11+
error_message text,
12+
started_at timestamptz NOT NULL DEFAULT now(),
13+
finished_at timestamptz
14+
);

docs/adr/0009-packagist-worker-design-decisions.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ like the one deps.dev exposes in BigQuery.
2727
| Dependency model | decided |
2828
| Metadata enrichment scope | decided |
2929
| Lane architecture & cadence | decided |
30+
| Transitive dependent counts | decided |
3031

3132
---
3233

@@ -187,6 +188,78 @@ that week's enrichment (recoverable via the manual trigger).
187188

188189
---
189190

191+
### Transitive dependent counts: weekly materialized reverse closure over our own edges
192+
193+
A fifth lane, `computePackagistTransitiveDependents`, populates
194+
`packages.transitive_dependent_count` for packagist weekly: snapshot the direct edges to
195+
package-level pairs (`staging.packagist_transitive_edges`), compute the **exact** reverse
196+
transitive closure in one recursive statement (`staging.packagist_transitive_counts`), then
197+
keyset-merge into `packages` with zero-fill and `IS DISTINCT FROM` churn protection. It is
198+
chained off the metadata drain's natural completion (event, not clock — the same idiom as
199+
seed → metadata) and tracked in its own run ledger, `packagist_transitive_runs`
200+
(`pending → merging → done | failed`, one row per run with graph sizes and merge totals):
201+
per-purl `packagist_package_state` exists for lanes whose HTTP fetches fail per package —
202+
a whole-ecosystem batch has no per-purl outcomes — and `osspckgs_ingest_jobs` is the
203+
BQ-ingest ledger (gcs/bq columns, ingest-shaped kinds), so overloading it was rejected. The
204+
run aborts loudly on an empty edge snapshot, the merge side refuses to run against an empty
205+
counts table (a crash-truncated UNLOGGED staging table can never be zero-filled over good
206+
data), and a permanently failed merge marks the run `failed` instead of stranding it in
207+
`merging`. A weekly **ledger-gated backstop cron** (Monday, after the Sunday chain) covers
208+
broken chains: it no-ops when the ledger shows a `done` run within 6 days or while the metadata drain is
209+
still running (whose completion chains the closure), else chain-starts the fixed workflow
210+
id — clock as safety net, event chain as primary.
211+
212+
**Provenance.** deps.dev has no Packagist coverage, and Packagist's own API reports only
213+
direct dependents — so this is the only ecosystem where the transitive signal must come from
214+
our own stored graph. `rank_packages()` already carries `transitive_dependents` as a coverage
215+
signal but silently drops it while the ecosystem total is zero; measurement showed the closure
216+
surfaces hidden infrastructure with tiny direct counts (symfony/polyfill-php80 ≈ 196K
217+
transitive dependents vs 471 registry-reported direct) and closes most of the critical-set gap
218+
vs ecosyste.ms. `is_critical` is a BOOL_OR across signals, so enabling this can only add
219+
critical packages — the same measurement-first precedent as the sonatype signal.
220+
221+
**This does not reopen the "no resolved graph at ingest" decision** (see *Dependency model*
222+
above). No versions are resolved and no per-edge resolved targets are stored; the closure is a
223+
package-level derived aggregate — the same count columns deps.dev hands us pre-computed for
224+
npm/maven/pypi/cargo, and the same exact-closure computation the deps-dev worker already runs
225+
in BigQuery for GO/NUGET/RUBYGEMS. Packagist's edges live in Postgres instead of BigQuery, so
226+
the closure runs there.
227+
228+
**Semantics** match the deps.dev convention (`MinimumDepth > 1`): dependents reachable only at
229+
depth ≥ 2, direct dependents excluded, computed from the same edge set (never mixed with the
230+
registry-reported `dependent_count`, which stays its own signal); dev-deps excluded (Composer
231+
does not install them transitively); cycles terminate and a package is never its own
232+
dependent; `0` means "computed, none" vs NULL "never computed". Count updates are **not
233+
audited** — bulk derived analytics, matching the deps.dev dependent-counts merges rather than
234+
this worker's registry-fact auditing.
235+
236+
**Costs, measured.** The snapshot is the only step touching the full `package_dependencies`
237+
table (~1.5B rows, no index on `package_id`): a deliberate weekly parallel seq scan,
238+
~10–20 min projected in prod — the same accepted access pattern as the criticality PageRank
239+
edge loader. The closure itself runs on the ~919K-row package-level snapshot: ~29.3M reachable
240+
pairs in ~51 s (validated end-to-end locally on the full real dataset: 88 s wall clock,
241+
454,455 rows merged, +95 packages newly critical from the signal alone).
242+
243+
**Consequences.**
244+
245+
_Positive:_ the criticality gap for packagist closes without any ranking change; the lane is
246+
idempotent and resumable (continueAsNew keyset drain, pending-run reuse); Tinybird sees only
247+
real changes.
248+
249+
_Negative / trade-offs:_ a weekly full scan of the shared 1.5B-row table; counts refresh at
250+
most weekly (with the edges), so they lag registry reality by up to a week.
251+
252+
_Risks / escape hatch:_ if the weekly scan becomes a problem, the metadata lane already holds
253+
each package's edges in memory at write time and could co-maintain the package-level edge
254+
table incrementally, eliminating the scan — the snapshot step is isolated in one activity so
255+
the source can be swapped without touching the closure or merge. Undercount caveats: edges
256+
exist only for dependency targets that resolved to known package rows, and edge-less packages
257+
count as leaves — both bias counts down, so criticality conclusions stay conservative.
258+
259+
**Decided**: 2026-07-27
260+
261+
---
262+
190263
## Changelog
191264

192265
- **2026-07-13** — ADR created. First entry: _Dependency model: direct edges + declared constraints,
@@ -198,3 +271,9 @@ that week's enrichment (recoverable via the manual trigger).
198271
- **2026-07-17** — Corrected stale "critical slice only" wording left over in the _Dependency
199272
model_ entry from before the _Metadata enrichment scope_ decision widened it to all packages;
200273
set `Status` to `accepted` (matching ADR-0005's precedent for a living/consolidated doc).
274+
- **2026-07-27** — Added _Transitive dependent counts: weekly materialized reverse closure over
275+
our own edges_ (the fifth lane; resolves the `transitive_dependent_count` gap flagged in the
276+
original risks).
277+
- **2026-08-04** — Revised _Transitive dependent counts_: run state moved to the dedicated
278+
`packagist_transitive_runs` ledger, and a ledger-gated weekly backstop cron added for weeks
279+
where the seed→metadata chain breaks.

services/apps/packages_worker/src/activities.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export {
4747
getCriticalPackagistCount,
4848
packagistCurrentTimestamp,
4949
packagistStopAfterFirstPage,
50+
preparePackagistTransitiveCounts,
51+
mergePackagistTransitiveBatch,
52+
finishPackagistTransitiveRun,
53+
failPackagistTransitiveRun,
54+
packagistTransitiveRanRecently,
55+
packagistMetadataDrainRunning,
5056
} from './packagist/activities'
5157
export { processRubyGemsCoreBatch, processRubyGemsCriticalBatch } from './rubygems/activities'
5258
export {

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Context } from '@temporalio/activity'
22

3-
import { createIngestJob, markJobStatus } from '@crowd/data-access-layer'
3+
import { createIngestJob, findPendingJobByKind, markJobStatus } from '@crowd/data-access-layer'
44
import { getServiceChildLogger } from '@crowd/logging'
55

66
import { getPackagesDb } from '../db'
@@ -70,13 +70,9 @@ export async function rankPackages(): Promise<{ scoredRows: number; rankedRows:
7070

7171
// On retry, a pending row from the prior attempt may already exist — reuse it.
7272
// Do NOT reuse a done row: it belongs to a previous bootstrap run and ranking must re-execute.
73-
const existing = await qx.selectOneOrNone(
74-
`SELECT id FROM osspckgs_ingest_jobs
75-
WHERE job_kind = 'ranking' AND status = 'pending'
76-
ORDER BY id DESC LIMIT 1`,
77-
)
78-
79-
const jobId = existing?.id ?? (await createIngestJob(qx, 'ranking', 'ranking', null))
73+
const jobId =
74+
(await findPendingJobByKind(qx, 'ranking')) ??
75+
(await createIngestJob(qx, 'ranking', 'ranking', null))
8076
try {
8177
await markJobStatus(qx, jobId, 'merging')
8278
const [result] = await qx.select(`SELECT * FROM rank_packages()`)

services/apps/packages_worker/src/packagist/README.md

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
The Packagist worker keeps the PHP/Composer slice of the packages database fresh
44
by crawling **packagist.org directly** — deps.dev has no Packagist coverage, so
55
unlike npm/maven/pypi there is no BigQuery universe to import from; the registry
6-
crawl _is_ the universe source. It runs **four Temporal workflows** on the
7-
`packagist-worker` task queue: three cron schedules registered at worker boot
8-
(`src/bin/packagist-worker.ts`), plus the metadata drain, which the seed chains
9-
as a child workflow on completion.
6+
crawl _is_ the universe source. It runs **six Temporal workflows** on the
7+
`packagist-worker` task queue: four cron schedules registered at worker boot
8+
(`src/bin/packagist-worker.ts` — including the transitive backstop), plus two
9+
event-chained drains — the metadata drain (chained off the seed) and the
10+
transitive-dependents closure (chained off the metadata drain).
1011

1112
Identity: ecosystem `packagist`, purls `pkg:composer/{vendor}/{name}`
1213
(namespace = vendor). Audit tag: `packagist` in `audit_field_changes`.
@@ -115,6 +116,11 @@ touching p2.
115116
`If-Modified-Since` next run).
116117
- `audit_field_changes` — every changed field above, worker tag `packagist`.
117118

119+
On natural completion (not in `STOP_AFTER_FIRST_PAGE` debug runs) the drain
120+
**chain-starts the transitive-dependents closure** (§5) — freshly refreshed
121+
edges are what the closure consumes, so it follows the drain as an event, not
122+
a clock offset.
123+
118124
---
119125

120126
## 3. `ingestPackagistDownloads30d` — monthly rolling-window capture
@@ -166,11 +172,70 @@ last. State: `daily_downloads_last_run_at` + `daily_downloads_run_result`.
166172

167173
---
168174

175+
## 5. `computePackagistTransitiveDependents` — weekly reverse-closure counts
176+
177+
**Schedule:** primary trigger is the **chain off the metadata drain's natural
178+
completion** (effectively weekly, after the Sunday drain finishes), the same
179+
event-not-clock idiom as seed → metadata. A **ledger-gated backstop cron**
180+
(`packagist-transitive-backstop`, Monday 04:41 UTC) covers broken weeks: it
181+
no-ops when a run completed within 6 days or while the metadata drain is still
182+
crawling (whose completion chains the closure itself), otherwise chain-starts
183+
the same fixed workflow id — so it can never race a live drain and a healthy
184+
week never pays a second scan. Recover manually with
185+
`pnpm trigger-packagist:local transitive`.
186+
**Targets:** every packagist package (the merge zero-fills leaves).
187+
188+
**What it does:** three steps —
189+
190+
1. **Snapshot** — collapses the packagist slice of `package_dependencies` into
191+
`staging.packagist_transitive_edges`: distinct package-level pairs, `require`
192+
edges only (`require-dev` excluded — Composer does not install dev deps
193+
transitively), self-edges dropped. This is the only step touching the full
194+
~1.5B-row table; `package_id` has no index there, so it is a deliberate
195+
weekly parallel seq scan (same access pattern the criticality PageRank loader
196+
already uses).
197+
2. **Closure** — the exact reverse transitive closure in one recursive
198+
statement into `staging.packagist_transitive_counts` (~29M reachable pairs,
199+
~51 s measured on the full dataset). Cycles terminate; a package is never
200+
counted as its own dependent.
201+
3. **Merge** — keyset batches of 10K into
202+
`packages.transitive_dependent_count`, zero-filling packages with no
203+
dependents (`0` = "computed, none" vs NULL = "never computed").
204+
`IS DISTINCT FROM` keeps re-runs churn-free — `last_synced_at` (the
205+
Sequin/Tinybird signal) moves only on real changes.
206+
207+
**Populates:** `packages.transitive_dependent_count` for packagist rows only —
208+
dependents reachable **only at depth ≥ 2**; direct dependents are excluded,
209+
matching the deps.dev `MinimumDepth > 1` convention every other ecosystem uses.
210+
The registry-reported `dependent_count` is untouched. **No audit rows** — bulk
211+
derived analytics, same policy as the deps.dev dependent-counts merges. Run
212+
state: one `packagist_transitive_runs` row per run
213+
(`pending → merging → done | failed`, with graph sizes and merge totals) —
214+
per-purl `packagist_package_state` doesn't fit a whole-ecosystem batch, and
215+
`osspckgs_ingest_jobs` is the BQ-ingest ledger. An empty edge snapshot
216+
hard-aborts the run (`failed`) instead of writing zeros over good data; the
217+
merge itself refuses an empty counts table — the staging tables are UNLOGGED
218+
(truncated by crash recovery), and a mid-drain truncation must never be
219+
zero-filled over real counts — and a merge phase that fails permanently marks
220+
the run `failed` rather than leaving it in `merging`.
221+
222+
**Why:** deps.dev has zero Packagist coverage, so no external source can supply
223+
this signal. `rank_packages()` already has `transitive_dependents` wired in but
224+
drops it while the ecosystem total is zero — the first pass after this lane
225+
runs picks it up automatically, and since `is_critical` is a BOOL_OR across
226+
signals it can only add critical packages, never remove any.
227+
228+
**Known undercounts (conservative by design):** edges exist only where the
229+
dependency target resolved to a known packages row (unresolved targets are
230+
skipped at ingest), and packages with no stored edges count as leaves.
231+
232+
---
233+
169234
## What this worker deliberately does NOT write
170235

171-
- `transitive_dependent_count`, `dependent_repos_count` — not computable from
172-
the registry; needs a reverse-closure over our stored direct edges
173-
(future work, see ADR-0009 risks).
236+
- `dependent_repos_count` — not computable from the registry; needs a
237+
package→repo mapping across the dependent set (deps.dev provides this for
238+
its ecosystems; Packagist has no equivalent).
174239
- Advisories — the OSV worker owns security data platform-wide.
175240
- `is_critical` / `criticality_score` / ranking columns — the shared
176241
criticality worker; this worker only _reads_ `is_critical` for scoping.
@@ -199,14 +264,17 @@ DEV=1 ./scripts/cli service packagist-worker up
199264
# trigger on demand instead of waiting for the crons
200265
cd services/apps/packages_worker
201266
pnpm trigger-packagist:local seed # discovery (chain-starts metadata!)
202-
pnpm trigger-packagist:local metadata # enrichment: info + versions + deps
267+
pnpm trigger-packagist:local metadata # enrichment: info + versions + deps (chain-starts transitive!)
203268
pnpm trigger-packagist:local downloads-30d # monthly rolling-window capture
204269
pnpm trigger-packagist:local downloads-daily # daily capture, critical slice
270+
pnpm trigger-packagist:local transitive # reverse-closure transitive dependent counts
205271
```
206272

207273
Note: triggering `seed` also chain-starts the full `metadata` drain (set
208274
`CROWD_PACKAGES_PACKAGIST_STOP_AFTER_FIRST_PAGE=true` locally to bound it).
209-
Local smoke order: seed → metadata → (rank) → downloads lanes for
210-
critical-scoped writes. State lives in `packagist_package_state`
211-
(migration `V1784314023__packagist_worker.sql`); design decisions in
275+
Local smoke order: seed → metadata → transitive → (rank) → downloads lanes for
276+
critical-scoped writes. Per-purl state lives in `packagist_package_state`
277+
(migration `V1784314023__packagist_worker.sql`); the transitive lane tracks its
278+
runs in `packagist_transitive_runs` (migration
279+
`V1785740540__packagist_transitive_runs.sql`). Design decisions in
212280
`docs/adr/0009-packagist-worker-design-decisions.md`.

0 commit comments

Comments
 (0)