++ consolidamento funzionalità Backup

This commit is contained in:
2026-07-29 16:34:11 +02:00
parent fd15903c9c
commit 7ac454cd25
16 changed files with 585 additions and 30 deletions

View File

@@ -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++) {

View File

@@ -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);
}
}
/**