Skip to content

Commit 2897cb0

Browse files
DOMjudge teammeisterT
authored andcommitted
Fix missing events in the feed due to out of order commits
Events may become available to read by the event-feed API controller out of event_id order because they are (sometimes) created inside a transaction. The old code was only looking for events with ID larger than the last one emitted, which could lead to skipping events. This happened at the WFs in Luxor especially with judging events for a compiler error, as inside a transaction two events are logged and then the scoreboard is recalculated, leaving plenty of time for another event to get logged in the mean time. Fix this by querying for *all* events and making sure that we're not missing any sequential event_ids. If we do wait and retry for one second, and only then skip these. This means that event filtering must now be done in code rather than in the SQL query.
1 parent 984f3ed commit 2897cb0

File tree

1 file changed

+76
-22
lines changed

1 file changed

+76
-22
lines changed

webapp/src/Controller/API/ContestController.php

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Metadata\MetadataFactoryInterface;
2727
use Nelmio\ApiDocBundle\Annotation\Model;
2828
use OpenApi\Attributes as OA;
29+
use Psr\Log\LoggerInterface;
2930
use Symfony\Component\DependencyInjection\Attribute\Autowire;
3031
use Symfony\Component\ExpressionLanguage\Expression;
3132
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
@@ -63,6 +64,7 @@ public function __construct(
6364
ConfigurationService $config,
6465
EventLogService $eventLogService,
6566
protected readonly ImportExportService $importExportService,
67+
protected readonly LoggerInterface $logger,
6668
protected readonly AssetUpdateService $assetUpdater
6769
) {
6870
parent::__construct($entityManager, $dj, $config, $eventLogService);
@@ -651,7 +653,7 @@ public function getEventFeedAction(
651653
$response->headers->set('Content-Type', 'application/x-ndjson');
652654
$response->setCallback(function () use ($format, $cid, $contest, $request, $since_id, $types, $strict, $stream, $metadataFactory, $kernel) {
653655
$lastUpdate = 0;
654-
$lastIdSent = $since_id;
656+
$lastIdSent = max(0, $since_id); // Don't try to look for event_id=0
655657
$typeFilter = false;
656658
if ($types) {
657659
$typeFilter = explode(',', $types);
@@ -714,39 +716,83 @@ public function getEventFeedAction(
714716
// Reload the contest as the above method will clear the entity manager.
715717
$contest = $this->getContestWithId($request, $cid);
716718

719+
$missingEventRetries = 0;
717720
while (true) {
718721
// Add missing state events that should have happened already.
719722
$this->eventLogService->addMissingStateEvents($contest);
720723

721-
$qb = $this->em->createQueryBuilder()
724+
// We fetch *all* events after the last seen to check that
725+
// we don't skip events that are committed out of order.
726+
$q = $this->em->createQueryBuilder()
722727
->from(Event::class, 'e')
723728
->select('e')
724729
->andWhere('e.eventid > :lastIdSent')
725730
->setParameter('lastIdSent', $lastIdSent)
726-
->andWhere('e.contest = :cid')
727-
->setParameter('cid', $contest->getCid())
728-
->orderBy('e.eventid', 'ASC');
729-
730-
if ($typeFilter !== false) {
731-
$qb = $qb
732-
->andWhere('e.endpointtype IN (:types)')
733-
->setParameter('types', $typeFilter);
734-
}
735-
if (!$canViewAll) {
736-
$restricted_types = ['judgements', 'runs', 'clarifications'];
737-
if ($contest->getStarttime() === null || Utils::now() < $contest->getStarttime()) {
738-
$restricted_types[] = 'problems';
731+
->orderBy('e.eventid', 'ASC')
732+
->getQuery();
733+
734+
/** @var Event[] $events */
735+
$events = $q->getResult();
736+
737+
// Look for any missing sequential events and wait for them to
738+
// be committed if so.
739+
$missingEvents = false;
740+
$expectedId = $lastIdSent + 1;
741+
$lastFoundId = null;
742+
foreach ($events as $event) {
743+
if ($event->getEventid() !== $expectedId) {
744+
$missingEvents = true;
745+
$lastFoundId = $event->getEventid();
746+
break;
739747
}
740-
$qb = $qb
741-
->andWhere('e.endpointtype NOT IN (:restricted_types)')
742-
->setParameter('restricted_types', $restricted_types);
748+
$expectedId++;
743749
}
750+
if ($missingEvents) {
751+
if ($missingEventRetries == 0) {
752+
$this->logger->info(
753+
'Detected missing events %d ... %d, waiting for these to appear',
754+
[$expectedId, $lastFoundId-1]
755+
);
756+
}
757+
if (++$missingEventRetries < 10) {
758+
usleep(100 * 1000);
759+
continue;
760+
}
744761

745-
$q = $qb->getQuery();
762+
// We've decided to permanently ignore these non-existing
763+
// events for this connection. The wait for any
764+
// non-committed events was long enough.
765+
//
766+
// There might be multiple non-existing events. Log the
767+
// first consecutive gap of non-existing events. A consecutive
768+
// gap is guaranteed since the events are ordered.
769+
$this->logger->warning(
770+
'Waited too long for missing events %d ... %d, skipping',
771+
[$expectedId, $lastFoundId-1]
772+
);
773+
}
774+
$missingEventRetries = 0;
746775

747-
/** @var Event[] $events */
748-
$events = $q->getResult();
776+
$numEventsSent = 0;
749777
foreach ($events as $event) {
778+
// Filter out unwanted events
779+
if ($event->getContest()->getCid() !== $contest->getCid()) {
780+
continue;
781+
}
782+
if ($typeFilter !== false &&
783+
!in_array($event->getEndpointtype(), $typeFilter)) {
784+
continue;
785+
}
786+
if (!$canViewAll) {
787+
$restricted_types = ['judgements', 'runs', 'clarifications'];
788+
if ($contest->getStarttime() === null || Utils::now() < $contest->getStarttime()) {
789+
$restricted_types[] = 'problems';
790+
}
791+
if (in_array($event->getEndpointtype(), $restricted_types)) {
792+
continue;
793+
}
794+
}
795+
750796
$data = $event->getContent();
751797
// Filter fields with specific access restrictions.
752798
if (!$canViewAll) {
@@ -814,9 +860,17 @@ public function getEventFeedAction(
814860
flush();
815861
$lastUpdate = Utils::now();
816862
$lastIdSent = $event->getEventid();
863+
$numEventsSent++;
864+
865+
if ($missingEvents && $event->getEventid() >= $lastFoundId) {
866+
// The first event after the first gap has been emitted. Stop
867+
// emitting events and restart the gap detection logic to find
868+
// any potential gaps after this last emitted event.
869+
break;
870+
}
817871
}
818872

819-
if (count($events) == 0) {
873+
if ($numEventsSent == 0) {
820874
if (!$stream) {
821875
break;
822876
}

0 commit comments

Comments
 (0)