Skip to content

Commit 55560ae

Browse files
authored
Merge branch 'main' into chore/missing-category-in-1249
2 parents a0399f3 + 6a26da0 commit 55560ae

11 files changed

Lines changed: 256 additions & 6 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "projectCatalog"
2+
ADD COLUMN IF NOT EXISTS "onboardingError" TEXT;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export {}
1+
export * from './activities/activities'
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
findProjectCatalogById,
3+
findProjectCatalogPendingOnboarding,
4+
markProjectCatalogOnboardingFailed,
5+
updateProjectCatalog,
6+
} from '@crowd/data-access-layer'
7+
import { IDbProjectCatalog } from '@crowd/data-access-layer/src/project-catalog/types'
8+
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
9+
import { getServiceLogger } from '@crowd/logging'
10+
11+
import { svc } from '../main'
12+
import { onboardProject } from '../onboarder/onboarder'
13+
14+
const log = getServiceLogger()
15+
16+
export async function fetchProjectsPendingOnboarding(
17+
batchSize: number,
18+
): Promise<IDbProjectCatalog[]> {
19+
const qx = pgpQx(svc.postgres.reader.connection())
20+
21+
const projects = await findProjectCatalogPendingOnboarding(qx, { limit: batchSize })
22+
23+
log.info({ count: projects.length, batchSize }, 'Fetched projects pending onboarding.')
24+
25+
return projects
26+
}
27+
28+
async function findAlreadyOnboarded(
29+
qx: ReturnType<typeof pgpQx>,
30+
projectId: string,
31+
): Promise<IDbProjectCatalog | null> {
32+
const fresh = await findProjectCatalogById(qx, projectId)
33+
return fresh?.onboardedAt ? fresh : null
34+
}
35+
36+
export async function onboardAndUpdateProject(project: IDbProjectCatalog): Promise<void> {
37+
const qx = pgpQx(svc.postgres.writer.connection())
38+
const startTime = Date.now()
39+
40+
// Guard: uses the writer connection to avoid replica lag missing a just-written onboardedAt.
41+
const fresh = await findAlreadyOnboarded(qx, project.id)
42+
if (fresh) {
43+
log.info(
44+
{ id: project.id, repoUrl: project.repoUrl, onboardedAt: fresh.onboardedAt },
45+
'Project already onboarded, skipping API call.',
46+
)
47+
return
48+
}
49+
50+
log.info({ id: project.id, repoUrl: project.repoUrl }, 'Starting onboarding.')
51+
52+
const result = await onboardProject({
53+
id: project.id,
54+
repoUrl: project.repoUrl,
55+
repoName: project.repoName,
56+
projectSlug: project.projectSlug,
57+
})
58+
59+
if (result.outcome === 'error') {
60+
throw new Error(result.error ?? 'Unknown onboarding error')
61+
}
62+
63+
await updateProjectCatalog(qx, project.id, {
64+
onboardedAt: new Date().toISOString(),
65+
onboardingError: null,
66+
})
67+
68+
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1)
69+
70+
log.info(
71+
{ id: project.id, repoUrl: project.repoUrl, segmentId: result.segmentId, elapsedSeconds },
72+
'Onboarding complete.',
73+
)
74+
}
75+
76+
export async function markProjectOnboardingFailed(
77+
projectId: string,
78+
reason: string,
79+
): Promise<void> {
80+
const qx = pgpQx(svc.postgres.writer.connection())
81+
82+
const updatedRows = await markProjectCatalogOnboardingFailed(qx, projectId, reason)
83+
84+
if (updatedRows === 0) {
85+
log.info(
86+
{ id: projectId },
87+
'Project was already onboarded or no longer pending, not marking as error.',
88+
)
89+
return
90+
}
91+
92+
log.error({ id: projectId, reason }, 'Onboarding permanently failed, marked as error.')
93+
}

services/apps/automatic_onboarding_worker/src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Config } from '@crowd/archetype-standard'
22
import { Options, ServiceWorker } from '@crowd/archetype-worker'
33

