Skip to content

Commit e8662ba

Browse files
committed
Create judge tasks for a judging only once.
unblockJudgeTasks() checks for judgings without judge tasks and creates them, but two concurrent callers both pass the check. The second set fails on judging_run's unique key with the "1062 Duplicate entry" from #2848 and leaves judge tasks behind that no judging run points at. Lock the judging and re-check inside.
1 parent dc9602b commit e8662ba

2 files changed

Lines changed: 111 additions & 11 deletions

File tree

webapp/src/Service/DOMJudgeService.php

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
6363
use Symfony\Component\Security\Core\User\UserInterface;
6464
use Symfony\Contracts\Cache\ItemInterface;
65+
use Throwable;
6566
use Twig\Attribute\AsTwigFilter;
6667
use Twig\Attribute\AsTwigFunction;
6768
use Twig\Environment;
@@ -1798,15 +1799,42 @@ private function actuallyCreateJudgetasks(int $priority, Judging $judging, int $
17981799
$judgetaskInsertParamsWithoutColon[$key] = $param;
17991800
}
18001801

1801-
$this->em->getConnection()->executeQuery($judgetaskInsertQuery, $judgetaskInsertParamsWithoutColon);
1802+
// Create the judge tasks exactly once: two concurrent unblockJudgeTasks() calls both
1803+
// see a judging without them, and a second set fails on judging_run's unique key. Lock
1804+
// the judging and re-check. The raw transaction API, because wrapInTransaction()
1805+
// flushes, which createRejudging() avoids for speed.
1806+
$connection = $this->em->getConnection();
1807+
$connection->beginTransaction();
1808+
try {
1809+
$connection->executeQuery(
1810+
'SELECT judgingid FROM judging WHERE judgingid = :judgingid FOR UPDATE',
1811+
['judgingid' => $judging->getJudgingid()]
1812+
);
18021813

1803-
// Step 3: Insert the corresponding judging runs.
1804-
$this->em->getConnection()->executeQuery(
1805-
'INSERT INTO judging_run (judgingid, judgetaskid, testcaseid)
1806-
SELECT :judgingid, judgetaskid, testcase_id FROM judgetask
1807-
WHERE jobid = :judgingid ORDER BY judgetaskid',
1808-
['judgingid' => $judging->getJudgingid()]
1809-
);
1814+
$alreadyCreated = (int)$connection->fetchOne(
1815+
'SELECT COUNT(*) FROM judgetask WHERE jobid = :judgingid',
1816+
['judgingid' => $judging->getJudgingid()]
1817+
);
1818+
if ($alreadyCreated > 0) {
1819+
$connection->commit();
1820+
return;
1821+
}
1822+
1823+
$connection->executeQuery($judgetaskInsertQuery, $judgetaskInsertParamsWithoutColon);
1824+
1825+
// Step 3: Insert the corresponding judging runs.
1826+
$connection->executeQuery(
1827+
'INSERT INTO judging_run (judgingid, judgetaskid, testcaseid)
1828+
SELECT :judgingid, judgetaskid, testcase_id FROM judgetask
1829+
WHERE jobid = :judgingid ORDER BY judgetaskid',
1830+
['judgingid' => $judging->getJudgingid()]
1831+
);
1832+
1833+
$connection->commit();
1834+
} catch (Throwable $e) {
1835+
$connection->rollBack();
1836+
throw $e;
1837+
}
18101838
}
18111839

18121840
public function shadowMode(): bool

webapp/tests/Unit/Service/DOMJudgeServiceTest.php

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@
22

33
namespace App\Tests\Unit\Service;
44

5+
use App\Entity\Contest;
6+
use App\Entity\JudgeTask;
7+
use App\Entity\Judging;
8+
use App\Entity\JudgingRun;
9+
use App\Entity\Problem;
10+
use App\Entity\Submission;
11+
use App\Entity\SubmissionSource;
12+
use App\Entity\Team;
513
use App\Service\DOMJudgeService;
6-
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
14+
use App\Service\SubmissionService;
15+
use App\Tests\Unit\BaseTestCase;
16+
use Doctrine\ORM\EntityManagerInterface;
717
use Symfony\Component\Filesystem\Filesystem;
18+
use Symfony\Component\HttpFoundation\File\UploadedFile;
819

