Skip to content

Commit 1cf1e69

Browse files
authored
Merge branch 'main' into feat/security_contacts_worker
2 parents 5f32ed1 + 5fc149a commit 1cf1e69

50 files changed

Lines changed: 2119 additions & 643 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"script:continue-run": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/continue-run.ts",
2020
"script:trigger-webhook": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/trigger-webhook.ts",
2121
"script:send-weekly-analytics-email": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/send-weekly-analytics-email.ts",
22+
"script:merge-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/merge-members.ts",
2223
"script:merge-organizations": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/merge-organizations.ts",
2324
"script:refresh-materialized-views": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/refresh-materialized-views.ts",
2425
"script:unmerge-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/unmerge-members.ts",

backend/src/api/public/v1/stewardships/getMyActivity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export async function getMyActivityHandler(req: Request, res: Response): Promise
6565
description: r.content,
6666
actor: r.actor,
6767
createdAt: r.createdAt,
68-
suggestedAction: SUGGESTED_ACTIONS[r.stewardshipStatus] ?? null,
68+
suggestedAction: SUGGESTED_ACTIONS[r.currentStewardshipStatus] ?? null,
6969
})),
7070
meta: {
7171
total,

backend/src/bin/scripts/merge-members.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import commandLineUsage from 'command-line-usage'
33
import * as fs from 'fs'
44
import path from 'path'
55

6+
import { generateUUIDv1 } from '@crowd/common'
67
import { CommonMemberService } from '@crowd/common_services'
78
import { optionsQx } from '@crowd/data-access-layer'
89
import { MemberField, findMemberById } from '@crowd/data-access-layer/src/members'
@@ -33,6 +34,13 @@ const options = [
3334
description:
3435
'The unique ID of a member that will be merged into the first one. This one will be destroyed. You can provide multiple ids here separated by comma.',
3536
},
37+
{
38+
name: 'userId',
39+
alias: 'u',
40+
typeLabel: '{underline userId}',
41+
type: String,
42+
description: 'User ID of the user performing the merge.',
43+
},
3644
{
3745
name: 'help',
3846
alias: 'h',
@@ -65,6 +73,8 @@ if (parameters.help || !parameters.originalId || !parameters.targetId) {
6573
const originalId = parameters.originalId
6674
const targetIds = parameters.targetId.split(',')
6775

76+
const userId = parameters.userId
77+
6878
const options = await SequelizeRepository.getDefaultIRepositoryOptions()
6979
const qx = SequelizeRepository.getQueryExecutor(options)
7080

@@ -74,6 +84,16 @@ if (parameters.help || !parameters.originalId || !parameters.targetId) {
7484
])
7585

7686
options.currentTenant = { id: originalMember.tenantId }
87+
options.currentUser = { id: userId }
88+
89+
const ctx = {
90+
...options,
91+
requestId: generateUUIDv1(),
92+
userData: {
93+
ip: '127.0.0.1',
94+
userAgent: 'merge-members-script',
95+
},
96+
}
7797

7898
for (const targetId of targetIds) {
7999
const targetMember = await findMemberById(qx, targetId, [
@@ -89,7 +109,7 @@ if (parameters.help || !parameters.originalId || !parameters.targetId) {
89109
log.info(`Merging ${targetId} into ${originalId}...`)
90110
const service = new CommonMemberService(optionsQx(options), options.temporal, log)
91111
try {
92-
await service.merge(originalId, targetId)
112+
await service.merge(originalId, targetId, ctx)
93113
} catch (err) {
94114
log.error(`Error merging members: ${err.message}`)
95115
process.exit(1)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
create table if not exists git."repoAffiliationRegistry" (
2+
"repoId" uuid primary key references public.repositories(id) on delete cascade,
3+
"filePath" text,
4+
"fileHash" text,
5+
"status" text not null,
6+
"snapshot" jsonb,
7+
"lastRunAt" timestamptz,
8+
"createdAt" timestamptz not null default now(),
9+
"updatedAt" timestamptz not null default now()
10+
);

backend/src/database/repositories/memberRepository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
includeMemberToSegments,
5151
} from '@crowd/data-access-layer/src/members/segments'
5252
import { IDbMemberData } from '@crowd/data-access-layer/src/members/types'
53-
import { optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
53+
import { optionsBgQx, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
5454
import {
5555
fetchManySegments,
5656
getSegmentMergeSuggestionCounts,
@@ -1259,7 +1259,7 @@ class MemberRepository {
12591259
let memberResponse = null
12601260

12611261
const qx = optionsQx(options)
1262-
const bgQx = optionsQx({ ...options, transaction: null })
1262+
const bgQx = optionsBgQx(options)
12631263

12641264
memberResponse = await queryMembersAdvanced(qx, bgQx, options.redis, {
12651265
filter: { id: { eq: id } },
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Store the stewardship status as it was at the moment each activity was logged.
2+
-- Existing rows get NULL (no history to reconstruct); queries fall back to s.status for them.
3+
ALTER TABLE stewardship_activity
4+
ADD COLUMN IF NOT EXISTS status_at_time TEXT;

backend/src/services/memberService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
insertMemberSegmentAggregates,
2222
queryMembersAdvanced,
2323
} from '@crowd/data-access-layer/src/members'
24-
import { QueryExecutor, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
24+
import { QueryExecutor, optionsBgQx, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
2525
import {
2626
decrementMemberMergeSuggestionCounts,
2727
fetchManySegments,
@@ -944,7 +944,7 @@ export default class MemberService extends LoggerBase {
944944

945945
async findAllAutocomplete(data) {
946946
const qx = optionsQx(this.options)
947-
const bgQx = optionsQx({ ...this.options, transaction: null })
947+
const bgQx = optionsBgQx(this.options)
948948

949949
return queryMembersAdvanced(qx, bgQx, this.options.redis, {
950950
filter: data.filter,
@@ -970,7 +970,7 @@ export default class MemberService extends LoggerBase {
970970
}
971971

972972
const qx = optionsQx(this.options)
973-
const bgQx = optionsQx({ ...this.options, transaction: null })
973+
const bgQx = optionsBgQx(this.options)
974974

975975
return queryMembersAdvanced(qx, bgQx, this.options.redis, {
976976
...data,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# ADR-0004: Compute GO/NUGET transitive dependent counts via exact reverse closure (over HLL approximation)
2+
3+
**Date**: 2026-06-23
4+
**Status**: accepted
5+
**Deciders**: Uroš Marolt
6+
7+
## Context
8+
deps.dev publishes its reverse-dependent index (`Dependents`) and its resolved/transitive
9+
dependency graph (`Dependencies`, `DependencyGraphEdges`) for only four ecosystems —
10+
NPM, MAVEN, PYPI, CARGO (verified via BQ 2026-06-23: each `GROUP BY System` returns exactly
11+
those four). GO and NUGET have only raw direct manifests (`GoRequirementsLatest`,
12+
`NuGetRequirementsLatest`). The `dependent_counts` ingest was therefore split three ways
13+
(edges / go / nuget — see the dependent-counts split). Direct `dependent_count` for GO/NUGET
14+
is a cheap manifest invert, but `transitive_dependent_count` (MinimumDepth>1) and the all-depth
15+
`dependent_repos_count` require the reverse **transitive closure**, which deps.dev does not
16+
provide for these two ecosystems — so we must compute it ourselves from the direct edges.
17+
18+
## Decision
19+
Compute the **exact** reverse transitive closure for GO/NUGET — a semi-naive fixpoint over
20+
module-level edges (names hashed to INT64 via `FARM_FINGERPRINT`, persisted + clustered) iterated
21+
to convergence — so GO/NUGET transitive counts are true integers matching the edge-system
22+
methodology. We chose exact over an HLL-sketch approximation, accepting the higher cost for now.
23+
24+
## Alternatives Considered
25+
26+
### Alternative 1: HLL sketch propagation (approximate)
27+
- **Pros**: ~cents/run, seconds, tiny tables (one HyperLogLog sketch per subject, ~188K rows for
28+
GO, merged along edges to a fixpoint — never materializes the billions of pairs). Full-depth.
29+
- **Cons**: probabilistic cardinality estimate with bounded relative error ≈ `1.04/sqrt(2^p)`
30+
(~1.6% at precision 12, ~0.4% at 15). Methodology differs from the exact edge-system counts, so
31+
GO/NUGET would be ~1% approximate while the other four ecosystems are exact.
32+
- **Why not**: the decision is to have identical, exact integer methodology across all six
33+
ecosystems. The measured exact cost (below) is acceptable for now. HLL remains the documented
34+
fallback if weekly cost becomes prohibitive.
35+
36+
### Alternative 2: Bounded-depth closure (cap at K hops)
37+
- **Pros**: K deterministic joins in a single statement, dry-runnable, cost-bounded, no scripting.
38+
- **Cons**: undercounts. The GO closure needs **31 hops** to converge; new pairs peak at hop 8
39+
(~507M) and only decay to zero by hop 31. Capping at a small K drops hundreds of millions of
40+
genuine transitive pairs.
41+
- **Why not**: a low cap is materially wrong; a cap high enough to be correct is no cheaper than
42+
full convergence.
43+
44+
## Consequences
45+
46+
### Positive
47+
- Exact integer counts, full depth, identical methodology to NPM/MAVEN/PYPI/CARGO.
48+
- Deterministic and reproducible; no estimator error to explain to downstream consumers.
49+
- Termination is guaranteed by construction: each iteration's frontier is anti-joined against the
50+
accumulated `reach`, so only brand-new pairs survive and the finite pair space converges (proven:
51+
GO converged at hop 31 with `new_pairs → 0`).
52+
53+
### Negative
54+
- This is by far the heaviest job in the packages pipeline. Measured exact runs (2026-06-23,
55+
INT64-fingerprint-optimized; raw string names would be ~5×). The build-`reach` figures are from the
56+
closure probe; the full-pipeline figures (incl. the all-depth repos aggregation + per-subject
57+
counts → `_export_data`) are from the end-to-end validation run of the production script:
58+
- **GO**: 5.81B closure pairs, 31 iterations. Build reach ≈ 1.86 TB; **full pipeline = 2.31 TB
59+
billed (~$14.5 at $6.25/TiB), 99 slot-hours, 16 min wall, 132 child statements** (validated).
60+
- **NUGET**: 114M closure pairs, 19 iterations, ~4 min wall, ~32 GB billed (~$0.19); full pipeline
61+
validated → 191,762 subject rows.
62+
- GO dominates (its graph is ~50× larger and deeper). Combined ≈ **~$15/run, ~20 min, weekly**.
63+
For comparison the edge `dependent_counts` job scans ~310 GB (~$2).
64+
- Requires a semi-naive BQ scripting job (not a single exportable SELECT), so it does not fit the
65+
existing one-query→GCS pipeline shape as-is (integrated via an `isScript` mode — see below).
66+
67+
### Risks
68+
- Cost/runtime grow with the GO/NUGET graphs. Mitigations: INT64 fingerprints (~5× cheaper),
69+
clustering on the join keys, and a documented escape hatch to switch GO/NUGET to HLL (Alternative
70+
1) if cost becomes prohibitive — counts in the hundred-thousands make ~1% error invisible for
71+
popularity/ranking.
72+
- `FARM_FINGERPRINT` collisions are negligible at ~1–2M nodes (~1e-6) but non-zero; acceptable for
73+
these metrics. Switch to a dictionary-encoded INT id if exactness must be collision-proof.
74+
75+
## Pipeline integration (decided 2026-06-23)
76+
- **No persistent scratch dataset.** The closure runs as one multi-statement scripting job using
77+
session-scoped `CREATE TEMP TABLE`s (edges → INT64-fingerprinted edges → `reach` fixpoint →
78+
fingerprint→name lookup → `_export_data`). Temp tables are clustered on the join keys and are
79+
auto-dropped when the script's session ends — on success *and* failure — so there is no lingering
80+
storage to bill or clean up. (The interactive probe used regular tables in
81+
`lfx-insights:scratch_closure_probe`; that is leftover, dropped separately — not the pipeline.)
82+
- **Reuses the existing job path, not a new kind.** `bqExportToGcs` gains an `isScript` mode: when
83+
set, `sql` is the full script (ending in `CREATE TEMP TABLE _export_data AS …`) and the activity
84+
appends only the `EXPORT DATA … AS SELECT * FROM _export_data` statement rather than wrapping
85+
`sql` in a subquery. Everything downstream (listParquetFiles → guard → chunked
86+
gcsParquetToStaging → mergeStagingToTable) is unchanged, and the `dependent_counts_go` /
87+
`dependent_counts_nuget` kinds, guard baselines, and monitor entries stay continuous.
88+
- **Cost guard.** A dry-run cannot price a `WHILE` loop, so script mode skips it and enforces the
89+
byte ceiling server-side via `maximumBytesBilled` (= the per-variant `maxBytesGb`). The script
90+
also carries an iteration cap as the deterministic runaway guard (convergence already proven:
91+
GO hop 31, NUGET hop 19, both `new_pairs → 0`).
92+
- **Final output → counts.** `_export_data` emits `(purl, dependent_count,
93+
transitive_dependent_count, dependent_repos_count)`: fingerprints in `reach` are mapped back to
94+
names via a `names` temp table, joined to `purl_map` (name→purl). `transitive_dependent_count =
95+
COUNT(DISTINCT dep over reach) − direct_count`; `dependent_repos_count` = distinct
96+
`PackageVersionToProject` SOURCE_REPO_TYPE repos over the all-depth dependent set.
97+
- **Weekly trigger.** No new schedule — the `go`/`nuget` variants are already child workflows of
98+
`bootstrapOsspckgs`, which the existing `osspckgs-bootstrap-weekly` schedule runs incrementally.
99+
100+
Related: ADR-0003 (deps BQ table selection — same root cause: GO/NUGET absent from the resolved-graph tables).

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
1010
| --------------------------------------------------- | ---------------------------------------- | ------ | ---------- |
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 |
13+
| [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 |
1314

1415
## Why ADRs?
1516

0 commit comments

Comments
 (0)