Skip to content

Commit 28630f1

Browse files
authored
feat: add DAL (CM-951) (#3836)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 7e6dc18 commit 28630f1

20 files changed

Lines changed: 1472 additions & 16 deletions

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Automatic Projects Discovery Worker
2+
3+
Temporal worker that discovers open-source projects from external data sources and writes them to the `projectCatalog` table.
4+
5+
## Architecture
6+
7+
### Source abstraction
8+
9+
Every data source implements the `IDiscoverySource` interface (`src/sources/types.ts`):
10+
11+
| Method | Purpose |
12+
| ----------------------------- | --------------------------------------------------------------------------- |
13+
| `listAvailableDatasets()` | Returns available dataset snapshots, sorted newest-first |
14+
| `fetchDatasetStream(dataset)` | Returns a readable stream for the dataset (e.g. HTTP response) |
15+
| `parseRow(rawRow)` | Converts a raw CSV/JSON row into a `IDiscoverySourceRow`, or `null` to skip |
16+
17+
Sources are registered in `src/sources/registry.ts` as a simple name → factory map.
18+
19+
**To add a new source:** create a class implementing `IDiscoverySource`, then add one line to the registry.
20+
21+
### Current sources
22+
23+
| Name | Folder | Description |
24+
| ------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------ |
25+
| `ossf-criticality-score` | `src/sources/ossf-criticality-score/` | OSSF Criticality Score snapshots from a public GCS bucket (~750K repos per snapshot) |
26+
27+
### Workflow
28+
29+
```
30+
discoverProjects({ mode: 'incremental' | 'full' })
31+
32+
├─ Activity: listDatasets(sourceName)
33+
│ → returns dataset descriptors sorted newest-first
34+
35+
├─ Selection: incremental → latest only, full → all datasets
36+
37+
└─ For each dataset:
38+
└─ Activity: processDataset(sourceName, dataset)
39+
→ HTTP stream → csv-parse → batches of 5000 → bulkUpsertProjectCatalog
40+
```
41+
42+
### Timeouts
43+
44+
| Activity | startToCloseTimeout | retries |
45+
| ------------------ | ------------------- | ------- |
46+
| `listDatasets` | 2 min | 3 |
47+
| `processDataset` | 30 min | 3 |
48+
| Workflow execution | 2 hours | 3 |
49+
50+
### Schedule
51+
52+
Runs daily at midnight via Temporal cron (`0 0 * * *`).
53+
54+
## File structure
55+
56+
```
57+
src/
58+
├── main.ts # Service bootstrap (postgres enabled)
59+
├── activities.ts # Barrel re-export
60+
├── workflows.ts # Barrel re-export
61+
├── activities/
62+
│ └── activities.ts # listDatasets, processDataset
63+
├── workflows/
64+
│ └── discoverProjects.ts # Orchestration with mode selection
65+
├── schedules/
66+
│ └── scheduleProjectsDiscovery.ts # Temporal cron schedule
67+
└── sources/
68+
├── types.ts # IDiscoverySource, IDatasetDescriptor
69+
├── registry.ts # Source factory map
70+
└── ossf-criticality-score/
71+
├── source.ts # IDiscoverySource implementation
72+
└── bucketClient.ts # GCS public bucket HTTP client
73+
```

services/apps/automatic_projects_discovery_worker/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@crowd/automatic-projects-discovery-worker",
33
"scripts": {
44
"start": "CROWD_TEMPORAL_TASKQUEUE=automatic-projects-discovery SERVICE=automatic-projects-discovery-worker tsx src/main.ts",
5-
"start:debug:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=automatic-projects-discovery SERVICE=automatic-projects-discovery-worker LOG_LEVEL=trace tsx --inspect=0.0.0.0:9232 src/main.ts",
5+
"start:debug:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=automatic-projects-discovery SERVICE=automatic-projects-discovery-worker tsx --inspect=0.0.0.0:9232 src/main.ts",
66
"start:debug": "CROWD_TEMPORAL_TASKQUEUE=automatic-projects-discovery SERVICE=automatic-projects-discovery-worker LOG_LEVEL=trace tsx --inspect=0.0.0.0:9232 src/main.ts",
77
"dev:local": "nodemon --watch src --watch ../../libs --ext ts --exec pnpm run start:debug:local",
88
"dev": "nodemon --watch src --watch ../../libs --ext ts --exec pnpm run start:debug",
@@ -24,6 +24,7 @@
2424
"@temporalio/activity": "~1.11.8",
2525
"@temporalio/client": "~1.11.8",
2626
"@temporalio/workflow": "~1.11.8",
27+
"csv-parse": "^5.5.6",
2728
"tsx": "^4.7.1",
2829
"typescript": "^5.6.3"
2930
},
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export * from './activities/activities'
1+
import { listDatasets, listSources, processDataset } from './activities/activities'
2+
3+
export { listDatasets, listSources, processDataset }
Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,124 @@
1+
import { parse } from 'csv-parse'
2+
3+
import { bulkUpsertProjectCatalog } from '@crowd/data-access-layer'
4+
import { IDbProjectCatalogCreate } from '@crowd/data-access-layer/src/project-catalog/types'
5+
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
16
import { getServiceLogger } from '@crowd/logging'
27

8+
import { svc } from '../main'
9+
import { getAvailableSourceNames, getSource } from '../sources/registry'
10+
import { IDatasetDescriptor } from '../sources/types'
11+
312
const log = getServiceLogger()
413

5-
export async function logDiscoveryRun(): Promise<void> {
6-
log.info('Automatic projects discovery workflow executed successfully.')
14+
const BATCH_SIZE = 5000
15+
16+
export async function listSources(): Promise<string[]> {
17+
return getAvailableSourceNames()
18+
}
19+
20+
export async function listDatasets(sourceName: string): Promise<IDatasetDescriptor[]> {
21+
const source = getSource(sourceName)
22+
const datasets = await source.listAvailableDatasets()
23+
24+
log.info({ sourceName, count: datasets.length, newest: datasets[0]?.id }, 'Datasets listed.')
25+
26+
return datasets
27+
}
28+
29+
export async function processDataset(
30+
sourceName: string,
31+
dataset: IDatasetDescriptor,
32+
): Promise<void> {
33+
const qx = pgpQx(svc.postgres.writer.connection())
34+
const startTime = Date.now()
35+
36+
log.info({ sourceName, datasetId: dataset.id, url: dataset.url }, 'Processing dataset...')
37+
38+
const source = getSource(sourceName)
39+
const stream = await source.fetchDatasetStream(dataset)
40+
41+
// For CSV sources: pipe through csv-parse to get Record<string, string> objects.
42+
// For JSON sources: the stream already emits pre-parsed objects in object mode.
43+
const records =
44+
source.format === 'json'
45+
? stream
46+
: stream.pipe(
47+
parse({
48+
columns: true,
49+
skip_empty_lines: true,
50+
trim: true,
51+
}),
52+
)
53+
54+
// pipe() does not forward source errors to the destination automatically, so we
55+
// destroy records explicitly — this surfaces the error in the for-await loop and
56+
// lets Temporal mark the activity as failed and retry it.
57+
stream.on('error', (err: Error) => {
58+
log.error({ datasetId: dataset.id, error: err.message }, 'Stream error.')
59+
records.destroy(err)
60+
})
61+
62+
if (source.format !== 'json') {
63+
const csvRecords = records as ReturnType<typeof parse>
64+
csvRecords.on('error', (err) => {
65+
log.error({ datasetId: dataset.id, error: err.message }, 'CSV parser error.')
66+
})
67+
}
68+
69+
let batch: IDbProjectCatalogCreate[] = []
70+
let totalProcessed = 0
71+
let totalSkipped = 0
72+
let batchNumber = 0
73+
let totalRows = 0
74+
75+
for await (const rawRow of records) {
76+
totalRows++
77+
78+
const parsed = source.parseRow(rawRow as Record<string, unknown>)
79+
if (!parsed) {
80+
totalSkipped++
81+
continue
82+
}
83+
84+
batch.push({
85+
projectSlug: parsed.projectSlug,
86+
repoName: parsed.repoName,
87+
repoUrl: parsed.repoUrl,
88+
ossfCriticalityScore: parsed.ossfCriticalityScore,
89+
lfCriticalityScore: parsed.lfCriticalityScore,
90+
})
91+
92+
if (batch.length >= BATCH_SIZE) {
93+
batchNumber++
94+
95+
await bulkUpsertProjectCatalog(qx, batch)
96+
totalProcessed += batch.length
97+
batch = []
98+
99+
log.info({ totalProcessed, batchNumber, datasetId: dataset.id }, 'Batch upserted.')
100+
}
101+
}
102+
103+
// Flush remaining rows that didn't fill a complete batch
104+
if (batch.length > 0) {
105+
batchNumber++
106+
await bulkUpsertProjectCatalog(qx, batch)
107+
totalProcessed += batch.length
108+
}
109+
110+
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1)
111+
112+
log.info(
113+
{
114+
sourceName,
115+
datasetId: dataset.id,
116+
totalRows,
117+
totalProcessed,
118+
totalSkipped,
119+
totalBatches: batchNumber,
120+
elapsedSeconds,
121+
},
122+
'Dataset processing complete.',
123+
)
7124
}

services/apps/automatic_projects_discovery_worker/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const config: Config = {
1818

1919
const options: Options = {
2020
postgres: {
21-
enabled: false,
21+
enabled: true,
2222
},
2323
opensearch: {
2424
enabled: false,

services/apps/automatic_projects_discovery_worker/src/schedules/scheduleProjectsDiscovery.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,15 @@ import { ScheduleAlreadyRunning, ScheduleOverlapPolicy } from '@temporalio/clien
33
import { svc } from '../main'
44
import { discoverProjects } from '../workflows'
55

6-
const DEFAULT_CRON = '0 2 * * *' // Daily at 2:00 AM
7-
86
export const scheduleProjectsDiscovery = async () => {
9-
const cronExpression = process.env.CROWD_AUTOMATIC_PROJECTS_DISCOVERY_CRON || DEFAULT_CRON
10-
11-
svc.log.info(`Scheduling projects discovery with cron: ${cronExpression}`)
7+
svc.log.info(`Scheduling projects discovery`)
128

139
try {
1410
await svc.temporal.schedule.create({
1511
scheduleId: 'automaticProjectsDiscovery',
1612
spec: {
17-
cronExpressions: [cronExpression],
13+
// Run every day at midnight
14+
cronExpressions: ['0 0 * * *'],
1815
},
1916
policies: {
2017
overlap: ScheduleOverlapPolicy.SKIP,
@@ -24,6 +21,8 @@ export const scheduleProjectsDiscovery = async () => {
2421
type: 'startWorkflow',
2522
workflowType: discoverProjects,
2623
taskQueue: 'automatic-projects-discovery',
24+
args: [{ mode: 'incremental' as const }],
25+
workflowExecutionTimeout: '2 hours',
2726
retry: {
2827
initialInterval: '15 seconds',
2928
backoffCoefficient: 2,

0 commit comments

Comments
 (0)