++ consolidamento funzionalità Backup
This commit is contained in:
@@ -92,10 +92,12 @@ class BackupArchive
|
||||
'files_count' => $totale,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
if (! $zip->close()) {
|
||||
try {
|
||||
$this->chiudi($zip);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($totale === 0) {
|
||||
@@ -116,7 +118,19 @@ class BackupArchive
|
||||
foreach ($files as $relativePath) {
|
||||
// addFile() legge dal disco durante close(): i PDF non
|
||||
// passano mai interi per la memoria del processo.
|
||||
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
|
||||
//
|
||||
// Il valore di ritorno si controlla: se il file e' sparito
|
||||
// fra allFiles() e qui — cancellato da un'altra richiesta,
|
||||
// o illeggibile per i permessi — addFile() torna false
|
||||
// senza sollevare, e l'archivio finirebbe con un buco di
|
||||
// cui nessuno si accorge fino al giorno del ripristino.
|
||||
if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
||||
throw new RuntimeException(
|
||||
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
||||
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
||||
);
|
||||
}
|
||||
|
||||
$nelLotto++;
|
||||
$fatti++;
|
||||
|
||||
@@ -133,11 +147,62 @@ class BackupArchive
|
||||
throw $e;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->verificaArchivio($destinazione, $totale);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$onProgress && $onProgress($totale, $totale);
|
||||
|
||||
return $destinazione;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rilegge l'archivio appena scritto e controlla che contenga quello che
|
||||
* dichiara.
|
||||
*
|
||||
* Un backup e' l'unico file di cui non ci si accorge che era rotto finche'
|
||||
* non serve. Costa una riapertura e la lettura della directory centrale —
|
||||
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
||||
* troncamenti, chiusure a meta' e lotti persi.
|
||||
*/
|
||||
private function verificaArchivio(string $path, int $fileAttesi): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($path, ZipArchive::RDONLY) !== true) {
|
||||
throw new RuntimeException('Il backup appena creato non e\' rileggibile.');
|
||||
}
|
||||
|
||||
$trovati = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
||||
$trovati++;
|
||||
}
|
||||
}
|
||||
|
||||
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
||||
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
||||
|
||||
$zip->close();
|
||||
|
||||
if (! $haDati || ! $haManifest) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
||||
}
|
||||
|
||||
if ($trovati !== $fileAttesi) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
||||
$trovati,
|
||||
$fileAttesi
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
|
||||
* chiusura anche se il callback solleva.
|
||||
@@ -152,14 +217,21 @@ class BackupArchive
|
||||
try {
|
||||
$callback($zip);
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
// La chiusura di cortesia non deve poter sostituire l'errore vero:
|
||||
// dopo un close() fallito l'oggetto ZipArchive resta inutilizzabile
|
||||
// e ogni metodo successivo solleva "Invalid or uninitialized Zip
|
||||
// object", che finirebbe sotto gli occhi dell'amministratore al
|
||||
// posto del messaggio che spiega davvero cos'e' andato storto.
|
||||
try {
|
||||
$zip->close();
|
||||
} catch (\Throwable) {
|
||||
// gia' chiuso o invalido: non c'e' niente da salvare
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (! $zip->close()) {
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
}
|
||||
$this->chiudi($zip);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,15 +241,42 @@ class BackupArchive
|
||||
*/
|
||||
private function chiudiEriapri(ZipArchive $zip, string $path): void
|
||||
{
|
||||
if (! $zip->close()) {
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
}
|
||||
$this->chiudi($zip);
|
||||
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiude l'archivio riportando il motivo del fallimento.
|
||||
*
|
||||
* E' in close() che libzip legge davvero i file e scrive l'archivio, quindi
|
||||
* e' qui che si manifestano permessi negati, disco pieno e file spariti. Un
|
||||
* "scrittura non riuscita" secco lascerebbe l'amministratore senza il pezzo
|
||||
* che serve — quale file, e perche' — che sta in getStatusString().
|
||||
*/
|
||||
private function chiudi(ZipArchive $zip): void
|
||||
{
|
||||
$motivo = '';
|
||||
|
||||
try {
|
||||
if ($zip->close()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$motivo = (string) $zip->getStatusString();
|
||||
} catch (\Throwable $e) {
|
||||
$motivo = $e->getMessage();
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'Scrittura del file di backup non riuscita'
|
||||
. ($motivo !== '' ? ': ' . rtrim($motivo, '. ') . '.' : '.')
|
||||
. ' Cause tipiche: un file non leggibile nel volume storage, o spazio esaurito.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apre il pacchetto, ne verifica la struttura ed estrae i file in una
|
||||
* cartella di staging, restituendo l'XML e il percorso dello staging.
|
||||
@@ -233,9 +332,17 @@ class BackupArchive
|
||||
$totalBytes = 0;
|
||||
$extracted = 0;
|
||||
|
||||
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il
|
||||
// totale su cui si misura l'estrazione.
|
||||
$daEstrarre = max(0, $zip->numFiles - 2);
|
||||
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
||||
// due entry di servizio": un archivio con una entry in piu' (una
|
||||
// cartella dichiarata, un file aggiunto a mano) sfalserebbe la
|
||||
// barra fino a farla superare il 100%.
|
||||
$daEstrarre = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
|
||||
$daEstrarre++;
|
||||
}
|
||||
}
|
||||
$onProgress && $onProgress(0, $daEstrarre);
|
||||
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
|
||||
@@ -31,6 +31,23 @@ class BackupOperationState
|
||||
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
|
||||
private const MAX_LOG = 60;
|
||||
|
||||
/**
|
||||
* Puntatore all'unica operazione ammessa alla volta.
|
||||
*
|
||||
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
|
||||
* sullo stesso disco: due in parallelo — due schede aperte, due
|
||||
* amministratori — non si limitano a rallentarsi, si sovrascrivono. Un
|
||||
* ripristino che riscrive le tabelle mentre un azzeramento le cancella
|
||||
* lascia uno stato che nessuno dei due voleva.
|
||||
*
|
||||
* Il TTL e' la valvola di sicurezza: se il worker muore senza chiudere
|
||||
* l'operazione, il blocco si scioglie da solo entro un'ora invece di
|
||||
* richiedere un intervento sul server. Prima ancora, corrente() considera
|
||||
* libero il posto se l'operazione puntata non esiste piu' o e' terminata.
|
||||
*/
|
||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||
private const TTL_CORRENTE = 3600;
|
||||
|
||||
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||
{
|
||||
$state = [
|
||||
@@ -48,10 +65,40 @@ class BackupOperationState
|
||||
];
|
||||
|
||||
$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, e il puntatore va tolto di mezzo.
|
||||
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
@@ -141,6 +188,8 @@ class BackupOperationState
|
||||
|
||||
return $state;
|
||||
});
|
||||
|
||||
$this->liberaCorrente($id);
|
||||
}
|
||||
|
||||
public function markFailed(string $id, string $error): void
|
||||
@@ -154,6 +203,19 @@ class BackupOperationState
|
||||
});
|
||||
|
||||
$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
|
||||
{
|
||||
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user