Skip to content

Commit 2ad18b1

Browse files
authored
feat: evaluation priority logic (CM-1166) (#4130)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent c5f331c commit 2ad18b1

7 files changed

Lines changed: 148 additions & 9 deletions

File tree

services/apps/automatic_projects_discovery_worker/src/sources/insights-discussions/source.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,6 @@ export class InsightsDiscussionsSource implements IDiscoverySource {
261261
projectSlug,
262262
repoName,
263263
repoUrl,
264-
action: 'evaluate',
265264
}
266265
}
267266
}

services/apps/projects_evaluation_worker/src/activities/activities.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
1-
import { findProjectCatalogPendingEvaluation, updateProjectCatalog } from '@crowd/data-access-layer'
1+
import {
2+
findProjectCatalogPendingEvaluation,
3+
promoteProjectsToEvaluate,
4+
updateProjectCatalog,
5+
} from '@crowd/data-access-layer'
26
import { IDbProjectCatalog } from '@crowd/data-access-layer/src/project-catalog/types'
37
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
48
import { getServiceLogger } from '@crowd/logging'
59

610
import { evaluateProject } from '../evaluator/evaluator'
711
import { svc } from '../main'
12+
import { IPriorityConfig } from '../types'
813

914
const log = getServiceLogger()
1015

