$id, 'tipo' => $tipo, 'status' => 'queued', 'fase' => $fase, 'total' => $total, 'processed' => 0, 'logs' => [], 'result' => [], 'error' => null, 'started_at' => null, 'finished_at' => null, ]; $this->put($id, $state); Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE); return $state; } /** * Id dell'operazione in corso, o null se non ce n'e' nessuna. * * Serve a due cose: rifiutare l'avvio di una seconda operazione, e * riagganciare la barra di avanzamento quando la pagina viene ricaricata — * senza, un azzeramento fermo in attesa di conferma resterebbe irraggiungibile * e nessuno potrebbe piu' confermarlo o annullarlo. */ public function corrente(): ?string { $id = Cache::get(self::CHIAVE_CORRENTE); if (! is_string($id) || $id === '') { return null; } $stato = $this->get($id); // Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto // e' libero. Il puntatore pero' qui non si tocca: corrente() la // chiamano anche le pagine che si aggiornano, fuori dal mutex di // occupa(), e cancellarlo poteva togliere quello che un'altra // richiesta aveva appena scritto per un'operazione nuova. Lo // sovrascrive initialize() al prossimo avvio. if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) { return null; } return $id; } /** * Prende il posto unico se e' libero, in un passo solo. * * Restituisce null se il posto e' stato preso (e l'operazione inizializzata * come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa. * Tutte le porte d'ingresso passano da qui proprio per il mutex: un * controllo con corrente() e un initialize() separati lasciano aperta la * finestra fra i due. * * $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a * meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il * posto resterebbe occupato per l'ora intera del TTL ordinario. */ public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string { try { return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) { $inCorso = $this->corrente(); if ($inCorso !== null) { return $inCorso; } $this->initialize($id, $tipo, $fase); if ($ttlPosto !== null) { Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto); } return null; }); } catch (LockTimeoutException) { // Qualcun altro sta prendendo il posto proprio adesso: il mutex // copre pochi millisecondi, quindi cinque secondi d'attesa vogliono // dire che il posto non e' disponibile, non che va forzato. return $this->corrente() ?? 'avvio-in-corso'; } } /** * Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo * blocca, perche' l'operazione in corso puo' essere stata avviata da un * altro amministratore in un'altra zona della pagina. */ public function messaggioOccupato(string $idInCorso): string { $tipo = $this->get($idInCorso)['tipo'] ?? null; $cosa = match ($tipo) { self::TIPO_BACKUP => ' (un backup)', self::TIPO_RESTORE => ' (un ripristino)', self::TIPO_RESET => ' (un azzeramento)', self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)', self::TIPO_IMPORT_XML => ' (un import XML)', default => '', }; return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.'; } public function get(string $id): ?array { return Cache::get($this->key($id)); } public function put(string $id, array $state): void { Cache::put($this->key($id), $state, $this->ttlSeconds); } public function forget(string $id): void { Cache::forget($this->key($id)); } public function update(string $id, callable $callback): ?array { $state = $this->get($id); if (! $state) { return null; } $state = $callback($state) ?? $state; $this->put($id, $state); return $state; } public function log(string $id, string $message): void { $this->update($id, function (array $state) use ($message) { $state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message; $state['logs'] = array_slice($state['logs'], -self::MAX_LOG); return $state; }); } /** * Cambia fase e, insieme, il totale di riferimento: ogni fase ha una sua * unita' di misura (file da comprimere, file da estrarre, passi di * cancellazione) e la barra riparte da zero su quel totale. */ public function fase(string $id, string $fase, int $total = 0): void { $this->update($id, function (array $state) use ($fase, $total) { $state['fase'] = $fase; $state['total'] = $total; $state['processed'] = 0; return $state; }); $this->log($id, $fase . ($total > 0 ? ' (' . $total . ')' : '')); } public function avanza(string $id, int $processed, ?int $total = null): void { $this->update($id, function (array $state) use ($processed, $total) { $state['processed'] = $processed; if ($total !== null) { $state['total'] = $total; } return $state; }); } public function markRunning(string $id): void { $this->update($id, function (array $state) { $state['status'] = 'running'; $state['started_at'] ??= now()->toDateTimeString(); return $state; }); } public function markCompleted(string $id, array $result = []): void { $this->update($id, function (array $state) use ($result) { $state['status'] = 'completed'; $state['processed'] = max($state['processed'], $state['total']); $state['result'] = $result + $state['result']; $state['finished_at'] = now()->toDateTimeString(); return $state; }); $this->liberaCorrente($id); } public function markFailed(string $id, string $error): void { $this->update($id, function (array $state) use ($error) { $state['status'] = 'failed'; $state['error'] = $error; $state['finished_at'] = now()->toDateTimeString(); return $state; }); $this->log($id, 'ERRORE: ' . $error); $this->liberaCorrente($id); } /** * Libera il posto, ma solo se e' ancora questa operazione a occuparlo: * senza il confronto, la chiusura di un'operazione vecchia sbloccherebbe * quella appena avviata da qualcun altro. */ private function liberaCorrente(string $id): void { // Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi // un'altra richiesta poteva prendere il posto, e il forget le toglieva // il puntatore. Se il mutex non arriva si lascia stare: il puntatore a // un'operazione chiusa vale già come posto libero (vedi corrente()). try { Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) { if (Cache::get(self::CHIAVE_CORRENTE) === $id) { Cache::forget(self::CHIAVE_CORRENTE); } }); } catch (LockTimeoutException) { } } /** * L'archivio e' pronto ma non si prosegue: l'azzeramento parte solo quando * il download risulta consegnato per intero. */ public function markAttesaDownload(string $id, array $result): void { $this->update($id, function (array $state) use ($result) { $state['status'] = 'running'; $state['fase'] = self::FASE_ATTESA_DOWNLOAD; $state['processed'] = $state['total']; $state['result'] = $result + $state['result']; return $state; }); } protected function key(string $id): string { return 'backup-op:' . $id; } }