Skip to content

Commit 6371f23

Browse files
authored
Merge pull request #417 from topcoder-platform/develop
Fix for final system tests not working for one of the MM165 submissions
2 parents e3a1a53 + a2af878 commit 6371f23

5 files changed

Lines changed: 373 additions & 29 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.
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.
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. `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. `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.
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.
116116

117117
## Tester-change rerun
118118

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

Lines changed: 216 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,121 @@ describe('ScoringResultService', () => {
828828
);
829829
});
830830

831+
it('finalizes pending relative placeholders even when they are not latest member submissions', async () => {
832+
const { service, m2mService, prisma } = createService();
833+
const reviewFor = (
834+
submissionId: string,
835+
rawScore: number,
836+
relativeScoringPending = false,
837+
) => ({
838+
id: `summation-${submissionId}`,
839+
aggregateScore: relativeScoringPending ? 0 : rawScore,
840+
isFinal: true,
841+
scorecardId: 'scorecard-1',
842+
metadata: {
843+
...(relativeScoringPending ? { relativeScoringPending: true } : {}),
844+
testStatus: relativeScoringPending
845+
? ScoringTestStatus.InProgress
846+
: ScoringTestStatus.Success,
847+
testType: 'system',
848+
testScores: [{ testcase: '753388858', score: rawScore }],
849+
},
850+
});
851+
852+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
853+
challengeId: basePayload.challengeId,
854+
name: 'Blocks',
855+
submissionApiUrl: 'https://api.topcoder-dev.com/v6',
856+
relativeScoringEnabled: true,
857+
scoreDirection: ScoreDirection.MAXIMIZE,
858+
});
859+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
860+
861+
jest
862+
.spyOn(service as any, 'withRelativeScoringLock')
863+
.mockImplementation(
864+
async (
865+
_challengeId: string,
866+
_testPhase: string,
867+
work: () => Promise<unknown>,
868+
) => work(),
869+
);
870+
jest
871+
.spyOn(service as any, 'resolveScorecardId')
872+
.mockResolvedValue('scorecard-1');
873+
jest.spyOn(service as any, 'fetchChallengeSubmissions').mockResolvedValue([
874+
{
875+
id: 'submission-newer',
876+
memberId: 'member-a',
877+
isLatest: true,
878+
submittedDate: '2026-05-01T00:00:02.000Z',
879+
reviewSummation: [reviewFor('submission-newer', 100)],
880+
},
881+
{
882+
id: 'submission-old-pending',
883+
memberId: 'member-a',
884+
isLatest: false,
885+
submittedDate: '2026-05-01T00:00:01.000Z',
886+
reviewSummation: [reviewFor('submission-old-pending', 50, true)],
887+
},
888+
{
889+
id: 'submission-other',
890+
memberId: 'member-b',
891+
isLatest: true,
892+
submittedDate: '2026-05-01T00:00:03.000Z',
893+
reviewSummation: [reviewFor('submission-other', 80)],
894+
},
895+
]);
896+
const upsertReviewSummationSpy = jest
897+
.spyOn(service as any, 'upsertReviewSummation')
898+
.mockResolvedValue(undefined);
899+
jest
900+
.spyOn(service as any, 'completeSystemReviewIfNeeded')
901+
.mockResolvedValue(undefined);
902+
jest
903+
.spyOn(service as any, 'notifyScoringCompletionEmailIfReady')
904+
.mockResolvedValue(undefined);
905+
906+
await expect(
907+
service.recomputeQueuedRelativeScoring({
908+
challengeId: basePayload.challengeId,
909+
submissionId: 'submission-old-pending',
910+
reviewId: 'review-old-pending',
911+
testPhase: 'system',
912+
reviewTypeId: basePayload.reviewTypeId,
913+
scorecardId: 'scorecard-1',
914+
queuedAt: '2026-05-01T00:00:10.000Z',
915+
}),
916+
).resolves.toBe(undefined);
917+
918+
const pendingPayload = upsertReviewSummationSpy.mock.calls
919+
.map(
920+
([, , payload]) =>
921+
payload as {
922+
aggregateScore: number;
923+
isPassing: boolean;
924+
metadata?: Record<string, unknown>;
925+
submissionId: string;
926+
},
927+
)
928+
.find((payload) => payload.submissionId === 'submission-old-pending');
929+
930+
expect(pendingPayload).toEqual(
931+
expect.objectContaining({
932+
aggregateScore: 50,
933+
isPassing: true,
934+
submissionId: 'submission-old-pending',
935+
metadata: expect.objectContaining({
936+
testProgress: 1,
937+
testStatus: ScoringTestStatus.Success,
938+
}),
939+
}),
940+
);
941+
expect(pendingPayload?.metadata).not.toHaveProperty(
942+
'relativeScoringPending',
943+
);
944+
});
945+
831946
it('serializes concurrent relative scoring recomputations for the same challenge phase', async () => {
832947
const { service, m2mService, prisma } = createService();
833948
const firstPayload: ScoringResultCallbackPayload = {
@@ -1642,6 +1757,83 @@ describe('ScoringResultService', () => {
16421757
);
16431758
});
16441759

1760+
it('uses the review matching the callback submission when reviewId is mismatched', async () => {
1761+
const { service, httpService, m2mService, prisma } = createService();
1762+
1763+
const systemPayload: ScoringResultCallbackPayload = {
1764+
...basePayload,
1765+
reviewId: 'review-for-other-submission',
1766+
score: 100,
1767+
scorecardId: 'scorecard-1',
1768+
testPhase: 'system',
1769+
};
1770+
1771+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
1772+
challengeId: basePayload.challengeId,
1773+
name: 'Blocks',
1774+
submissionApiUrl: 'https://api.topcoder-dev.com/v6',
1775+
relativeScoringEnabled: false,
1776+
scoreDirection: ScoreDirection.MAXIMIZE,
1777+
});
1778+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
1779+
1780+
jest
1781+
.spyOn(service as any, 'resolveScorecardId')
1782+
.mockResolvedValue('scorecard-1');
1783+
jest
1784+
.spyOn(service as any, 'findExistingReviewSummations')
1785+
.mockResolvedValue([]);
1786+
jest
1787+
.spyOn(service as any, 'createReviewSummation')
1788+
.mockResolvedValue(undefined);
1789+
1790+
httpService.get.mockReturnValue(
1791+
of({
1792+
data: {
1793+
data: [
1794+
{
1795+
challengeId: basePayload.challengeId,
1796+
id: 'review-for-callback-submission',
1797+
scorecardId: 'scorecard-1',
1798+
status: 'COMPLETED',
1799+
submissionId: basePayload.submissionId,
1800+
},
1801+
],
1802+
},
1803+
}),
1804+
);
1805+
httpService.patch.mockReturnValue(
1806+
of({ data: { id: 'review-for-callback-submission' } }),
1807+
);
1808+
1809+
await expect(service.processScoringResult(systemPayload)).resolves.toBe(
1810+
undefined,
1811+
);
1812+
1813+
expect(httpService.patch).toHaveBeenCalledTimes(1);
1814+
expect(httpService.patch).toHaveBeenCalledWith(
1815+
'https://api.topcoder-dev.com/v6/reviews/review-for-callback-submission',
1816+
expect.objectContaining({
1817+
finalScore: 100,
1818+
status: 'COMPLETED',
1819+
}),
1820+
expect.any(Object),
1821+
);
1822+
expect(httpService.patch).not.toHaveBeenCalledWith(
1823+
'https://api.topcoder-dev.com/v6/reviews/review-for-other-submission',
1824+
expect.any(Object),
1825+
expect.any(Object),
1826+
);
1827+
expect(mockLogger.warn).toHaveBeenCalledWith(
1828+
expect.objectContaining({
1829+
message:
1830+
'Ignoring system reviewId because it does not match the callback submission context.',
1831+
reviewId: 'review-for-other-submission',
1832+
submissionId: basePayload.submissionId,
1833+
}),
1834+
);
1835+
});
1836+
16451837
it('persists system summation before completing the review', async () => {
16461838
const { service, httpService, m2mService, prisma } = createService();
16471839
const systemPayload: ScoringResultCallbackPayload = {
@@ -1673,7 +1865,14 @@ describe('ScoringResultService', () => {
16731865
httpService.get.mockReturnValue(
16741866
of({
16751867
data: {
1676-
data: [],
1868+
data: [
1869+
{
1870+
challengeId: basePayload.challengeId,
1871+
id: 'review-1',
1872+
status: 'PENDING',
1873+
submissionId: basePayload.submissionId,
1874+
},
1875+
],
16771876
},
16781877
}),
16791878
);
@@ -1751,7 +1950,14 @@ describe('ScoringResultService', () => {
17511950
httpService.get.mockReturnValue(
17521951
of({
17531952
data: {
1754-
data: [],
1953+
data: [
1954+
{
1955+
challengeId: basePayload.challengeId,
1956+
id: 'review-1',
1957+
status: 'PENDING',
1958+
submissionId: basePayload.submissionId,
1959+
},
1960+
],
17551961
},
17561962
}),
17571963
);
@@ -1842,7 +2048,14 @@ describe('ScoringResultService', () => {
18422048
.mockReturnValueOnce(
18432049
of({
18442050
data: {
1845-
data: [],
2051+
data: [
2052+
{
2053+
challengeId: basePayload.challengeId,
2054+
id: 'review-1',
2055+
status: 'PENDING',
2056+
submissionId: basePayload.submissionId,
2057+
},
2058+
],
18462059
},
18472060
}),
18482061
);

0 commit comments

Comments
 (0)