16+
/**
17+
* Promotes 'auto' projects to 'evaluate' up to the configured limit.
18+
* Count, slot computation, locking, and update are all done atomically
19+
* inside a single SQL statement — see promoteProjectsToEvaluate in the DAL.
20+
*/
21+
export async function promoteProjectsForEvaluation(config: IPriorityConfig): Promise<void> {
22+
const { evaluateLimit, sourcePriority } = config
23+
const qx = pgpQx(svc.postgres.writer.connection())
24+
25+
log.info({ evaluateLimit, sourcePriority }, 'Priority promotion: starting.')
26+
27+
const promoted = await promoteProjectsToEvaluate(qx, { evaluateLimit, sourcePriority })
28+
29+
log.info({ promoted }, 'Priority promotion: complete.')
30+
}
31+
1132
export async function fetchPendingProjects(batchSize: number): Promise<IDbProjectCatalog[]> {
1233
const qx = pgpQx(svc.postgres.reader.connection())
1334

services/apps/projects_evaluation_worker/src/schedules/scheduleProjectsEvaluation.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { ScheduleAlreadyRunning, ScheduleOverlapPolicy } from '@temporalio/client'
22

33
import { svc } from '../main'
4-
import { evaluateProjects } from '../workflows'
4+
import { IEvaluateProjectsInput, evaluateProjects } from '../workflows'
5+
6+
// Priority configuration for the evaluation queue.
7+
// - evaluateLimit: maximum number of projects in 'evaluate' state at any time.
8+
// - sourcePriority: ordered list of sources; earlier = higher priority; unlisted sources rank last.
9+
const EVALUATION_ARGS: IEvaluateProjectsInput = {
10+
batchSize: 50,
11+
priorityConfig: {
12+
evaluateLimit: 50,
13+
sourcePriority: ['insights-discussions'],
14+
},
15+
}
516

617
export const scheduleProjectsEvaluation = async () => {
718
svc.log.info('Scheduling projects evaluation')
@@ -21,8 +32,8 @@ export const scheduleProjectsEvaluation = async () => {
2132
type: 'startWorkflow',
2233
workflowType: evaluateProjects,
2334
taskQueue: 'projects-evaluation',
24-
args: [{ batchSize: 100 }],
25-
// 100 projects × ~3min each = ~5h worst case; set ceiling with margin.
35+
args: [EVALUATION_ARGS],
36+
// 50 projects × ~3min each = ~2.5h worst case; set ceiling with margin.
2637
workflowExecutionTimeout: '6 hours',
2738
retry: {
2839
initialInterval: '30 seconds',
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface IPriorityConfig {
2+
/** Maximum number of projects in the 'evaluate' queue at any time. */
3+
evaluateLimit: number
4+
/**
5+
* Ordered list of source names by descending priority.
6+
* Sources not in this list rank below all listed ones.
7+
*/
8+
sourcePriority: string[]
9+
}
10+
11+
export interface IEvaluateProjectsInput {
12+
batchSize?: number
13+
priorityConfig?: IPriorityConfig
14+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { IEvaluateProjectsInput } from './types'
12
import { evaluateProjects } from './workflows/evaluateProjects'
23

34
export { evaluateProjects }
5+
export type { IEvaluateProjectsInput }

services/apps/projects_evaluation_worker/src/workflows/evaluateProjects.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { log, proxyActivities } from '@temporalio/workflow'
22

33
import type * as activities from '../activities'
4+
import type { IEvaluateProjectsInput, IPriorityConfig } from '../types'
5+
6+
// Quick DB write — promote auto → evaluate.
7+
const promotionActivities = proxyActivities<typeof activities>({
8+
startToCloseTimeout: '1 minute',
9+
retry: { maximumAttempts: 3 },
10+
})
411

512
// Short timeout: just a DB read.
613
const fetchActivities = proxyActivities<typeof activities>({
@@ -14,13 +21,20 @@ const evaluateActivities = proxyActivities<typeof activities>({
1421
retry: { maximumAttempts: 2 },
1522
})
1623

17-
const DEFAULT_BATCH_SIZE = 100
24+
const DEFAULT_PRIORITY_CONFIG: IPriorityConfig = {
25+
evaluateLimit: 50,
26+
sourcePriority: ['insights-discussions'],
27+
}
1828

19-
export async function evaluateProjects(input: { batchSize?: number } = {}): Promise<void> {
20-
const batchSize = input.batchSize ?? DEFAULT_BATCH_SIZE
29+
export async function evaluateProjects(input: IEvaluateProjectsInput = {}): Promise<void> {
30+
const { batchSize = 50, priorityConfig = DEFAULT_PRIORITY_CONFIG } = input
2131

2232
log.info('evaluateProjects workflow started.')
2333

34+
// Step 1: promote 'auto' projects to 'evaluate' according to priority config.
35+
await promotionActivities.promoteProjectsForEvaluation(priorityConfig)
36+
37+
// Step 2: fetch the evaluation queue (includes any leftovers from prior runs).
2438
const projects = await fetchActivities.fetchPendingProjects(batchSize)
2539

2640
if (projects.length === 0) {

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

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { QueryExecutor } from '../queryExecutor'
22
import { prepareSelectColumns } from '../utils'
33

4-
import { IDbProjectCatalog, IDbProjectCatalogCreate, IDbProjectCatalogUpdate } from './types'
4+
import {
5+
IDbProjectCatalog,
6+
IDbProjectCatalogCreate,
7+
IDbProjectCatalogUpdate,
8+
ProjectCatalogAction,
9+
} from './types'
510

611
const PROJECT_CATALOG_COLUMNS = [
712
'id',
@@ -108,6 +113,79 @@ export async function countProjectCatalog(qx: QueryExecutor): Promise<number> {
108113
return parseInt(result.count, 10)
109114
}
110115

116+
export async function countProjectCatalogByAction(
117+
qx: QueryExecutor,
118+
action: ProjectCatalogAction,
119+
): Promise<number> {
120+
const result = await qx.selectOne(
121+
`
122+
SELECT COUNT(*) AS count
123+
FROM "projectCatalog"
124+
WHERE action = $(action)
125+
`,
126+
{ action },
127+
)
128+
return parseInt(result.count, 10)
129+
}
130+
131+
/**
132+
* Promotes 'auto' projects to 'evaluate', targeting a soft cap on the queue size.
133+
*
134+
* Uses a CTE to:
135+
* 1. Compute available slots (evaluateLimit − current 'evaluate' count) inline.
136+
* 2. Lock candidates with FOR UPDATE SKIP LOCKED — prevents double-promotion
137+
* if two transactions run concurrently (e.g. simultaneous manual triggers).
138+
* Note: concurrent calls can each compute the same slot count and promote
139+
* up to evaluateLimit disjoint rows, so the cap is soft — the queue can
140+
* overshoot by at most evaluateLimit per extra concurrent caller. In practice
141+
* this doesn't happen: the schedule uses ScheduleOverlapPolicy.SKIP.
142+
* 3. Re-check action = 'auto' in the outer UPDATE to guard against rows whose
143+
* state changed after the subquery snapshot (e.g. manual updates).
144+
*
145+
* Ordering (configurable via `sourcePriority`):
146+
* 1. Source priority — earlier position in the array = higher priority (unlisted = lowest)
147+
* 2. lfCriticalityScore DESC (NULLs last)
148+
* 3. createdAt ASC (stable tie-breaker)
149+
*
150+
* Returns the number of rows actually promoted.
151+
*/
152+
export async function promoteProjectsToEvaluate(
153+
qx: QueryExecutor,
154+
options: { evaluateLimit: number; sourcePriority: string[] },
155+
): Promise<number> {
156+
const { evaluateLimit, sourcePriority } = options
157+
158+
return qx.result(
159+
`
160+
WITH
161+
slots AS (
162+
SELECT GREATEST(0, $(evaluateLimit) - COUNT(*)) AS available
163+
FROM "projectCatalog"
164+
WHERE action = 'evaluate'
165+
),
166+
candidates AS (
167+
SELECT pc.id
168+
FROM "projectCatalog" pc
169+
CROSS JOIN slots
170+
WHERE pc.action = 'auto'
171+
AND slots.available > 0
172+
ORDER BY
173+
COALESCE(ARRAY_POSITION($(sourcePriority)::text[], pc.source), 2147483647) ASC,
174+
pc."lfCriticalityScore" DESC NULLS LAST,
175+
pc."createdAt" ASC
176+
LIMIT (SELECT available FROM slots)
177+
FOR UPDATE SKIP LOCKED
178+
)
179+
UPDATE "projectCatalog"
180+
SET action = 'evaluate', "updatedAt" = NOW()
181+
FROM candidates
182+
WHERE "projectCatalog".id = candidates.id
183+
AND "projectCatalog".action = 'auto'
184+
`,
185+
{ evaluateLimit, sourcePriority },
186+
)
187+
}
188+
111189
export async function insertProjectCatalog(
112190
qx: QueryExecutor,
113191
data: IDbProjectCatalogCreate,

0 commit comments

Comments
 (0)