Skip to content

Commit a338dd9

Browse files
committed
Fix for stalling relative scoring
1 parent a2af878 commit a338dd9

5 files changed

Lines changed: 222 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ Config identity semantics:
300300
- `POST /challenge/:challengeId` validates that the challenge exists in challenge-api and that `reviewScorecardId` resolves in review-api before persisting. If a config already exists for the challenge, the API returns `409 Conflict`.
301301
- `phaseConfig.phaseId` stores the canonical challenge phase definition id from challenge-api `phases[].phaseId`. Create/update requests also accept the challenge-phase row `id` and normalize it before persistence for backwards compatibility.
302302
- `reviewScorecardId` can be either the current review-api scorecard id or a legacy id; scoring callback processing resolves it to the canonical scorecard id before posting review summations.
303-
- When `relativeScoringEnabled = true`, review scores are recomputed from per-test raw scores against the best score currently held by the latest submission from each member, so final review summation scores stay within `0..100` and can change as new submissions arrive. The raw callback result is stored only as a pending placeholder until the recompute worker writes the normalized score; queued recompute passes also finalize visible pending placeholders that are not selected as the latest member submission.
303+
- When `relativeScoringEnabled = true`, review scores are recomputed from per-test raw scores against the best score currently held by the latest submission from each member, so final review summation scores stay within `0..100` and can change as new submissions arrive. The raw callback result is stored only as a pending placeholder until the recompute worker writes the normalized score; queued recompute passes also finalize visible pending placeholders that are not selected as the latest member submission, and fetch the queued callback submission's review summation directly from Review API.
304304

305305
Important runtime behavior:
306306

docs/review-phase-scoring.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The queue is keyed by challenge ID, review ID, and submission ID so repeated pha
8484

8585
## Relative scoring at completion
8686

87-
If `relativeScoringEnabled = true`, `ScoringResultService` persists the raw callback result as a pending, non-passing placeholder and queues a pg-boss `relative-scoring-recompute` job. The worker recalculates normalized aggregate scores under a challenge/phase PostgreSQL advisory lock, updates Review API, and then completes matching SYSTEM reviews with the recomputed scores. Each worker pass also finalizes any persisted `relativeScoringPending` placeholders it can see, even when a placeholder is not the selected latest submission for that member. `ChallengeCompletionService.finalizeChallenge(...)` consumes those persisted review summaries; it does not recompute relative scoring itself.
87+
If `relativeScoringEnabled = true`, `ScoringResultService` persists the raw callback result as a pending, non-passing placeholder and queues a pg-boss `relative-scoring-recompute` job. The worker recalculates normalized aggregate scores under a challenge/phase PostgreSQL advisory lock, updates Review API, and then completes matching SYSTEM reviews with the recomputed scores. Each worker pass also finalizes any persisted `relativeScoringPending` placeholders it can see, even when a placeholder is not the selected latest submission for that member; the queued callback submission's review summation is fetched directly from Review API so reruns omitted from the challenge submission list still complete. `ChallengeCompletionService.finalizeChallenge(...)` consumes those persisted review summaries; it does not recompute relative scoring itself.
8888

8989
The recompute queue debounces SYSTEM bursts by challenge and phase so simultaneous scorer callbacks do not all run the expensive fan-out. Retry behavior is controlled by `RELATIVE_SCORING_RECOMPUTE_RETRY_DELAY_SECONDS` (default `60`), `RELATIVE_SCORING_RECOMPUTE_RETRY_LIMIT` (default `1000`), `RELATIVE_SCORING_RECOMPUTE_DEBOUNCE_SECONDS` (default `15`), `RELATIVE_SCORING_RECOMPUTE_START_DELAY_SECONDS` (default `10`), and `RELATIVE_SCORING_RECOMPUTE_WORKER_CONCURRENCY` (default `1`).
9090

docs/submission-phase-scoring.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Relative scoring applies when:
112112
- `relativeScoringEnabled = true` on the Marathon Match config
113113
- `testScores` are present in the scorer metadata
114114

