Merge pull request 'Fehlgeschlagenen Codeberg-Sync zeitnah wiederholen statt bis zum naechsten Intervall zu warten' (#25) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/25
This commit is contained in:
fds2610 2026-07-29 21:31:53 +02:00
commit 523e8a4dcf
4 changed files with 135 additions and 2 deletions

12
API.md
View file

@ -295,6 +295,18 @@ Beim manuellen Aufruf werden Fehler direkt auf der Konsole sichtbar (`Codeberg-A
erreichbar: …` bzw. `Codeberg-API-Fehler (HTTP 4xx)`) - der erste Anlaufpunkt, wenn Backlog
und Done leer bleiben.
Schlaegt ein Lauf fehl (vereinzelte Timeouts nach codeberg.org kommen vor), wird der
naechste Versuch **vorgezogen** statt bis zum regulaeren Intervall zu warten: 5s, 10s, 20s,
… bis hoechstens `MONITOR_SYNC_INTERVAL` (`Sync\SyncSchedule`). Sonst ginge eine per Webhook
angeforderte Aktualisierung verloren - die Anforderung wird vor dem Lauf quittiert und
waere damit erst nach vollen `MONITOR_SYNC_INTERVAL` Sekunden nachgeholt. Nach einem
erfolgreichen Lauf gilt sofort wieder das regulaere Intervall. Im Log sieht das so aus:
```
[sync] Lauf fehlgeschlagen (1x in Folge, naechster Versuch in 5s): Codeberg-API nicht erreichbar: …
[sync] Lauf fehlgeschlagen (2x in Folge, naechster Versuch in 10s): …
```
### `POST /api/v1/webhooks/codeberg` — Sofort-Sync per Webhook
Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen,

View file

@ -8,6 +8,7 @@ use Monitor\Api\Db\Database;
use Monitor\Api\Repository\TicketRepository;
use Monitor\Api\Sync\CodebergSyncService;
use Monitor\Api\Sync\CurlCodebergClient;
use Monitor\Api\Sync\SyncSchedule;
use Monitor\Api\Sync\SyncTrigger;
require dirname(__DIR__) . '/vendor/autoload.php';
@ -32,23 +33,35 @@ if (!in_array('--watch', $argv, true)) {
$trigger = new SyncTrigger($config->syncTriggerPath());
$interval = $config->syncIntervalSeconds();
$schedule = new SyncSchedule($interval);
fwrite(STDOUT, sprintf("[sync] Watch-Modus, periodischer Lauf alle %ds, Webhook-Trigger sofort.\n", $interval));
$lastRun = 0;
$failures = 0;
while (true) {
$triggered = $trigger->consume();
$due = (time() - $lastRun) >= $interval;
$due = (time() - $lastRun) >= $schedule->secondsUntilNextRun($failures);
if ($triggered || $due) {
$lastRun = time();
try {
$service->sync();
$failures = 0;
fwrite(STDOUT, sprintf("[sync] Lauf beendet (%s).\n", $triggered ? 'Webhook' : 'periodisch'));
} catch (Throwable $e) {
// Ein fehlgeschlagener Lauf (Codeberg nicht erreichbar, Rate-Limit) darf die
// Schleife nicht beenden, sonst landet der Container in einer Restart-Schleife.
fwrite(STDERR, sprintf("[sync] Lauf fehlgeschlagen: %s\n", $e->getMessage()));
// Stattdessen wird der naechste Versuch vorgezogen (siehe SyncSchedule) - eine
// per Webhook angeforderte Aktualisierung ist sonst verloren, weil die
// Anforderung vor dem Lauf quittiert wurde.
$failures++;
fwrite(STDERR, sprintf(
"[sync] Lauf fehlgeschlagen (%dx in Folge, naechster Versuch in %ds): %s\n",
$failures,
$schedule->secondsUntilNextRun($failures),
$e->getMessage()
));
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Sync;
/**
* Bestimmt, wie lange der Sync-Dauerlaeufer (bin/sync-codeberg.php --watch) bis zum
* naechsten Lauf wartet.
*
* Nach einem erfolgreichen Lauf ist das schlicht MONITOR_SYNC_INTERVAL. Nach einem
* Fehlschlag wird der naechste Versuch dagegen deutlich vorgezogen: Ein per Webhook
* angeforderter Sync ist zu diesem Zeitpunkt bereits quittiert (SyncTrigger::consume()
* loescht die Anforderung vor dem Lauf), er wuerde sonst also erst nach vollen 600s
* nachgeholt - genau die Verzoegerung, die der Webhook abschaffen soll.
*
* Der Abstand waechst exponentiell (5s, 10s, 20s, …), gedeckelt auf das regulaere
* Intervall. Damit erholt sich ein kurzer Aussetzer (beobachtet: vereinzelte
* curl-Timeouts nach 15s ohne Antwort) binnen Sekunden, waehrend eine laengere
* Codeberg-Stoerung nicht in einen Dauerbeschuss laeuft.
*/
final class SyncSchedule
{
/** Ab hier waere 2^$failures groesser als jedes sinnvolle Intervall - schuetzt vor Overflow. */
private const MAX_EXPONENT = 20;
public function __construct(
private readonly int $intervalSeconds,
private readonly int $retryBaseSeconds = 5,
) {
}
public function secondsUntilNextRun(int $consecutiveFailures): int
{
if ($consecutiveFailures < 1) {
return $this->intervalSeconds;
}
$exponent = min($consecutiveFailures - 1, self::MAX_EXPONENT);
return min($this->intervalSeconds, $this->retryBaseSeconds * (2 ** $exponent));
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Sync;
use Monitor\Api\Sync\SyncSchedule;
use PHPUnit\Framework\TestCase;
final class SyncScheduleTest extends TestCase
{
public function testUsesTheRegularIntervalWhileEverythingWorks(): void
{
$schedule = new SyncSchedule(600);
self::assertSame(600, $schedule->secondsUntilNextRun(0));
}
/**
* Der Kern des Fixes: Nach einem Fehlschlag darf nicht bis zum naechsten regulaeren
* Lauf gewartet werden - die per Webhook angeforderte Aktualisierung waere sonst bis
* zu MONITOR_SYNC_INTERVAL Sekunden verzoegert.
*/
public function testRetriesQuicklyAfterAFailure(): void
{
$schedule = new SyncSchedule(600, retryBaseSeconds: 5);
self::assertSame(5, $schedule->secondsUntilNextRun(1));
}
public function testBacksOffExponentiallyWhileFailuresContinue(): void
{
$schedule = new SyncSchedule(600, retryBaseSeconds: 5);
self::assertSame(10, $schedule->secondsUntilNextRun(2));
self::assertSame(20, $schedule->secondsUntilNextRun(3));
self::assertSame(40, $schedule->secondsUntilNextRun(4));
}
/** Eine laengere Codeberg-Stoerung darf nicht in einen Dauerbeschuss laufen. */
public function testNeverWaitsLongerThanTheRegularInterval(): void
{
$schedule = new SyncSchedule(600, retryBaseSeconds: 5);
self::assertSame(600, $schedule->secondsUntilNextRun(8));
self::assertSame(600, $schedule->secondsUntilNextRun(50));
}
/** Sehr viele Fehlschlaege duerfen 2^n nicht ueberlaufen lassen. */
public function testSurvivesAnAbsurdNumberOfFailures(): void
{
$schedule = new SyncSchedule(600, retryBaseSeconds: 5);
self::assertSame(600, $schedule->secondsUntilNextRun(PHP_INT_MAX));
}
/** Bei sehr kurzem Intervall bleibt dieses die Untergrenze, nicht die Retry-Basis. */
public function testShortIntervalIsNotStretchedByTheRetryBase(): void
{
$schedule = new SyncSchedule(3, retryBaseSeconds: 5);
self::assertSame(3, $schedule->secondsUntilNextRun(0));
self::assertSame(3, $schedule->secondsUntilNextRun(1));
}
}