Close the SDD cycle for the data-loss shielding change: verified pass with warnings (0 critical, 10/10 scenarios with named tests, W1 fixed post-verify), three stacked work units shipped plus the path-resolution fix. The persistence-resilience capability spec is promoted to openspec/specs/.
8.1 KiB
8.1 KiB
Design: Persistence Corruption Guard
Technical Approach
Applies the proposal's persistence-resilience invariant to three stores. Two storage/write
models drive TWO guard mechanisms, not one:
- Alarms — SP key + AUTOMATIC writer (
recalcularTodas: cold start + every 60s). Danger = auto-wipe with no user action. Fix: per-entry tolerant parse + a degraded flag that muzzles the automatic writer. - Stations & EQ — explicit-only writers (no timer). Danger = the user's own next write overwriting a corrupted-then-emptied store. Fix: per-entry tolerant parse (kills the common case) + a subsystem-specific total-corruption policy.
Shared primitive: per-entry parse-skip-log, so one bad entry never blanks its siblings and every
skip/degradation logs [PluriWave][persistencia] ... (repo convention: debugPrint).
Architecture Decisions
| ID | Decision | Rejected alternative | Rationale |
|---|---|---|---|
| D1 | New lib/servicios/persistencia_tolerante.dart: parseListaTolerante<T> + parseMapaTolerante<V> (parse each entry in own try, skip+log, return (validas, saltadas)) + registrarSaltoPersistencia(). Flag/quarantine POLICY stays per-file. |
Inline try/catch at ~8 parse sites; or one mega-helper owning policy. | 8 sites share identical skip+log boilerplate → DRY + one uniform log format. Policy differs per subsystem → stays local. Dart 3.7 records make (validas, saltadas) idiomatic. |
| D2 | AlarmaMusical.fromJson keeps id: json['id'] as String UNCHANGED; harden the CALLER — _parsear wraps each fromJson in the per-entry try; bad/missing id throws → entry skipped+logged (best-effort e['id']). |
Null-check returning a sentinel id. | Skip-never-fabricate — a sentinel is a ghost. The per-entry boundary tolerates ANY future required-field break, not just id; model contract stays honest. |
| D3 | Alarm _cacheRaw coherence (flagged): on PARTIAL load (container decodes, ≥1 skipped, ≥1 survives) cache _cache=survivors and _cacheRaw=_serializar(survivors) (normalized), NOT the corrupt raw. No write-on-read. Partial NEVER sets the flag / quarantines. |
Force one _guardar on the read path; or leave _cacheRaw=raw. |
Coherent raw==parsed → recalcularTodas's dirty-guard (nuevoRaw==actualRaw) does NOT re-fire every 60s. Disk cleaned opportunistically by the next schedule-changing recalc (the cold-start recalc in practice) = ONE clean write, never a thrash. Verified: timer calls recalcularTodas() (not cargar()) and _guardar refreshes _cacheRaw, so the normalized cache holds between ticks. No surprising write-on-read. |
| D4 | Alarm total-fail: _cache=empty, _lecturaAlarmasDegradada=true, keep _cacheRaw=raw. recalcularTodas gets if (_lecturaAlarmasDegradada) return config; BEFORE its dirty-guard. Explicit mutations flow through _guardar, which clears the flag + persists. |
Let the dirty-guard fire (writes empty → wipe). | Closes the headline auto-wipe without user action; recalcularTodas is the only automatic writer (native-sync is inert when in-memory is empty). Explicit creation overwriting a 0%-in-app-recoverable blob = accepted fresh start. |
| D5 | Station total-corruption (load-bearing): split _cargarEmisorasCustom catch. Parse fail (bytes read, invalid JSON) → rename file to ${path}.corrupt sidecar (only if none exists; else drop the live copy), log, list =[]; live path now clean → next add/remove writes a fresh file. NO flag. IO fail (readAsString throws) → do NOT touch file, list =[], _customDegradado=true → SUPPRESS _guardarEmisorasCustom this session; clears on next clean load. Partial → survivors, no flag. |
block-and-log (permanent lockout); accept-and-overwrite (loses unreadable old stations — rejected by task constraint). | Quarantine uniquely satisfies preserve-old-data + no-lockout + user-intent. "Which action re-establishes authority / what it writes": none special — load quarantine clears the path, the next normal add/remove writes {survivors+change}; the unreadable payload is preserved out-of-band. Parse-vs-IO split avoids quarantining a transiently-unreadable GOOD file. Station URLs are user-discovered/high-value → worth the sidecar. |
| D6 | EQ: make the 4 readers per-entry tolerant (survivors kept; colon station:device keys are map keys → round-trip untouched) + log degraded reads. Total-corruption + explicit tweak overwrites (user intent). No flag, no quarantine. |
Per-key quarantine to _corrupto SP keys. |
EQ is explicit-only (no auto-writer to muzzle) and presets are trivially re-creatable (low recovery value). Per-entry tolerance fixes the realistic (partial) corruption; logging removes the silent loss. Total-corruption residual = accepted, documented tradeoff per the proposal's user-intent rule. Asymmetry with stations is intentional (data value + write model). |
Degraded-flag / write-authority matrix
| Event | Alarms | Stations | EQ |
|---|---|---|---|
| Cold start, clean | normal | normal | normal |
| Cold start, PARTIAL | survivors; _cacheRaw=survivors; no write-on-read |
survivors loaded | survivors loaded (per key) |
| Cold start, TOTAL parse-fail | empty; flag SET; no write | quarantine→.corrupt; path cleared; no flag |
empty map (per key); logged |
| Cold start, IO error | n/a (SP) | _customDegradado SET; writes suppressed |
n/a (SP) |
60s timer / pull-to-refresh (recalcularTodas) |
WRITE SUPPRESSED while flag set; else dirty-guard | n/a (no timer) | n/a (no timer) |
| User save/delete alarm | _guardar clears flag + persists |
— | — |
| Station add/remove | — | writes {survivors+change}; suppressed only under IO-degraded | — |
| EQ tweak | — | — | read-modify-write persists {survivors+change} |
File Changes
| File | Action | Change |
|---|---|---|
lib/servicios/persistencia_tolerante.dart |
Create | shared per-entry helpers + logging (D1) |
lib/servicios/servicio_alarmas.dart |
Modify | tolerant _parsear (D1/D2), _cacheRaw normalize (D3), degraded flag + recalcularTodas guard + _guardar clear (D4) |
lib/modelos/alarma_musical.dart |
Modify | none functional (D2 keeps as String); doc comment only |
lib/estado/estado_radio.dart |
Modify | tolerant load + parse/IO split + eager quarantine + _customDegradado (D5) |
lib/servicios/servicio_ecualizador.dart |
Modify | 4 readers per-entry tolerant + logging (D6) |
Testing Strategy
Existing suites LOCK the healthy path (must stay green): servicio_alarmas_cache_test.dart
(S3-R5/R7 dirty-guard + concurrency), servicio_alarmas_proxima_test.dart,
servicio_ecualizador_test.dart (20 cases incl. colon keys), estado_radio_test.dart.
New (RED→GREEN):
| Subsystem | Seed | Cases |
|---|---|---|
Alarms (_PrefsEspia on alarmas_musicales_v1) |
one bad entry / '{bad' |
A1 partial→survivors + no thrash (≤1 write over 2 recalcularTodas); A2 total→empty + zero writes; A3 explicit save after A2 writes once + clears flag; A4 missing-id skipped, no ghost |
Stations (temp file via resolverArchivoCustom) |
bad entry / '{bad' / IO-throw |
B1 partial survivors; B2 total→.corrupt holds original bytes, live cleared; B3 add after B2 → fresh file has only new, sidecar preserved; B4 IO error → file untouched, write suppressed |
EQ (setMockInitialValues) |
one bad value / '{bad' |
C1 partial map survivors + colon-key round-trip; C2 total→empty, explicit save still persists; C3 corrupt principal→flat + logged; C4 survivor colon round-trip with a skipped sibling |
Migration / Rollout
No migration. Persisted payload format unchanged; revert = restore prior behavior. .corrupt
sidecar and degraded flags are runtime-only.
Open Questions
- EQ/alarms total-corruption on explicit mutation forfeits the unreadable blob (accepted per
user-intent rule). Add SP
_corruptobackup-key quarantine for symmetry with stations only if reviewers require zero-loss everywhere. - Confirm 400-line budget once quarantine + tests land; if exceeded, chain Alarms → Stations → EQ per the proposal's plan (Alarms first, highest severity).