Piantine pubblicate anche dopo un ripristino; posto unico senza gare

- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il
  ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il
  percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce
  le piantine già pubblicate).
- BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto
  lo stesso mutex di occupa(), così una lettura non cancella il puntatore
  di un'operazione appena avviata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:33:07 +02:00
parent 426a95211d
commit e7148c7b32
8 changed files with 143 additions and 22 deletions

View File

@@ -84,7 +84,8 @@ Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con - `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
- Il commit successivo chiude i blocchi 2 e 3 della revisione (sotto). - `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
della tabella sono nel commit successivo.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare. sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
@@ -111,22 +112,20 @@ sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` | | Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` | | Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) | | Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore | | Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
Non cambiato, di proposito: `trustProxies('*')` (commento in Non cambiato, di proposito: `trustProxies('*')` (commento in
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS). `bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
Suite: **427 test verdi**. Provati in browser in locale: file protetti (404 Suite: **429 test verdi**. Provati in browser in locale: file protetti (404
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2 senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato). lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
**Non provato / aperto:** **Non provato / aperto:**
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio - chiusura delle sessioni con due browser veri; coda e worker con un riavvio
reale; modifica vertici al tocco su tablet vero; reale; modifica vertici al tocco su tablet vero;
- dopo un ripristino le piantine pubblicate risultano "Modifiche da pubblicare"
(l'import ricrea i territori con id nuovi): verso sicuro, basta ripubblicare;
- `corrente()` / `liberaCorrente()` di `BackupOperationState` fanno get+forget
senza mutex (finestra di millisecondi);
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche - l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
lotto riceve 429 (il browser lo segnala); lotto riceve 429 (il browser lo segnala);
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome - numeri di territorio con lettere davanti ("A12") non si abbinano da nome

View File

@@ -33,6 +33,7 @@ class TerritorioMappa extends Model
'geojson', 'geojson',
'geojson_pubblicato', 'geojson_pubblicato',
'impronta_pubblicata', 'impronta_pubblicata',
'pdf_pubblicato',
'pubblicata_at', 'pubblicata_at',
'image_path', 'image_path',
'rendered_at', 'rendered_at',
@@ -110,9 +111,11 @@ class TerritorioMappa extends Model
{ {
$territorio = $this->territorio; $territorio = $this->territorio;
// Il territorio entra con il numero, non con l'id: il numero è unico e
// il ripristino di un backup ricrea i territori con id nuovi, che
// facevano risultare da pubblicare ogni piantina appena ripristinata.
return hash('sha256', json_encode([ return hash('sha256', json_encode([
$this->geometria(), $this->geometria(),
$this->territorio_id,
$territorio ? trim((string) $territorio->numero) : null, $territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome, $territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null, $territorio ? trim((string) $territorio->confini) : null,
@@ -131,15 +134,19 @@ class TerritorioMappa extends Model
} }
/** /**
* Il PDF in vigore del territorio è uno composto da una piantina. Se nel * Il PDF in vigore del territorio è ancora quello prodotto dall'ultima
* frattempo è stato sostituito per altre vie (ripristino di una versione, * pubblicazione. Se nel frattempo è stato sostituito per altre vie
* caricamento a mano, import), quello che arriva ai proclamatori non è * (ripristino di una versione, caricamento a mano, import), quello che
* più questo disegno, anche se l'impronta torna. * arriva ai proclamatori non è più questo disegno, anche se l'impronta
* torna. Si confronta il percorso, che il backup conserva: prima si
* cercava l'id del territorio nel nome del file, e dopo un ripristino
* (id nuovi) non tornava più.
*/ */
public function pdfInUsoDallaPiantina(): bool public function pdfInUsoDallaPiantina(): bool
{ {
return $this->territorio_id !== null return $this->pdf_pubblicato !== null
&& str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-'); && $this->territorio !== null
&& $this->territorio->pdf_path === $this->pdf_pubblicato;
} }
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */ /** Il testo dell'etichetta di stato, uguale in ogni pagina. */

View File

@@ -112,10 +112,12 @@ class BackupOperationState
$stato = $this->get($id); $stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto // Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
// e' libero, e il puntatore va tolto di mezzo. // 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)) { if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null; return null;
} }
@@ -296,8 +298,17 @@ class BackupOperationState
*/ */
private function liberaCorrente(string $id): void private function liberaCorrente(string $id): void
{ {
if (Cache::get(self::CHIAVE_CORRENTE) === $id) { // Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi
Cache::forget(self::CHIAVE_CORRENTE); // 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) {
} }
} }

View File

@@ -176,6 +176,9 @@ class PubblicazionePiantina
$mappa->update([ $mappa->update([
'geojson_pubblicato' => $mappa->geojson, 'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $stato['impronta'], 'impronta_pubblicata' => $stato['impronta'],
// Il PDF appena messo in vigore: finché il territorio usa questo
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
'pubblicata_at' => now(), 'pubblicata_at' => now(),
]); ]);

View File

@@ -0,0 +1,68 @@
<?php
use App\Models\TerritorioMappa;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* La piantina ricorda il percorso del PDF che ha pubblicato.
*
* Prima "Pubblicata" si riconosceva dall'id del territorio, sia
* nell'impronta sia nel nome del file (territori-pdf/piantina-{id}-…): il
* ripristino di un backup ricrea i territori con id nuovi, e ogni piantina
* pubblicata tornava "da pubblicare". Ora l'impronta usa il numero e lo stato
* confronta il percorso del PDF in vigore con quello registrato qui; entrambi
* attraversano il ripristino intatti.
*
* Le piantine già pubblicate restano tali solo se lo erano davvero: impronta
* corrispondente (con la formula di prima, o con la nuova se la migrazione
* precedente gira adesso su un'installazione nuova) e PDF in vigore composto
* da questa piantina.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->string('pdf_pubblicato')->nullable()->after('impronta_pubblicata');
});
TerritorioMappa::with('territorio.zona')
->whereNotNull('pubblicata_at')
->whereNotNull('territorio_id')
->get()
->each(function (TerritorioMappa $mappa) {
$territorio = $mappa->territorio;
$pdf = (string) $territorio?->pdf_path;
$improntaVecchia = hash('sha256', json_encode([
$mappa->geometria(),
$mappa->territorio_id,
$territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null,
]));
$improntaNuova = $mappa->impronta();
$eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true)
&& str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-');
if (! $eraPubblicata) {
return;
}
$mappa->forceFill([
'impronta_pubblicata' => $improntaNuova,
'pdf_pubblicato' => $pdf,
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->dropColumn('pdf_pubblicato');
});
}
};

View File

@@ -212,6 +212,7 @@ XML;
$mappa->update([ $mappa->update([
'geojson_pubblicato' => $mappa->geojson, 'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $mappa->impronta(), 'impronta_pubblicata' => $mappa->impronta(),
'pdf_pubblicato' => $pdf,
'pubblicata_at' => now(), 'pubblicata_at' => now(),
]); ]);

View File

@@ -105,6 +105,26 @@ class RipristinoBackupTest extends TestCase
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg')); $this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
} }
/**
* Una piantina pubblicata resta "Pubblicata" anche se il ripristino ricrea
* il suo territorio con un id nuovo: prima lo stato dipendeva dall'id.
*/
public function test_una_piantina_pubblicata_resta_pubblicata_dopo_il_ripristino(): void
{
$this->installazioneConPiantine();
$mappa = TerritorioMappa::with('territorio.zona')->sole();
$mappa->update([
'impronta_pubblicata' => $mappa->impronta(),
'pdf_pubblicato' => $mappa->territorio->pdf_path,
]);
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato());
$stato = $this->ripristina($this->backup());
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, TerritorioMappa::sole()->stato());
}
/** /**
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina. * Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
* Non contiene piantine e storico, che restano vuoti — tenere quelle * Non contiene piantine e storico, che restano vuoti — tenere quelle

View File

@@ -65,15 +65,27 @@ class BackupOperationStateTest extends TestCase
/** /**
* Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto * Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto
* ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da * ma il puntatore no. Il posto deve risultare libero e potersi prendere;
* un'operazione che non esiste piu'. * il puntatore lo sovrascrive l'operazione nuova (corrente() non lo
* cancella: girando fuori dal mutex poteva togliere quello di un'altra).
*/ */
public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void
{ {
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600); Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
$this->assertNull($this->stato->corrente()); $this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.'); $this->assertNull($this->stato->occupa('op-nuova', BackupOperationState::TIPO_BACKUP, 'In coda'));
$this->assertSame('op-nuova', $this->stato->corrente());
}
/** Un corrente() fuori dal mutex non deve togliere il puntatore appena scritto. */
public function test_corrente_non_cancella_il_puntatore(): void
{
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
$this->stato->corrente();
$this->assertSame('op-svanita', Cache::get(self::CHIAVE_CORRENTE));
} }
public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void
@@ -90,7 +102,7 @@ class BackupOperationStateTest extends TestCase
Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600); Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600);
$this->assertNull($this->stato->corrente()); $this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE)); $this->assertNull($this->stato->occupa('op-2', BackupOperationState::TIPO_BACKUP, 'In coda'));
} }
/** /**