ADR 0042 — Clear CCU-derivable caches and re-pull as a first-class operation¶
- Status: accepted
- Date: 2026-06-20
- Related: ADR 0002 — multi-CCU first class, ADR 0019 — persistent VALUES cache, docs/caching.md
Context¶
In the Python reference family, operators repeatedly hit situations where the only fix was to clear the cache — after reconfiguring a device on the CCU, after a firmware change, after a paramset description drifted from what the daemon had cached. OpenCCU-Loom inherits the same caches (device descriptions, paramset descriptions, MASTER values, the persistent VALUES cache, the in-memory model + registries) and therefore the same need.
Today there is no robust user-facing way to clear them:
- The WS command
ccu.cache_clearexists only as a parity-shape stub; itsCacheClearerdependency is wired tonilin production, so the command is never registered, and there is no concreteClearAllCachesimplementation. POST /devices/refreshandccu.reload_device_configrefresh (re-pull over the existing rows) rather than clear — they cannot recover from a cache that is structurally wrong, only from one that is out of date in place.- A true purge requires stopping the daemon and editing SQLite or deleting files by hand.
This has to become a first-class, robust capability: it must always work, never leave a partial-stale state, and — above all — never destroy operator-authored data while clearing CCU-derivable data.
Decision¶
Add a scoped "clear CCU-derivable caches + readiness-gated re-pull" operation, exposed on every operator surface, built on the existing boot path rather than a bespoke teardown.
1. Preserve/clear policy (the load-bearing decision)¶
Every persisted and in-memory store is classified once. Only CCU-derivable state is ever cleared; operator-authored and system state is never touched by this operation.
Cleared (re-fetchable from the CCU):
- SQLite:
devices(device descriptions),paramsets(paramset descriptions),values_cache,master_values. - In-memory: the device-description registry, the paramset registry (incl. its address→parameter index), the value cache coordinator, the model registry, and the devicedetails cache (names/rooms/functions, sourced from the CCU).
Never touched (operator-authored or system):
visibility_unignore(operator visibility rules),centralsandconfig_sections(operator configuration),users/tokens/auth_sessions(auth), allmatter_*tables (clearing them would un-commission the bridge from Apple/Google Home), andaudit_log/incidents/session_recorder(history and diagnostics — the clear action is itself recorded intoaudit_log).
A standing contract test enumerates the never-touched tables and fails if the clear path ever references one, so the guarantee cannot silently rot.
master_values is deliberately included: MASTER values are CCU-acked and re-derivable, and leaving them while clearing descriptions would risk a partial-stale mismatch. The cost is a lazy per-channel getParamset(MASTER) re-read on next access (a modest DutyCycle tick), accepted for consistency.
2. Scopes¶
The operation accepts a scope: global, central:<name>, interface:<central>/<iface>, or device:<central>/<iface>/<addr>. The scope selects which rows are deleted. Re-pull granularity is always the owning central — even for an interface- or device-scoped clear the whole owning central is re-initialized — because re-pulling through the proven boot path is more robust than a surgical partial re-pull with its own quiesce requirements.
3. Mechanism — reuse the boot path¶
There is no safe atomic point to clear-and-rebuild a central's model while inbound callbacks stream into it (no event-bus pause; ingest and event handlers run on independent goroutines). Rather than invent a fragile partial quiesce, the re-pull reuses the daemon's existing, tested startup choreography:
- Tear the central down via
Unit.Stop()(ordered stop tiers: detach north-bound adapters, stop scheduler/recovery/clients, hub logout, unsubscribe + clear the in-memory caches) and detach its south-bound callbacks. - Clear the scoped persisted rows in a single transaction.
- Re-initialize the central through the readiness-gated bring-up (
gatedCentralBringUp→WaitForCCUReady→bringUpCentral→ device list → descriptions → paramsets → values), thenUnit.Start().
To make this re-entrant per central, the per-central bring-up is factored out of the one-shot daemon wiring into a restartable handle that owns its own context and closers, so one central can be re-initialized without disturbing the others (ADR 0002 multi-CCU neutrality).
4. Surfaces¶
A single domain service (ClearAndReinit(ctx, scope)) is the one implementation, fronted by:
- REST —
POST /api/v1/admin/cache/clear(scope in the body). - WS — the dormant
ccu.cache_clearis wired to the service, taking a scope argument. - hmcli —
hmcli cache clear --scope …with an online mode (calls REST, including the re-pull) and an offline mode that clears the persisted rows directly against the database when the daemon is down or wedged (the "always works" escape hatch; it cannot re-pull, so it reports that a start is required). - SPA — a destructive action in the diagnostics surface using the shared confirm dialog + toast, localized de/en.
5. Robustness guarantees¶
- Atomic per store, idempotent overall: each store's scoped delete is a single atomic statement; the service collects any per-store failure into the result and continues clearing the rest rather than aborting. Because clearing an already-empty cache is a no-op, a partial failure is safely recovered by re-running the clear (it is not a single cross-store transaction).
- Readiness-gated: if the CCU is down, the re-pull waits (as at boot) and surfaces the "waiting for CCU" health state — it never fails hard or trips
/healthto 503. - Audited: every clear is appended to
audit_log(operator, scope, timestamp), which is itself in the never-touched set.
Implementation note — descriptor persistence¶
The devices / paramsets SQLite tables are written on the pipeline persistence path: WireDescriptorPersistence attaches a sink to the device and paramset registries, so every registry Put also writes a row. The online clear already covers them through the DeviceClearer / ParamsetClearer ports (nil-safe, so a disabled store is a no-op). The offline hmcli mode opens the same two stores directly, alongside the VALUES cache and MASTER values, so hmcli cache clear --offline empties all four persisted caches — matching the online path's clearUnit.
Consequences¶
- The clear is honest about cost: an interface- or device-scoped clear still briefly takes the whole owning central offline during re-init (all its entities go unavailable until the re-pull completes), the price of reusing the proven path over a surgical one.
- The per-central bring-up must be refactored from a one-shot into a restartable handle at the composition root. This is the main engineering risk and is covered by an integration test that clears and re-pulls a central against the
godevccusimulator. - The offline hmcli mode clears persistence but cannot re-pull; the next daemon start performs the readiness-gated bring-up. This is the only mode that works when the daemon itself is unavailable, which is exactly when it is most needed.
- Alternatives rejected: (a) a partial in-place quiesce + rebuild — there is no safe atomic pause for inbound callbacks, so it cannot meet the "always works" bar; (b) clearing everything including operator data — a recurring support footgun and a data-loss hazard; © restarting the whole daemon — too coarse for a multi-CCU deployment where one CCU's cache is stale.