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
parent
8b0db1b7ef
commit
6b5bc6938c
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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._
|
||||
|
||||
## [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
|
||||
|
||||
### Hinzugefügt
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ class Index extends Component
|
|||
/** Only these channels are ever offered to users — there is no 'dev' channel. */
|
||||
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 $updateStatus = null;
|
||||
|
|
@ -44,19 +53,38 @@ class Index extends Component
|
|||
public int $updatePolls = 0;
|
||||
|
||||
/**
|
||||
* On load: resume the "running" state if an update sentinel is still pending (survives a
|
||||
* reload mid-update), and — without any network call — surface an available update from the
|
||||
* cached check so the button shows immediately (the badge already knew). No re-click needed.
|
||||
* On load, in order:
|
||||
* 1. If an update is in flight (Redis marker): either announce it just finished (running
|
||||
* 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->updateBaseVersion = (string) config('clusev.version');
|
||||
$this->updateBaseVersion = (string) $marker['base'];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
|
||||
}
|
||||
|
|
@ -78,6 +106,7 @@ class Index extends Component
|
|||
$this->updateBaseVersion ??= $current;
|
||||
|
||||
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
|
||||
Cache::forget(self::UPDATE_FLAG);
|
||||
$this->updateRequested = false;
|
||||
$this->updatePolls = 0;
|
||||
$this->updateState = 'current';
|
||||
|
|
@ -88,6 +117,7 @@ class Index extends Component
|
|||
}
|
||||
|
||||
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
|
||||
Cache::forget(self::UPDATE_FLAG);
|
||||
$this->updateRequested = false;
|
||||
$this->updatePolls = 0;
|
||||
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
|
||||
|
|
@ -154,6 +184,10 @@ class Index extends Component
|
|||
RateLimiter::hit($key, 600);
|
||||
|
||||
$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([
|
||||
'user_id' => Auth::id(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
// 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
|
||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||
|
|
|
|||
|
|
@ -148,13 +148,29 @@ class VersionUpdateCheckTest extends TestCase
|
|||
->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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
config()->set('clusev.version', '0.9.10');
|
||||
|
|
|
|||
Loading…
Reference in New Issue