fix(versions): persist in-progress update state in Redis (survives reload)

0.9.12 derived the running state from the sentinel file, which the host
watcher consumes BEFORE update.sh runs — so a reload mid-update showed nothing.
Store an 'update in progress' marker (with the base version) in the cache
(Redis in prod) instead: it survives both the reload and the app rebuild. On
load, mount() resumes the 'running' notice + polling, or — if the running
version has already moved past the base — announces completion and clears the
marker. 15-minute TTL backstop so a failed update can't wedge the banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.13
boban 2026-06-19 19:18:34 +02:00
parent 8b0db1b7ef
commit 6b5bc6938c
4 changed files with 71 additions and 10 deletions

View File

@ -13,6 +13,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.13] - 2026-06-19
### Behoben
- **Update-Status übersteht jetzt einen Reload (in Redis statt Sentinel gespeichert).** In 0.9.12
wurde der laufende Zustand aus der Sentinel-Datei abgeleitet — die der Host-Watcher aber **vor**
dem Update verbraucht, sodass ein Reload mitten im Update nichts mehr anzeigte. Der „läuft"-Status
wird jetzt als Redis-Marker (mit der Ausgangsversion) persistiert: er übersteht sowohl den Reload
als auch den Container-Neustart. Nach einem Reload wird entweder „Update läuft" wieder aufgenommen
(inkl. Polling) oder — falls das Update inzwischen durch ist — direkt „Aktuell — vX" mit
Erfolgsmeldung angezeigt und der Marker gelöscht (15-Min-Backstop-TTL, damit nichts hängen bleibt).
## [0.9.12] - 2026-06-19 ## [0.9.12] - 2026-06-19
### Hinzugefügt ### Hinzugefügt

View File

@ -27,6 +27,15 @@ class Index extends Component
/** Only these channels are ever offered to users — there is no 'dev' channel. */ /** Only these channels are ever offered to users — there is no 'dev' channel. */
private const CHANNELS = ['stable', 'beta']; private const CHANNELS = ['stable', 'beta'];
/**
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
* when the update was requested. Persisted in Redis NOT the sentinel file (which the host
* watcher consumes before update.sh runs) so the "update läuft" state survives BOTH a page
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
* backstop so a failed update can never wedge the banner. Completion = running version > base.
*/
private const UPDATE_FLAG = 'clusev:update-in-progress';
public ?string $lastChecked = null; public ?string $lastChecked = null;
public ?string $updateStatus = null; public ?string $updateStatus = null;
@ -44,19 +53,38 @@ class Index extends Component
public int $updatePolls = 0; public int $updatePolls = 0;
/** /**
* On load: resume the "running" state if an update sentinel is still pending (survives a * On load, in order:
* reload mid-update), and without any network call surface an available update from the * 1. If an update is in flight (Redis marker): either announce it just finished (running
* cached check so the button shows immediately (the badge already knew). No re-click needed. * version now past the marked base success toast + clear) or resume the "läuft" state +
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
* Redis, surviving both the reload and the app rebuild).
* 2. Otherwise surface an available update straight from the cached check (no network), so the
* button shows immediately when the badge already knows a newer version no re-click.
*/ */
public function mount(DeploymentService $deployment): void public function mount(): void
{ {
if ($deployment->updateRequested()) { $installed = (string) config('clusev.version');
$marker = Cache::get(self::UPDATE_FLAG);
if (is_array($marker) && isset($marker['base'])) {
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
// Finished while the operator was away (or reloaded) — announce + clear.
Cache::forget(self::UPDATE_FLAG);
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
return;
}
// Still running — resume the notice + polling.
$this->updateRequested = true; $this->updateRequested = true;
$this->updateBaseVersion = (string) config('clusev.version'); $this->updateBaseVersion = (string) $marker['base'];
return;
} }
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load $latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
if (! $this->updateRequested && $latest !== null && $this->isNewer($latest, (string) config('clusev.version'))) { if ($latest !== null && $this->isNewer($latest, $installed)) {
$this->updateState = 'update'; $this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]); $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
} }
@ -78,6 +106,7 @@ class Index extends Component
$this->updateBaseVersion ??= $current; $this->updateBaseVersion ??= $current;
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) { if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false; $this->updateRequested = false;
$this->updatePolls = 0; $this->updatePolls = 0;
$this->updateState = 'current'; $this->updateState = 'current';
@ -88,6 +117,7 @@ class Index extends Component
} }
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning. if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false; $this->updateRequested = false;
$this->updatePolls = 0; $this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error'); $this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
@ -154,6 +184,10 @@ class Index extends Component
RateLimiter::hit($key, 600); RateLimiter::hit($key, 600);
$deployment->requestUpdate(); $deployment->requestUpdate();
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
AuditEvent::create([ AuditEvent::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),

View File

@ -2,7 +2,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). // First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.12', 'version' => '0.9.13',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in // image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -148,13 +148,29 @@ class VersionUpdateCheckTest extends TestCase
->assertSee('0.9.11'); ->assertSee('0.9.11');
} }
public function test_a_pending_update_sentinel_resumes_the_running_state_on_load(): void public function test_an_in_progress_marker_resumes_the_running_state_on_load(): void
{ {
app(DeploymentService::class)->requestUpdate(); // sentinel present (update in flight) // Redis marker (not the consumed sentinel) is what survives a reload mid-update.
config()->set('clusev.version', '0.9.11');
Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15));
Livewire::test(Index::class)->assertSet('updateRequested', true); Livewire::test(Index::class)->assertSet('updateRequested', true);
} }
public function test_a_finished_update_is_announced_and_cleared_on_load(): void
{
// Reloaded after the rebuild: the running version is now past the marked base → done.
config()->set('clusev.version', '0.9.12');
Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15));
Livewire::test(Index::class)
->assertSet('updateRequested', false)
->assertSet('updateState', 'current')
->assertSee('0.9.12');
$this->assertNull(Cache::get('clusev:update-in-progress'), 'marker cleared once the update landed');
}
public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void
{ {
config()->set('clusev.version', '0.9.10'); config()->set('clusev.version', '0.9.10');