Persistence¶
Cosalette's persistence system lets devices save state across restarts — accumulated values, calibration offsets, last-known-good readings, or anything that shouldn't be lost on power failure.
The system has three layers:
| Layer | What it does | Set where |
|---|---|---|
| Store backend | Where bytes live (file, database, memory) | App(store=...) |
| DeviceStore | Per-device scoped dict-like interface | Injected into handlers |
| PersistPolicy | When to flush to disk | persist= decorator parameter |
Store Backends¶
A Store is a key-value storage backend. The framework ships four:
| Backend | Use case |
|---|---|
JsonFileStore(path) |
Production — single JSON file, atomic writes |
SqliteStore(path) |
Production — single SQLite file, WAL mode |
MemoryStore() |
Testing — in-memory dict |
NullStore() |
Opt-out — all operations are no-ops |
The Store protocol is simple:
class Store(Protocol):
def load(self, key: str) -> dict[str, object] | None: ...
def save(self, key: str, data: dict[str, object]) -> None: ...
You can implement your own backend (Redis, S3, etc.) by satisfying this protocol.
JsonFileStore¶
Stores all keys as top-level entries in a single JSON file. Uses atomic writes (write to temp file, then rename) to prevent corruption.
store = JsonFileStore("./data/state.json")
# All device keys stored in one file: {"sensor": {...}, "counter": {...}}
SqliteStore¶
Stores all keys in a single SQLite database with WAL mode enabled for concurrent read access.
Store Factories¶
When the store path depends on runtime settings, pass a callable factory instead of a concrete instance:
def make_store(settings: Gas2MqttSettings) -> Store:
return JsonFileStore(settings.data_dir / "state.json")
app = cosalette.App(
name="gas2mqtt",
version="1.0.0",
settings_class=Gas2MqttSettings,
store=make_store,
)
The factory is called during bootstrap — after settings and adapters are resolved but before any device handlers run. Parameters are injected via the DI system (every parameter must carry a type annotation), so the factory can request settings, adapters, or both:
def make_store(settings: Gas2MqttSettings) -> Store:
return SqliteStore(settings.db_path)
app = cosalette.App(name="gas2mqtt", store=make_store)
When to use a factory
Use a concrete Store when the path is known at import time.
Use a factory when the path comes from settings or environment variables
that are resolved at startup.
Default Store Resolution¶
When store= is omitted from App(...), the framework automatically
creates a JsonFileStore at a path derived from the app name. Path
precedence:
<NAME>_STORE_PATHenvironment variable — name upper-cased, all non-alphanumeric characters replaced by underscores (e.g.CALDATES2MQTT_STORE_PATH,SENSOR_HUB_STORE_PATH).$XDG_STATE_HOME/<name>/store.json.~/.local/state/<name>/store.json(the XDG default).
This means retained-topic cleanup (ADR-048) works with zero configuration — apps that remove configured entities will have orphaned retained topics cleared on the next restart without any store wiring.
import cosalette
# Zero-config: JsonFileStore auto-resolved from the app name
# Path: MYAPP_STORE_PATH → $XDG_STATE_HOME/myapp/store.json → ~/.local/state/myapp/store.json
app = cosalette.App(name="myapp", version="1.0.0")
To opt out of persistence entirely, pass store=None:
# Explicit opt-out — no store, no retained-topic cleanup
app = cosalette.App(name="myapp", version="1.0.0", store=None)
Explicit Store instances and factory callables always take precedence
over the default resolution:
# Explicit path — default resolution does not apply
app = cosalette.App(
name="myapp",
store=cosalette.JsonFileStore("/app/data/state.json"),
)
Configurable default backend¶
By default the auto-resolved store uses JsonFileStore. For high-write
apps, call cosalette.set_default_store_backend() once at startup
(before any App() is constructed) to swap the backend:
import cosalette
from cosalette import SqliteStore
# All App() instances that omit store= will now resolve a SqliteStore
cosalette.set_default_store_backend(SqliteStore)
app = cosalette.App(name="myapp", version="1.0.0")
Passing None resets to the JsonFileStore default. Explicit store=
arguments on App() are always unaffected.
Process-global, not thread-safe
Call set_default_store_backend() once during module initialisation or
early startup. Do not call it concurrently or mid-run.
Switching backends on an existing store path
SqliteStore and JsonFileStore use different file formats. If
store.json already exists at the default path and you switch the
backend to SqliteStore, the open will fail with "file is not a
database". When switching backends, point <NAME>_STORE_PATH at a
new filename (e.g. MYAPP_STORE_PATH=/data/store.sqlite3) or
delete/migrate the existing file first.
Container deployments¶
Inside a container the default XDG path is ephemeral. When the framework
detects a container runtime (/.dockerenv, /run/.containerenv, or the
container env var) and no <NAME>_STORE_PATH is set and the
app's entity set may vary by config across restarts, it logs a WARNING
at startup:
WARNING Using an auto-resolved default store at <path>, which is ephemeral
inside a container - retained-topic cleanup (ADR-048) will not survive
restarts. Set <NAME>_STORE_PATH to a path on a mounted volume for
durable persistence.
For durable persistence across restarts, set <NAME>_STORE_PATH to a
path on a mounted volume (e.g. MYAPP_STORE_PATH=/app/data/store.json).
See the Deployment guide for
details.
Why static apps are exempt
The warning exists to protect against ADR-048 ghost entities — retained
topics for devices/telemetry that no longer exist after a config change.
If the app's entity set is provably fixed (static name= strings, no
callable enabled=, no @app.on_configure hooks), there can never be a
config-driven entity removal across restarts, so ADR-048 cleanup has
nothing to recover. The warning is suppressed for such apps.
Apps that use @app.on_configure or callable name=/enabled= always
receive the warning, as their entity set may shrink between restarts.
Additionally, provably-static apps skip the ADR-048 snapshot write
entirely — no store.json is created at the default XDG path unless
persist= is also used.
As a consequence, ADR-048 cleanup does not fire for code-driven entity
removals on static apps (e.g. renaming a telemetry key between deploys).
If you need cleanup on redeploy, use an explicit store= argument —
the gate only skips I/O for the auto-resolved default store.
Explicit opt-out: retained_cleanup¶
If your app uses @app.on_configure purely for non-entity-varying reasons (e.g.
config validation only), the conservative heuristic will still warn. Use
retained_cleanup=False to opt out explicitly — the store is kept for persist=
device state, but ADR-048 cleanup and the ephemeral-store warning are both disabled:
app = cosalette.App(
name="myapp",
version="1.0.0",
retained_cleanup=False, # skip cleanup + warning; store kept for persist=
)
Pass retained_cleanup=True to force cleanup on for an app whose entity names are
derived from import-time config (not callable name= specs) — a case the structural
heuristic would otherwise miss.
App.retained_cleanup is a read-only property returning the override value
(True, False, or None for auto). See
ADR-049 for full rationale.
See ADR-049 — Default store path resolution for the design rationale and alternatives considered.
Optional: signed snapshots with retained_cleanup_snapshot_key¶
The ADR-048 snapshot has no integrity protection by default — anything with
write access to the configured Store backend (a shared SQLite file, a
network-backed store, etc.) can silently rewrite it. Pass a
retained_cleanup_snapshot_key to have the snapshot HMAC-SHA256 signed on
save and verified (timing-safe) on load:
import cosalette
import os
from pydantic import SecretStr
app = cosalette.App(
name="myapp",
version="1.0.0",
retained_cleanup_snapshot_key=SecretStr(os.environ["CLEANUP_SNAPSHOT_KEY"]),
)
Source the key the same way you would MqttSettings.password (env var,
secrets manager, etc.) — never via the same Store this feature protects,
or an attacker who can rewrite the snapshot could rewrite the key too. A
missing, unrecognized-algorithm, or mismatched signature — including a
snapshot written before a key was configured — is treated as no previous
snapshot: cleanup is skipped for that run and a freshly signed snapshot is
written. None (the default) preserves today's unsigned behavior exactly.
This only adds tamper detection against an untrusted-but-writable Store;
it does not change the module's single-writer assumption — see
ADR-063
for the full threat model and rationale.
DeviceStore¶
DeviceStore is a per-device scoped wrapper around a Store backend.
It provides a familiar dict-like interface:
@app.telemetry("sensor", interval=60)
async def sensor(store: DeviceStore) -> dict[str, object]:
# Dict-like access
store["count"] = store.get("count", 0) + 1
store.setdefault("offset", 0.0)
# Check what's stored
all_data = store.to_dict()
return {"count": store["count"]}
The framework automatically:
- Creates a
DeviceStorescoped to the device name - Loads existing data before the first handler call
- Injects it via the DI system (declare
store: DeviceStore) - Saves on shutdown (safety net, regardless of policy)
Dirty Tracking¶
DeviceStore tracks whether it has been modified since the last save.
This enables the SaveOnChange policy to avoid unnecessary I/O:
store["value"] = 42 # store.dirty → True
store.save() # store.dirty → False
store.mark_dirty() # Force dirty (e.g., after mutating a nested object)
Save Policies (PersistPolicy)¶
A PersistPolicy controls when the store is saved during the
telemetry loop. Three policies ship with the framework:
SaveOnPublish¶
Save after each successful MQTT publish. The most common choice — persisted state always matches what's been broadcast.
@app.telemetry("sensor", interval=60, persist=SaveOnPublish())
async def sensor(store: DeviceStore) -> dict[str, object]:
store["count"] = store.get("count", 0) + 1
return {"count": store["count"]}
SaveOnChange¶
Save whenever the store has been modified, regardless of whether MQTT publishing occurred. Most aggressive — minimises data loss.
@app.telemetry("sensor", interval=60, persist=SaveOnChange())
async def sensor(store: DeviceStore) -> dict[str, object]:
store["count"] = store.get("count", 0) + 1
return {"count": store["count"]}
SaveOnShutdown¶
Save only on graceful shutdown. Lightest I/O — no saves during normal operation. Risk: data loss on hard crash or power loss.
Crash risk
SaveOnShutdown means all data since the last startup is lost
if the process crashes or loses power. Use only when the data
can be re-derived.
Default Behaviour¶
If you set store= on the App but don't specify persist= on a device,
the framework saves only on shutdown (equivalent to SaveOnShutdown()).
The framework always saves on shutdown regardless of policy — the
persist= parameter only controls additional saves during operation.
Composing Policies¶
Policies compose with | (OR) and & (AND), just like publish strategies:
# Save on publish OR when dirty (maximum safety)
persist = SaveOnPublish() | SaveOnChange()
# Save only when BOTH conditions are true
persist = SaveOnPublish() & SaveOnChange()
| creates an AnySavePolicy (save if any child says yes).
& creates an AllSavePolicy (save only if all children agree).
When to Use Which Policy¶
| Policy | I/O frequency | Data safety | Best for |
|---|---|---|---|
SaveOnPublish() |
Medium | Good | Most telemetry devices |
SaveOnChange() |
High | Best | Critical counters, calibration |
SaveOnShutdown() |
Minimal | Low | Derived/re-calculable data |
SaveOnPublish() \| SaveOnChange() |
High | Best | Belt-and-suspenders |
Testing with MemoryStore¶
Use MemoryStore in tests to avoid filesystem access:
from cosalette import MemoryStore, DeviceStore
from cosalette.testing import AppHarness
async def test_sensor_persists_count():
backend = MemoryStore()
harness = AppHarness.create(store=backend)
@harness.app.telemetry("sensor", interval=10)
async def sensor(store: DeviceStore) -> dict[str, object]:
store["count"] = store.get("count", 0) + 1
return {"count": store["count"]}
await harness.run()
assert backend.load("sensor") == {"count": 1}
You can also pre-seed the store to test load behaviour:
backend = MemoryStore()
backend.save("sensor", {"count": 99})
# Handler will see store["count"] == 99 on first call
Persistence and Device Handlers¶
The persist= parameter is only available on @app.telemetry, because
the framework controls the telemetry loop and knows when publishes occur.
For @app.device handlers (which own their loop), inject DeviceStore
and call store.save() manually when appropriate:
@app.device("controller")
async def controller(ctx: DeviceContext, store: DeviceStore):
while not ctx.shutdown_requested:
# ... do work ...
store["last_run"] = ctx.clock.now()
store.save() # Manual save
yield # reaction boundary
await ctx.sleep(60)
The framework still saves on shutdown via the finally block.
See Also¶
- Publish Strategies — the
publish=parameter thatpersist=mirrors - Signal Filters — another composable utility
- Testing Guide — testing with
MemoryStore - ADR-015: Persistence — architectural decision record
- ADR-037: Lazy Store Resolution — callable store factories