Skip to content

Commit 9f4dcc0

Browse files
Keep track of potential first to solves so we can update them when we get 'delayed' results.
Fixes #2281.
1 parent 440cfa2 commit 9f4dcc0

File tree

5 files changed

+272
-12
lines changed

5 files changed

+272
-12
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20231229094359 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return 'Add potential first to solve to scorecache.';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('ALTER TABLE scorecache ADD is_potential_first_to_solve TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Is this potentially the first solution to this problem?\' AFTER is_first_to_solve');
24+
}
25+
26+
public function down(Schema $schema): void
27+
{
28+
// this down() migration is auto-generated, please modify it to your needs
29+
$this->addSql('ALTER TABLE scorecache DROP is_potential_first_to_solve');
30+
}
31+
32+
public function isTransactional(): bool
33+
{
34+
return false;
35+
}
36+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\DataFixtures\Test;
4+
5+
use App\Entity\Team;
6+
use App\Entity\TeamAffiliation;
7+
use App\Entity\TeamCategory;
8+
use Doctrine\Persistence\ObjectManager;
9+
10+
class SampleTeamsFixture extends AbstractTestDataFixture
11+
{
12+
final public const FIRST_TEAM_REFERENCE = 'team1';
13+
final public const SECOND_TEAM_REFERENCE = 'team2';
14+
15+
public function load(ObjectManager $manager): void
16+
{
17+
$affiliation = $manager->getRepository(TeamAffiliation::class)->findOneBy(['externalid' => 'utrecht']);
18+
$category = $manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => 'participants']);
19+
20+
$team1 = new Team();
21+
$team1
22+
->setExternalid('team1')
23+
->setIcpcid('team1')
24+
->setLabel('team1')
25+
->setName('Team 1')
26+
->setAffiliation($affiliation)
27+
->setCategory($category);
28+
29+
$team2 = new Team();
30+
$team2
31+
->setExternalid('team2')
32+
->setIcpcid('team2')
33+
->setLabel('team2')
34+
->setName('Team 2')
35+
->setAffiliation($affiliation)
36+
->setCategory($category);
37+
38+
$manager->persist($team1);
39+
$manager->persist($team2);
40+
$manager->flush();
41+
42+
$this->addReference(self::FIRST_TEAM_REFERENCE, $team1);
43+
$this->addReference(self::SECOND_TEAM_REFERENCE, $team2);
44+
}
45+
}

webapp/src/Entity/ScoreCache.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ class ScoreCache
9494
])]
9595
private bool $is_first_to_solve = false;
9696

97+
#[ORM\Column(options: [
98+
'comment' => 'Is this potentially the first solution to this problem?',
99+
'default' => 0,
100+
])]
101+
private bool $is_potential_first_to_solve = false;
102+
97103
#[ORM\Id]
98104
#[ORM\ManyToOne]
99105
#[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')]
@@ -230,6 +236,17 @@ public function getIsFirstToSolve() : bool
230236
return $this->is_first_to_solve;
231237
}
232238

