++ add reset program
This commit is contained in:
@@ -45,53 +45,137 @@ class BackupArchive
|
||||
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e'
|
||||
* temporaneo: chi chiama lo consegna al browser e lo cancella.
|
||||
* Quante volte l'archivio viene chiuso e riaperto mentre lo si costruisce.
|
||||
*
|
||||
* ZipArchive non scrive niente finche' non si chiama close(): tutte le
|
||||
* addFile() sono solo annotazioni in memoria. Un avanzamento contato sulle
|
||||
* addFile() salirebbe al 100% in un istante per poi restare fermo per tutta
|
||||
* la compressione — cioe' l'esatto contrario di una barra reale. Chiudendo
|
||||
* a lotti si paga una riscrittura dell'archivio per ogni lotto (libzip
|
||||
* ricopia le entry gia' presenti, byte a byte, senza ricomprimerle) e in
|
||||
* cambio ogni avanzamento corrisponde a byte davvero finiti sul disco.
|
||||
*
|
||||
* Il numero e' un tetto, non un passo fisso: il lotto si calcola come
|
||||
* totale/questo valore, cosi' la ricopiatura resta limitata a ~10 volte la
|
||||
* dimensione dell'archivio indipendentemente da quanti file ci sono.
|
||||
*/
|
||||
private const FLUSH_MASSIMI = 20;
|
||||
|
||||
/**
|
||||
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||
*
|
||||
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
*/
|
||||
public function create(string $xml): string
|
||||
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
$files = $disk->allFiles();
|
||||
$totale = count($files);
|
||||
|
||||
$path = storage_path('app/backup-' . Str::uuid() . '.zip');
|
||||
$cartella = dirname($destinazione);
|
||||
if (! is_dir($cartella) && ! mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
||||
throw new RuntimeException('Impossibile preparare la cartella dei backup.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
if ($zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Impossibile creare il file di backup.');
|
||||
}
|
||||
|
||||
try {
|
||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'app_url' => (string) config('app.url'),
|
||||
'files_count' => count($files),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'app_url' => (string) config('app.url'),
|
||||
'files_count' => $totale,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// addFile() legge dal disco in fase di close(): i PDF non passano
|
||||
// mai interi per la memoria del processo, per quanto sia grande
|
||||
// l'archivio.
|
||||
foreach ($files as $relativePath) {
|
||||
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
|
||||
}
|
||||
if (! $zip->close()) {
|
||||
@unlink($destinazione);
|
||||
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
}
|
||||
|
||||
if ($totale === 0) {
|
||||
$onProgress && $onProgress(0, 0);
|
||||
|
||||
return $destinazione;
|
||||
}
|
||||
|
||||
$perLotto = (int) max(1, ceil($totale / self::FLUSH_MASSIMI));
|
||||
|
||||
try {
|
||||
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
||||
) {
|
||||
$nelLotto = 0;
|
||||
$fatti = 0;
|
||||
|
||||
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);
|
||||
$nelLotto++;
|
||||
$fatti++;
|
||||
|
||||
if ($nelLotto >= $perLotto && $fatti < $totale) {
|
||||
$this->chiudiEriapri($zip, $destinazione);
|
||||
$nelLotto = 0;
|
||||
$onProgress && $onProgress($fatti, $totale);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$onProgress && $onProgress($totale, $totale);
|
||||
|
||||
return $destinazione;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
|
||||
* chiusura anche se il callback solleva.
|
||||
*/
|
||||
private function conArchivioAperto(string $path, callable $callback): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
||||
}
|
||||
|
||||
try {
|
||||
$callback($zip);
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
@unlink($path);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (! $zip->close()) {
|
||||
@unlink($path);
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manda su disco le entry accumulate e riapre l'archivio per le prossime.
|
||||
* E' qui che avviene il lavoro vero, quindi e' subito dopo questa chiamata
|
||||
* che l'avanzamento ha un significato.
|
||||
*/
|
||||
private function chiudiEriapri(ZipArchive $zip, string $path): void
|
||||
{
|
||||
if (! $zip->close()) {
|
||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,9 +186,10 @@ class BackupArchive
|
||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||
* dati correnti sono ancora intatti.
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
* @return array{xml: string, staging: string, files: int}
|
||||
*/
|
||||
public function extract(string $zipPath): array
|
||||
public function extract(string $zipPath, ?callable $onProgress = null): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
@@ -148,6 +233,11 @@ 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);
|
||||
$onProgress && $onProgress(0, $daEstrarre);
|
||||
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$entryName = $zip->getNameIndex($index);
|
||||
|
||||
@@ -208,6 +298,7 @@ class BackupArchive
|
||||
fclose($target);
|
||||
|
||||
$extracted++;
|
||||
$onProgress && $onProgress($extracted, $daEstrarre);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
@@ -228,21 +319,25 @@ class BackupArchive
|
||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
*/
|
||||
public function restoreFiles(string $staging): int
|
||||
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
foreach ($disk->directories() as $directory) {
|
||||
$disk->deleteDirectory($directory);
|
||||
}
|
||||
$disk->delete($disk->files());
|
||||
$this->svuotaDiscoPubblico();
|
||||
|
||||
if (! is_dir($staging)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$restored = 0;
|
||||
$totale = iterator_count(new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
));
|
||||
$onProgress && $onProgress(0, $totale);
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
@@ -267,11 +362,56 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$restored++;
|
||||
$onProgress && $onProgress($restored, $totale);
|
||||
}
|
||||
|
||||
return $restored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svuota il disco `public` (PDF dei territori, miniature, logo) lasciando
|
||||
* in piedi la cartella, a cui punta il symlink public/storage.
|
||||
*
|
||||
* Il conteggio si ricava da un secondo giro sul disco, non dai file
|
||||
* trovati all'inizio: Flysystem non solleva se una unlink fallisce, quindi
|
||||
* restituire il totale iniziale significherebbe dichiarare cancellati file
|
||||
* ancora li'. Succede davvero — basta un file scritto da un processo che
|
||||
* gira con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
||||
* worker di coda da www-data) perche' il disco resti popolato mentre
|
||||
* l'operazione si dichiara riuscita.
|
||||
*
|
||||
* Se qualcosa sopravvive si solleva: per un ripristino significa che il
|
||||
* disco non e' stato davvero sostituito, per un azzeramento che dei dati
|
||||
* sono rimasti. In entrambi i casi e' un fallimento, non una nota a
|
||||
* margine.
|
||||
*
|
||||
* @return int File effettivamente rimossi.
|
||||
*/
|
||||
public function svuotaDiscoPubblico(): int
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
$prima = count($disk->allFiles());
|
||||
|
||||
foreach ($disk->directories() as $directory) {
|
||||
$disk->deleteDirectory($directory);
|
||||
}
|
||||
$disk->delete($disk->files());
|
||||
|
||||
$rimasti = $disk->allFiles();
|
||||
|
||||
if ($rimasti !== []) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Non e\' stato possibile cancellare %d file dal disco (es. "%s"). '
|
||||
. 'Di solito e\' un problema di permessi: controlla che il volume storage '
|
||||
. 'appartenga a www-data.',
|
||||
count($rimasti),
|
||||
$rimasti[0]
|
||||
));
|
||||
}
|
||||
|
||||
return $prima;
|
||||
}
|
||||
|
||||
public function deleteDirectory(string $path): void
|
||||
{
|
||||
if (! is_dir($path)) {
|
||||
|
||||
179
app/Services/BackupOperationState.php
Normal file
179
app/Services/BackupOperationState.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Stato di avanzamento delle operazioni lunghe di backup, ripristino e
|
||||
* azzeramento.
|
||||
*
|
||||
* Le tre operazioni girano in coda e la pagina le segue interrogando questo
|
||||
* stato: `total` e `processed` contano unita' di lavoro davvero completate
|
||||
* (file scritti nell'archivio, file estratti, tabelle svuotate), non un
|
||||
* temporizzatore. La barra si muove quando qualcosa e' finito sul disco.
|
||||
*
|
||||
* Stessa impostazione di TerritorioPdfImportState, con cui condivide anche il
|
||||
* TTL: lo stato sopravvive un giorno, quindi ricaricare la pagina dopo un
|
||||
* ripristino lungo mostra ancora l'esito.
|
||||
*/
|
||||
class BackupOperationState
|
||||
{
|
||||
public const TIPO_BACKUP = 'backup';
|
||||
public const TIPO_RESTORE = 'restore';
|
||||
public const TIPO_RESET = 'reset';
|
||||
|
||||
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
||||
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||
|
||||
protected int $ttlSeconds = 86400;
|
||||
|
||||
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
|
||||
private const MAX_LOG = 60;
|
||||
|
||||
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||
{
|
||||
$state = [
|
||||
'id' => $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);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user