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:
11
HANDOFF.md
11
HANDOFF.md
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user