4+
import { scheduleProjectsOnboarding } from './schedules/scheduleProjectsOnboarding'
5+
46
const config: Config = {
57
envvars: [
68
'CROWD_API_SERVICE_URL',
@@ -34,5 +36,9 @@ setImmediate(async () => {
3436

3537
svc.log.info('Automatic onboarding worker starting up.')
3638

39+
await scheduleProjectsOnboarding()
40+
41+
svc.log.info('Automatic onboarding worker running — schedule registered, waiting for Temporal.')
42+
3743
await svc.start()
3844
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ScheduleAlreadyRunning, ScheduleOverlapPolicy } from '@temporalio/client'
2+
3+
import { svc } from '../main'
4+
import { IOnboardProjectsInput, onboardProjects } from '../workflows'
5+
6+
const ONBOARDING_ARGS: IOnboardProjectsInput = {
7+
batchSize: 20,
8+
}
9+
10+
export const scheduleProjectsOnboarding = async () => {
11+
svc.log.info('Scheduling projects onboarding')
12+
13+
try {
14+
await svc.temporal.schedule.create({
15+
scheduleId: 'automaticOnboarding',
16+
spec: {
17+
// Daily: catches up on whatever landed in 'onboard' state, independent of the evaluation schedule's timing.
18+
cronExpressions: ['0 8 * * *'],
19+
},
20+
policies: {
21+
overlap: ScheduleOverlapPolicy.SKIP,
22+
catchupWindow: '1 hour',
23+
},
24+
action: {
25+
type: 'startWorkflow',
26+
workflowType: onboardProjects,
27+
taskQueue: 'automatic-onboarding',
28+
args: [ONBOARDING_ARGS],
29+
workflowExecutionTimeout: '6 hours',
30+
retry: {
31+
initialInterval: '30 seconds',
32+
backoffCoefficient: 2,
33+
maximumAttempts: 3,
34+
},
35+
},
36+
})
37+
} catch (err) {
38+
if (err instanceof ScheduleAlreadyRunning) {
39+
svc.log.info('Schedule already registered in Temporal.')
40+
svc.log.info('Configuration may have changed since. Please make sure they are in sync.')
41+
} else {
42+
throw new Error(err)
43+
}
44+
}
45+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface IOnboardProjectsInput {
2+
batchSize?: number
3+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { IOnboardProjectsInput } from './types'
2+
import { onboardProjects } from './workflows/onboardProjects'
3+
4+
export { onboardProjects }
5+
export type { IOnboardProjectsInput }

services/apps/automatic_onboarding_worker/src/workflows/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { log, proxyActivities, rootCause } from '@temporalio/workflow'
2+
3+
import type * as activities from '../activities'
4+
import type { IOnboardProjectsInput } from '../types'
5+
6+
// Short timeout: just a DB read.
7+
const fetchActivities = proxyActivities<typeof activities>({
8+
startToCloseTimeout: '2 minutes',
9+
retry: { maximumAttempts: 3 },
10+
})
11+
12+
// Each onboarding call chains a segment create/query plus GitHub enrichment and integration calls,
13+
// each of which can individually approach a ~30s backend timeout; give generous headroom per project.
14+
const onboardActivities = proxyActivities<typeof activities>({
15+
startToCloseTimeout: '5 minutes',
16+
retry: { maximumAttempts: 2 },
17+
})
18+
19+
const failureActivities = proxyActivities<typeof activities>({
20+
startToCloseTimeout: '2 minutes',
21+
retry: { maximumAttempts: 2 },
22+
})
23+
24+
export async function onboardProjects(input: IOnboardProjectsInput = {}): Promise<void> {
25+
const { batchSize = 20 } = input
26+
27+
log.info('onboardProjects workflow started.')
28+
29+
const projects = await fetchActivities.fetchProjectsPendingOnboarding(batchSize)
30+
31+
if (projects.length === 0) {
32+
log.info('No projects pending onboarding. Nothing to do.')
33+
return
34+
}
35+
36+
log.info(`Onboarding ${projects.length} project(s) (batch size: ${batchSize}).`)
37+
38+
let succeeded = 0
39+
let failed = 0
40+
41+
for (let i = 0; i < projects.length; i++) {
42+
const project = projects[i]
43+
log.info(`[${i + 1}/${projects.length}] Onboarding: ${project.repoUrl}`)
44+
45+
try {
46+
await onboardActivities.onboardAndUpdateProject(project)
47+
succeeded++
48+
} catch (err) {
49+
// Activity-level retries are already exhausted at this point — mark as a
50+
// terminal error so the daily schedule stops retrying this project forever.
51+
failed++
52+
const reason = rootCause(err) ?? String(err)
53+
log.error(
54+
`Onboarding failed for project id=${project.id} repoUrl=${project.repoUrl}: ${reason}`,
55+
)
56+
57+
try {
58+
await failureActivities.markProjectOnboardingFailed(project.id, reason)
59+
} catch (markErr) {
60+
// Don't let a failure to record the error state abort the rest of the batch.
61+
log.error(`Failed to mark project id=${project.id} as errored: ${String(markErr)}`)
62+
}
63+
}
64+
}
65+
66+
log.info(
67+
`Batch onboarding complete. total=${projects.length} succeeded=${succeeded} failed=${failed}`,
68+
)
69+
}

services/libs/data-access-layer/src/project-catalog/projectCatalog.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const PROJECT_CATALOG_COLUMNS = [
2020
'evaluationReason',
2121
'evaluatedAt',
2222
'onboardedAt',
23+
'onboardingError',
2324
'syncedAt',
2425
'createdAt',
2526
'updatedAt',
@@ -335,7 +336,7 @@ export async function upsertProjectCatalog(
335336
"repoName" = EXCLUDED."repoName",
336337
"source" = COALESCE(EXCLUDED."source", "projectCatalog"."source"),
337338
"action" = CASE
338-
WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure') THEN "projectCatalog"."action"
339+
WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure', 'error') THEN "projectCatalog"."action"
339340
WHEN EXCLUDED.action = 'evaluate' THEN 'evaluate'
340341
ELSE "projectCatalog"."action"
341342
END,
@@ -408,7 +409,7 @@ export async function bulkUpsertProjectCatalog(
408409
"repoName" = EXCLUDED."repoName",
409410
"source" = COALESCE(EXCLUDED."source", "projectCatalog"."source"),
410411
"action" = CASE
411-
WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure') THEN "projectCatalog"."action"
412+
WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure', 'error') THEN "projectCatalog"."action"
412413
WHEN EXCLUDED.action = 'evaluate' THEN 'evaluate'
413414
ELSE "projectCatalog"."action"
414415
END,
@@ -472,6 +473,10 @@ export async function updateProjectCatalog(
472473
setClauses.push('"onboardedAt" = $(onboardedAt)')
473474
params.onboardedAt = data.onboardedAt
474475
}
476+
if (data.onboardingError !== undefined) {
477+
setClauses.push('"onboardingError" = $(onboardingError)')
478+
params.onboardingError = data.onboardingError
479+
}
475480

476481
if (setClauses.length === 0) {
477482
return findProjectCatalogById(qx, id)
@@ -490,6 +495,21 @@ export async function updateProjectCatalog(
490495
)
491496
}
492497

498+
export async function markProjectCatalogOnboardingFailed(
499+
qx: QueryExecutor,
500+
id: string,
501+
reason: string,
502+
): Promise<number> {
503+
return qx.result(
504+
`
505+
UPDATE "projectCatalog"
506+
SET "action" = 'error', "onboardingError" = $(reason), "updatedAt" = NOW()
507+
WHERE id = $(id) AND "action" = 'onboard' AND "onboardedAt" IS NULL
508+
`,
509+
{ id, reason },
510+
)
511+
}
512+
493513
export async function updateProjectCatalogSyncedAt(qx: QueryExecutor, id: string): Promise<void> {
494514
await qx.selectNone(
495515
`

0 commit comments

Comments
 (0)