239+
public function setIsPotentialFirstToSolve(bool $isPotentialFirstToSolve): ScoreCache
240+
{
241+
$this->is_potential_first_to_solve = $isPotentialFirstToSolve;
242+
return $this;
243+
}
244+
245+
public function getIsPotentialFirstToSolve() : bool
246+
{
247+
return $this->is_potential_first_to_solve;
248+
}
249+
233250
public function setContest(?Contest $contest = null): ScoreCache
234251
{
235252
$this->contest = $contest;

webapp/src/Service/ScoreboardService.php

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ public function calculateScoreRow(
254254
Contest $contest,
255255
Team $team,
256256
Problem $problem,
257-
bool $updateRankCache = true
257+
bool $updateRankCache = true,
258+
bool $updatePotentialFirstToSolves = true
258259
): void {
259260
$this->logger->debug(
260261
"ScoreboardService::calculateScoreRow '%d' '%d' '%d'",
@@ -417,6 +418,7 @@ public function calculateScoreRow(
417418
// See if this submission was the first to solve this problem.
418419
// Only relevant if it was correct in the first place.
419420
$firstToSolve = false;
421+
$potentialFirstToSolve = false;
420422
if ($correctJury) {
421423
$params = [
422424
'cid' => $contest->getCid(),
@@ -437,32 +439,49 @@ public function calculateScoreRow(
437439
// - or already judged to be correct (if it is judged but not correct,
438440
// it is not a first to solve)
439441
// - or the submission is still queued for judgement (judgehost is NULL).
440-
$verificationRequiredExtra = $verificationRequired ? 'OR j.verified = 0' : '';
442+
// If there are no valid correct submissions submitted earlier but there
443+
// are submissions awaiting judgement, we are potentially the first to solve.
444+
// We need to keep track of this since we later need to set the actual first to solve.
445+
$verificationRequiredExtra = ($verificationRequired && !$useExternalJudgements) ? 'OR j.verified = 0' : '';
441446
if ($useExternalJudgements) {
442-
$firstToSolve = 0 == $this->em->getConnection()->fetchOne('
447+
$baseQuery = '
443448
SELECT count(*) FROM submission s
444449
LEFT JOIN external_judgement ej USING (submitid)
445450
LEFT JOIN external_judgement ej2 ON ej2.submitid = s.submitid AND ej2.starttime > ej.starttime
446451
LEFT JOIN team t USING(teamid)
447452
LEFT JOIN team_category tc USING (categoryid)
448453
WHERE s.valid = 1 AND
449-
(ej.result IS NULL OR ej.result = :correctResult '.
450-
$verificationRequiredExtra.') AND
451454
s.cid = :cid AND s.probid = :probid AND
452455
tc.sortorder = :teamSortOrder AND
453-
round(s.submittime,4) < :submitTime', $params);
456+
round(s.submittime,4) < :submitTime';
457+
$judgingTable = 'ej';
454458
} else {
455-
$firstToSolve = 0 == $this->em->getConnection()->fetchOne('
459+
$baseQuery = '
456460
SELECT count(*) FROM submission s
457461
LEFT JOIN judging j ON (s.submitid=j.submitid AND j.valid=1)
458462
LEFT JOIN team t USING (teamid)
459463
LEFT JOIN team_category tc USING (categoryid)
460464
WHERE s.valid = 1 AND
461-
(j.judgingid IS NULL OR j.result IS NULL OR j.result = :correctResult '.
462-
$verificationRequiredExtra.') AND
463465
s.cid = :cid AND s.probid = :probid AND
464466
tc.sortorder = :teamSortOrder AND
465-
round(s.submittime,4) < :submitTime', $params);
467+
round(s.submittime,4) < :submitTime';
468+
$judgingTable = 'j';
469+
}
470+
471+
$numEarlierCorrect = $this->em->getConnection()->fetchOne(
472+
"$baseQuery AND $judgingTable.result = :correctResult",
473+
$params);
474+
$numEarlierPending = $this->em->getConnection()->fetchOne(
475+
"$baseQuery AND ($judgingTable.result IS NULL $verificationRequiredExtra)",
476+
$params);
477+
478+
if ($numEarlierCorrect == 0) {
479+
// This is either a first to solve or potential first to solve
480+
if ($numEarlierPending == 0) {
481+
$firstToSolve = true;
482+
} else {
483+
$potentialFirstToSolve = true;
484+
}
466485
}
467486
}
468487

@@ -482,13 +501,14 @@ public function calculateScoreRow(
482501
'runtimePublic' => $runtimePubl === PHP_INT_MAX ? 0 : $runtimePubl,
483502
'isCorrectPublic' => (int)$correctPubl,
484503
'isFirstToSolve' => (int)$firstToSolve,
504+
'isPotentialFirstToSolve' => (int)$potentialFirstToSolve,
485505
];
486506
$this->em->getConnection()->executeQuery('REPLACE INTO scorecache
487507
(cid, teamid, probid,
488508
submissions_restricted, pending_restricted, solvetime_restricted, runtime_restricted, is_correct_restricted,
489-
submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve)
509+
submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve, is_potential_first_to_solve)
490510
VALUES (:cid, :teamid, :probid, :submissionsRestricted, :pendingRestricted, :solvetimeRestricted, :runtimeRestricted, :isCorrectRestricted,
491-
:submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve)', $params);
511+
:submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve, :isPotentialFirstToSolve)', $params);
492512

493513
if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)',
494514
['lock' => $lockString]) != 1) {
@@ -499,6 +519,31 @@ public function calculateScoreRow(
499519
if ($updateRankCache && ($correctJury || $correctPubl)) {
500520
$this->updateRankCache($contest, $team);
501521
}
522+
523+
// If we did not have a first to solve, we need to check if we have any
524+
// potential first to solve that are now the first to solve.
525+
// We only do this if there are no pending submissions for this problem
526+
// for this team and if this submission is not a potential first to solve itself.
527+
// We also do this only once, to not have infinite loops if we have multiple
528+
// potential first to solves.
529+
if ($updatePotentialFirstToSolves && $pendingJury === 0 && !$potentialFirstToSolve) {
530+
/** @var ScoreCache[] $potentialFirstToSolves */
531+
$potentialFirstToSolves = $this->em->createQueryBuilder()
532+
->from(ScoreCache::class, 's')
533+
->join('s.team', 't')
534+
->select('s', 't')
535+
->andWhere('s.contest = :contest')
536+
->andWhere('s.problem = :problem')
537+
->andWhere('s.is_potential_first_to_solve = 1')
538+
->setParameter('contest', $contest)
539+
->setParameter('problem', $problem)
540+
->getQuery()
541+
->getResult();
542+
543+
foreach ($potentialFirstToSolves as $row) {
544+
$this->calculateScoreRow($contest, $row->getTeam(), $problem, $updateRankCache, false);
545+
}
546+
}
502547
}
503548

504549
/**
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Tests\Unit\Service;
4+
5+
use App\DataFixtures\Test\SampleTeamsFixture;
6+
use App\Entity\Contest;
7+
use App\Entity\ContestProblem;
8+
use App\Entity\Judging;
9+
use App\Entity\Language;
10+
use App\Entity\ScoreCache;
11+
use App\Entity\Submission;
12+
use App\Entity\Team;
13+
use App\Service\ScoreboardService;
14+
use App\Tests\Unit\BaseTestCase;
15+
use App\Utils\Utils;
16+
use Doctrine\ORM\EntityManagerInterface;
17+
18+
class ScoreboardServiceTest extends BaseTestCase
19+
{
20+
/**
21+
* Test that a delayed submission result still results in a correct first to solve
22+
*/
23+
public function testFirstToSolveDelayed(): void
24+
{
25+
$em = static::getContainer()->get(EntityManagerInterface::class);
26+
$this->loadFixture(SampleTeamsFixture::class);
27+
$team1 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::FIRST_TEAM_REFERENCE);
28+
$team2 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::SECOND_TEAM_REFERENCE);
29+
$contest = $em->getRepository(Contest::class)->findOneBy(['shortname' => 'demo']);
30+
$contestProblem = $contest->getProblems()->first();
31+
$problem = $contestProblem->getProblem();
32+
33+
$scoreboardService = static::getContainer()->get(ScoreboardService::class);
34+
35+
// Create a submission for both team 1 and team 2.
36+
// The submission for team 1 will be pending, while the submission
37+
// for team 2 will be correct.
38+
$submission1 = $this->createSubmission($em, $team1, $contestProblem, $contest, null);
39+
$submission2 = $this->createSubmission($em, $team2, $contestProblem, $contest, 'correct');
40+
$em->flush();
41+
42+
$scoreboardService->calculateScoreRow($contest, $team1, $problem);
43+
$scoreboardService->calculateScoreRow($contest, $team2, $problem);
44+
45+
/** @var ScoreCache $scoreCacheTeam1 */
46+
$scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([
47+
'contest' => $contest,
48+
'team' => $team1,
49+
'problem' => $problem,
50+
]);
51+
/** @var ScoreCache $scoreCacheTeam2 */
52+
$scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([
53+
'contest' => $contest,
54+
'team' => $team2,
55+
'problem' => $problem,
56+
]);
57+
58+
static::assertFalse($scoreCacheTeam1->getIsFirstToSolve());
59+
static::assertFalse($scoreCacheTeam2->getIsFirstToSolve());
60+
61+
// Now update the submission for team 1 to be wrong
62+
$submission1->getJudgings()->first()->setResult('wrong');
63+
$em->flush();
64+
65+
$scoreboardService->calculateScoreRow($contest, $team1, $problem);
66+
67+
// We need to clear the entity manager to make sure we get the updated score caches.
68+
$em->clear();
69+
70+
/** @var ScoreCache $scoreCacheTeam1 */
71+
$scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([
72+
'contest' => $contest,
73+
'team' => $team1,
74+
'problem' => $problem,
75+
]);
76+
/** @var ScoreCache $scoreCacheTeam2 */
77+
$scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([
78+
'contest' => $contest,
79+
'team' => $team2,
80+
'problem' => $problem,
81+
]);
82+
83+
static::assertFalse($scoreCacheTeam1->getIsFirstToSolve());
84+
static::assertTrue($scoreCacheTeam2->getIsFirstToSolve());
85+
}
86+
87+
protected function createSubmission(
88+
EntityManagerInterface $em,
89+
Team $team,
90+
ContestProblem $problem,
91+
Contest $contest,
92+
?string $result
93+
): Submission {
94+
$submission = new Submission();
95+
$submission
96+
->setTeam($team)
97+
->setProblem($problem->getProblem())
98+
->setContest($contest)
99+
->setContestProblem($problem)
100+
->setLanguage($em->getRepository(Language::class)->findOneBy(['name' => 'C++']))
101+
->setValid(true)
102+
->setSubmittime(Utils::now())
103+
->addJudging(
104+
$judging = (new Judging())
105+
->setValid(true)
106+
->setStarttime(Utils::now())
107+
->setEndtime(Utils::now())
108+
->setSubmission($submission)
109+
->setResult($result)
110+
);
111+
112+
$em->persist($submission);
113+
$em->persist($judging);
114+
115+
return $submission;
116+
}
117+
}

0 commit comments

Comments
 (0)