9-
class DOMJudgeServiceTest extends KernelTestCase
20+
class DOMJudgeServiceTest extends BaseTestCase
1021
{
1122
private const ASSET_PATH = 'test-assets';
1223

@@ -15,7 +26,7 @@ class DOMJudgeServiceTest extends KernelTestCase
1526

1627
protected function setUp(): void
1728
{
18-
self::bootKernel();
29+
parent::setUp();
1930
$this->dj = static::getContainer()->get(DOMJudgeService::class);
2031
$this->assetDir = static::getContainer()->getParameter('kernel.project_dir') . '/public/' . self::ASSET_PATH;
2132
(new Filesystem())->mkdir($this->assetDir);
@@ -36,4 +47,65 @@ public function testGetAssetFilesMatchesOnTheExtensionOnly(): void
3647
self::assertSame(['also-ok.css', 'ok.css'], $this->dj->getAssetFiles(self::ASSET_PATH, ['css']));
3748
self::assertSame(['image.png', 'script.js'], $this->dj->getAssetFiles(self::ASSET_PATH, ['png', 'js']));
3849
}
50+
51+
private function em(): EntityManagerInterface
52+
{
53+
return static::getContainer()->get(EntityManagerInterface::class);
54+
}
55+
56+
private function addSubmission(): Submission
57+
{
58+
$em = $this->em();
59+
60+
$message = null;
61+
return static::getContainer()->get(SubmissionService::class)->submitSolution(
62+
$em->getRepository(Team::class)->findOneBy(['name' => 'DOMjudge']),
63+
null,
64+
$em->getRepository(Problem::class)->findOneBy(['externalid' => 'hello']),
65+
$em->getRepository(Contest::class)->findOneBy(['shortname' => 'demo']),
66+
'c',
67+
[new UploadedFile(__FILE__, 'foo.c', null, null, true)],
68+
SubmissionSource::UNKNOWN, null, null, null, null, null, $message
69+
);
70+
}
71+
72+
/**
73+
* Judge tasks are created for a judging exactly once.
74+
*
75+
* unblockJudgeTasks() looks for judgings that have no judge tasks yet, so two callers
76+
* running at the same time - enabling a language and a problem, say - would both pass that
77+
* check. Creating a second set would violate the unique key on judging_run and leave judge
78+
* tasks behind that no judging run points at, which judgehosts then choke on.
79+
*/
80+
public function testJudgeTasksAreCreatedOnlyOncePerJudging(): void
81+
{
82+
$this->logIn();
83+
84+
$submission = $this->addSubmission();
85+
$judgingId = $submission->getJudgings()->first()->getJudgingid();
86+
87+
$em = $this->em();
88+
$judgeTasksAfterFirst = (int)$em->getConnection()->fetchOne(
89+
'SELECT COUNT(*) FROM judgetask WHERE jobid = ?',
90+
[$judgingId]
91+
);
92+
self::assertGreaterThan(0, $judgeTasksAfterFirst, 'submitting must create judge tasks');
93+
94+
// A second attempt for the same judging must be a no-op rather than an error.
95+
$judging = $em->getRepository(Judging::class)->find($judgingId);
96+
$this->dj->maybeCreateJudgeTasks($judging);
97+
98+
self::assertSame(
99+
$judgeTasksAfterFirst,
100+
(int)$em->getConnection()->fetchOne('SELECT COUNT(*) FROM judgetask WHERE jobid = ?', [$judgingId]),
101+
'no extra judge tasks may be created'
102+
);
103+
104+
// Every judge task still has exactly one judging run, which is what judgehosts rely on.
105+
$tasks = $em->getRepository(JudgeTask::class)->findBy(['jobid' => $judgingId]);
106+
foreach ($tasks as $task) {
107+
$runs = $em->getRepository(JudgingRun::class)->findBy(['judgetaskid' => $task->getJudgetaskid()]);
108+
self::assertCount(1, $runs, 'each judge task needs exactly one judging run');
109+
}
110+
}
39111
}

0 commit comments

Comments
 (0)