115-
In that case, `ScoringResultService` writes the raw callback result as a pending, non-passing placeholder, then queues a pg-boss relative scoring recomputation. The worker recalculates latest-submission review scores relative to the current best result, updates the persisted aggregate scores, and keeps existing `reviewedDate` values so relative-score updates do not move historical review timestamps. A worker pass also finalizes visible `relativeScoringPending` placeholders that are not selected as the latest member submission, so completed reruns do not remain in progress.
115+
In that case, `ScoringResultService` writes the raw callback result as a pending, non-passing placeholder, then queues a pg-boss relative scoring recomputation. The worker recalculates latest-submission review scores relative to the current best result, updates the persisted aggregate scores, and keeps existing `reviewedDate` values so relative-score updates do not move historical review timestamps. A worker pass also finalizes visible `relativeScoringPending` placeholders that are not selected as the latest member submission, and fetches the queued callback submission's review summation directly from Review API so completed reruns do not remain in progress.
116116

117117
## Tester-change rerun
118118

src/api/scoring-result/scoring-result.service.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,122 @@ describe('ScoringResultService', () => {
943943
);
944944
});
945945

946+
it('loads the queued submission pending relative placeholder directly when submissions omit it', async () => {
947+
const { service, m2mService, prisma } = createService();
948+
949+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
950+
challengeId: basePayload.challengeId,
951+
name: 'Blocks',
952+
submissionApiUrl: 'https://api.topcoder-dev.com/v6',
953+
relativeScoringEnabled: true,
954+
scoreDirection: ScoreDirection.MAXIMIZE,
955+
});
956+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
957+
958+
jest
959+
.spyOn(service as any, 'withRelativeScoringLock')
960+
.mockImplementation(
961+
async (
962+
_challengeId: string,
963+
_testPhase: string,
964+
work: () => Promise<unknown>,
965+
) => work(),
966+
);
967+
jest
968+
.spyOn(service as any, 'resolveScorecardId')
969+
.mockResolvedValue('scorecard-1');
970+
jest.spyOn(service as any, 'fetchChallengeSubmissions').mockResolvedValue([
971+
{
972+
id: 'submission-visible-latest',
973+
memberId: 'member-visible',
974+
isLatest: true,
975+
submittedDate: '2026-05-01T00:00:02.000Z',
976+
reviewSummation: [
977+
{
978+
id: 'summation-visible-latest',
979+
aggregateScore: 100,
980+
isFinal: true,
981+
metadata: {
982+
testScores: [{ testcase: '753388858', score: 100 }],
983+
testStatus: ScoringTestStatus.Success,
984+
testType: 'system',
985+
},
986+
scorecardId: 'scorecard-1',
987+
},
988+
],
989+
},
990+
]);
991+
jest
992+
.spyOn(service as any, 'findExistingReviewSummations')
993+
.mockResolvedValue([
994+
{
995+
id: 'summation-hidden-pending',
996+
aggregateScore: 0,
997+
isFinal: true,
998+
metadata: {
999+
relativeScoringPending: true,
1000+
testScores: [{ testcase: '753388858', score: 50 }],
1001+
testStatus: ScoringTestStatus.InProgress,
1002+
testType: 'system',
1003+
},
1004+
scorecardId: 'scorecard-1',
1005+
},
1006+
]);
1007+
const upsertReviewSummationSpy = jest
1008+
.spyOn(service as any, 'upsertReviewSummation')
1009+
.mockResolvedValue(undefined);
1010+
const completeSystemReviewIfNeededSpy = jest
1011+
.spyOn(service as any, 'completeSystemReviewIfNeeded')
1012+
.mockResolvedValue(undefined);
1013+
jest
1014+
.spyOn(service as any, 'notifyScoringCompletionEmailIfReady')
1015+
.mockResolvedValue(undefined);
1016+
1017+
await expect(
1018+
service.recomputeQueuedRelativeScoring({
1019+
challengeId: basePayload.challengeId,
1020+
submissionId: 'submission-hidden-pending',
1021+
reviewId: 'review-hidden-pending',
1022+
testPhase: 'system',
1023+
reviewTypeId: basePayload.reviewTypeId,
1024+
scorecardId: 'scorecard-1',
1025+
queuedAt: '2026-05-01T00:00:10.000Z',
1026+
}),
1027+
).resolves.toBe(undefined);
1028+
1029+
expect(upsertReviewSummationSpy).toHaveBeenCalledWith(
1030+
'm2m-token',
1031+
'system',
1032+
expect.objectContaining({
1033+
aggregateScore: 50,
1034+
isPassing: true,
1035+
submissionId: 'submission-hidden-pending',
1036+
metadata: expect.objectContaining({
1037+
testProgress: 1,
1038+
testStatus: ScoringTestStatus.Success,
1039+
}),
1040+
}),
1041+
'summation-hidden-pending',
1042+
);
1043+
const hiddenPayload = upsertReviewSummationSpy.mock.calls.find(
1044+
([, , payload]) =>
1045+
(payload as { submissionId: string }).submissionId ===
1046+
'submission-hidden-pending',
1047+
)?.[2] as { metadata?: Record<string, unknown> } | undefined;
1048+
expect(hiddenPayload?.metadata).not.toHaveProperty(
1049+
'relativeScoringPending',
1050+
);
1051+
expect(completeSystemReviewIfNeededSpy).toHaveBeenCalledWith(
1052+
'm2m-token',
1053+
'review-hidden-pending',
1054+
50,
1055+
'system',
1056+
expect.objectContaining({
1057+
submissionId: 'submission-hidden-pending',
1058+
}),
1059+
);
1060+
});
1061+
9461062
it('serializes concurrent relative scoring recomputations for the same challenge phase', async () => {
9471063
const { service, m2mService, prisma } = createService();
9481064
const firstPayload: ScoringResultCallbackPayload = {

src/api/scoring-result/scoring-result.service.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,7 @@ export class ScoringResultService {
12251225
reviewTypeId,
12261226
fallbackScorecardId,
12271227
lockedSettings,
1228+
submissionId,
12281229
),
12291230
);
12301231

@@ -1352,6 +1353,8 @@ export class ScoringResultService {
13521353
* @param reviewTypeId Review type identifier to preserve in normalized metadata.
13531354
* @param fallbackScorecardId Scorecard ID resolved from callback/config data.
13541355
* @param settings Relative scoring configuration for the challenge.
1356+
* @param callbackSubmissionId Submission from the queued callback; its pending
1357+
* summation is fetched directly so reruns for non-listed submissions finalize.
13551358
* @returns Recomputed relative review payloads that were written.
13561359
* @throws Error when submission-api or review-api calls fail.
13571360
*/
@@ -1361,6 +1364,7 @@ export class ScoringResultService {
13611364
reviewTypeId: string,
13621365
fallbackScorecardId: string | undefined,
13631366
settings: Required<RelativeScoringSettings>,
1367+
callbackSubmissionId?: string,
13641368
): Promise<RelativeReviewPayload[]> {
13651369
const submissions = await this.fetchChallengeSubmissions(
13661370
token,
@@ -1379,10 +1383,27 @@ export class ScoringResultService {
13791383
testPhase,
13801384
reviewTypeId,
13811385
);
1382-
const reviewRecords = this.mergeRelativeReviewRecords(
1386+
const visibleReviewRecords = this.mergeRelativeReviewRecords(
13831387
latestReviewRecords,
13841388
pendingReviewRecords,
13851389
);
1390+
const callbackPendingReviewRecords =
1391+
this.hasRelativeReviewRecordForSubmission(
1392+
visibleReviewRecords,
1393+
callbackSubmissionId,
1394+
)
1395+
? []
1396+
: await this.fetchPendingRelativeReviewRecordsForSubmission(
1397+
token,
1398+
submissions,
1399+
testPhase,
1400+
reviewTypeId,
1401+
callbackSubmissionId,
1402+
);
1403+
const reviewRecords = this.mergeRelativeReviewRecords(
1404+
visibleReviewRecords,
1405+
callbackPendingReviewRecords,
1406+
);
13861407

13871408
if (reviewRecords.length === 0) {
13881409
this.logger.warn({
@@ -2663,6 +2684,87 @@ export class ScoringResultService {
26632684
return pendingRecords;
26642685
}
26652686

2687+
/**
2688+
* Fetches pending relative-scoring placeholders for the queued callback
2689+
* submission directly from Review API. This covers reruns whose submission is
2690+
* not present, or not expanded with metadata, in the challenge submission list.
2691+
* @param token M2M token for review-api.
2692+
* @param challengeSubmissions Submission records already fetched for context.
2693+
* @param testPhase Requested scoring phase.
2694+
* @param reviewTypeId Review type identifier to preserve in normalized metadata.
2695+
* @param callbackSubmissionId Submission ID carried by the recompute job.
2696+
* @returns Recomputable pending review records for the callback submission.
2697+
*/
2698+
private async fetchPendingRelativeReviewRecordsForSubmission(
2699+
token: string,
2700+
challengeSubmissions: Record<string, unknown>[],
2701+
testPhase: string,
2702+
reviewTypeId: string,
2703+
callbackSubmissionId?: string,
2704+
): Promise<RelativeReviewRecord[]> {
2705+
const submissionId = this.asString(callbackSubmissionId);
2706+
if (!submissionId) {
2707+
return [];
2708+
}
2709+
2710+
const submission = challengeSubmissions.find(
2711+
(entry) => this.extractSubmissionId(entry) === submissionId,
2712+
);
2713+
const reviewSummations = await this.findExistingReviewSummations(
2714+
token,
2715+
submissionId,
2716+
testPhase,
2717+
);
2718+
const pendingRecords: RelativeReviewRecord[] = [];
2719+
2720+
for (const reviewObject of reviewSummations) {
2721+
const metadata = this.asRecord(reviewObject.metadata);
2722+
if (this.parseBooleanFlag(metadata.relativeScoringPending) !== true) {
2723+
continue;
2724+
}
2725+
2726+
const pendingRecord = this.buildRelativeReviewRecord({
2727+
createdAt: submission
2728+
? this.resolveSubmissionDate(submission)
2729+
: undefined,
2730+
memberKey: submission
2731+
? (this.extractSubmissionMemberKey(submission) ??
2732+
`submission:${submissionId}`)
2733+
: `submission:${submissionId}`,
2734+
reviewObject,
2735+
reviewTypeId,
2736+
submissionId,
2737+
testPhase,
2738+
});
2739+
2740+
if (pendingRecord) {
2741+
pendingRecords.push(pendingRecord);
2742+
}
2743+
}
2744+
2745+
return pendingRecords;
2746+
}
2747+
2748+
/**
2749+
* Checks whether a recompute record set already includes one submission.
2750+
* @param reviewRecords Candidate relative review records.
2751+
* @param submissionId Submission ID to find.
2752+
* @returns True when the submission is already represented.
2753+
*/
2754+
private hasRelativeReviewRecordForSubmission(
2755+
reviewRecords: RelativeReviewRecord[],
2756+
submissionId?: string,
2757+
): boolean {
2758+
const normalizedSubmissionId = this.asString(submissionId);
2759+
if (!normalizedSubmissionId) {
2760+
return false;
2761+
}
2762+
2763+
return reviewRecords.some(
2764+
(reviewRecord) => reviewRecord.submissionId === normalizedSubmissionId,
2765+
);
2766+
}
2767+
26662768
/**
26672769
* Merges relative review records by persisted review summation id, falling back
26682770
* to submission id for records that do not expose an id.

0 commit comments

Comments
 (0)