Repository navigation
Engineering state store
The shared state store is KeepPeek's durable coordination plane. Clients, services, and coordinators publish small desired-state documents; other authorized participants watch those documents and use ordinary media commands to make the desired state real.
It is deliberately not a media bus, recording database, credential store, or replacement for
ServerCapabilities. State asks for an outcome. Capabilities and publication/subscription results
report whether that outcome is actually available.
A state value is one complete StateEntry identified by (namespace, key). KeepPeek assigns the
owner identity, revision, timestamps, and expiration. Writers replace complete documents rather
than mutating individual fields, so every watch update is independently usable.
| Concept | Meaning |
|---|---|
| Namespace | Authorization and lifecycle boundary, such as system/, service/, group/, or a private user/device namespace. |
| Key | Stable path-like name within a namespace. One orchestration intent uses one key. |
| Schema | Versioned document type, such as keeppeek.media-intent.v1. |
| Revision | Monotonically increasing namespace revision used for compare-and-set and ordered watches. |
| Owner | Server-issued principal identity permitted to write the entry under namespace policy. |
| TTL | Optional liveness lease. Expiry deletes the entry and is observable as EXPIRE. |
State documents are bounded structured data. They must not contain media bytes, JPEGs, access keys, cookies, passwords, raw SDP, or arbitrary logs. Large or historical data belongs in a dedicated store; live media belongs in RTP or media-data bindings.
KeepPeek applies the following namespace policy to the authenticated principal. Namespace spelling does not confer service identity or group membership:
| Namespace | Typical writer | Typical readers | Use |
|---|---|---|---|
system/ |
No client writes | Administrators | Server-owned observed coordination state |
service/<service-id>/ |
Administrators | Administrators | Worker liveness, leases, output intent |
group/<group-id>/ |
Administrators | Administrators | Shared group preferences and non-authoritative UI state |
user/<owner-id>/ |
Authenticated owner or administrator | Owner or administrator | Personal desired subscriptions and local preferences |
Service-specific grants and group-member grants are not implemented. Non-administrators fail closed for these namespaces, including watch delivery. KeepPeek assigns the authenticated principal as the owner; a client cannot supply another owner identity.
GetState returns the complete current entry. PutState atomically replaces it. DeleteState
removes it. A missing key returns STATE_STORE_ERROR_CODE_NOT_FOUND.
Writers use expected_revision to avoid lost updates:
expected_revision |
Meaning |
|---|---|
| Absent | Blind replacement permitted by namespace policy |
0 |
Create only; reject if the key already exists |
| Nonzero | Replace or delete only when it equals the current entry revision |
On a mismatch, KeepPeek returns STATE_STORE_ERROR_CODE_CONFLICT and the current revision. The
writer rereads, merges its own domain-specific intent, and retries. A client never retries a
blind write after a conflict because that would discard another writer's state.
An optional nonzero TTL requests a liveness lease. A value below one second is rejected with
STATE_STORE_ERROR_CODE_TTL_INVALID; a value above 24 hours is clamped to 24 hours, and the entry
carries the accepted expires_at_ms. Expiry is inclusive: an entry whose deadline equals the
current time already reads as not found. KeepPeek emits an EXPIRE update when an accepted TTL
elapses. Services refresh their own lease with compare-and-set writes; they do not refresh another
owner's entry. Restart purges overdue leases before serving: expired rows are deleted, their bytes
leave the counter, and each affected namespace revision advances by one per expired entry, exactly
as if the leases had expired lazily, so later writes stay monotonic. MQTT integration state and
settings-backed entries are live configuration rather than leases and reject any TTL.
WatchState atomically registers a watch and captures all current matching keys. Its
StateStoreResult.watch response contains the complete initial snapshot exactly as it existed at
snapshot_revision. Later updates follow that snapshot as StateStoreWatchUpdate notifications on
the reliable ordered control channel, each carrying a per-watch watch_sequence starting at 1.
Settings-backed namespaces follow the same lifecycle: the snapshot is captured while holding the
configuration lock so no committed mutation can slip between the snapshot and the registration,
and only file-committed puts and deletes fan out to watchers. Settings entries never expire, so
their watches carry no EXPIRE updates.
sequenceDiagram
participant C as Coordinator
participant K as KeepPeek state store
C->>K: WatchState { request_id: 41, watch_id: transcode-intents, namespace: service/transcoder-a, key_prefix: intents/ }
K-->>C: StateStoreResult { request_id: 41, snapshot_revision: 120, entries }
K-->>C: StateStoreWatchUpdate { watch_sequence: 1, revision: 121, kind: PUT, entry }
C->>K: WatchStateAck { request_id: 42, watch_id: transcode-intents, applied_sequence: 1 }
K-->>C: StateWatchAckResult { request_id: 42, watch_id: transcode-intents, applied_sequence: 1 }
K-->>C: StateStoreWatchUpdate { watch_sequence: 2, revision: 122, kind: EXPIRE, key }
A client installs the snapshot first, then applies an update only when its watch_sequence is
exactly one above the highest applied. The namespace revision also covers keys outside a prefix
filter, so a revision skip without a sequence skip is harmless, while a sequence skip is a gap. A
sequence gap, StateStoreWatchClosed termination, control-channel disconnect or closure, rejected
update, or local state corruption requires a new watch snapshot. The client sends UnwatchState
when possible, creates a replacement watch, and replaces its cached matching keys only after
installing the new snapshot. There is no resume token that lets a client invent missing state.
Broad snapshots are bounded by entry count and byte size; a rejected broad watch must be narrowed
by namespace or key prefix.
UnwatchState removes a registered watch. A watch update is a notification the client acknowledges
with WatchStateAck naming the highest contiguously applied watch_sequence; acknowledgement
confirms receipt by the client, not successful external work. The server terminates a watch that
overflows its bounded update buffer, misses its acknowledgement deadline, loses authorization, or
fails snapshot delivery, and reports the reason in StateStoreWatchClosed.
keeppeek.media-intent.v1 is the standard orchestration schema. It represents an intent, not a
binding. The document always names stable source IDs and logical stream IDs, never transient
source-session IDs or RTP MIDs.
{
"role": "publish",
"desired": true,
"source_id": "front-door",
"media_kind": "video",
"variant_id": "browser-h264-720p",
"output_profile": "browser-h264-720p",
"priority": 100,
"recording_mode": "disabled"
}The required fields are role, desired, source_id, and media_kind. role is publish or
subscribe. A publish intent must include recording_mode (inherit, disabled, or required)
and the worker copies it to StartPublication.recording_mode; source publication capability and
server policy decide whether that request succeeds. A missing or unknown publish-intent mode is a
worker validation failure; it must not produce a zero-value StartPublication, which KeepPeek
rejects with PUBLICATION_ERROR_CODE_RECORDING_MODE_INVALID. A subscription intent may name an
exact variant or leave it absent to permit ordinary automatic selection. The schema can include
service-specific declarative configuration under a namespaced parameters object, but it never
includes credentials or binary payloads.
The worker flow is deliberately two-stage:
- A coordinator writes an intent under an authorized service or user namespace.
- A worker watches the intent and current
ServerCapabilities. - The worker resolves the stable source ID to the current source session and validates the requested stream, variant, codec, publication, and recording capabilities.
- The worker sends ordinary
Subscribe,StartPublication,StopPublication, orUnsubscribecommands. - KeepPeek advertises actual variants and returns actual bindings through capabilities and command results.
- The worker may write a separate observed-status entry, but it never overwrites desired intent owned by another principal.
This separation prevents stale desired state from granting media access or causing a client to
subscribe to a nonexistent stream. A state document saying “publish browser H.264� does not mean
the variant exists; only a ready MediaVariantCapability does.
A state-store lease prevents duplicate worker ownership without embedding a scheduler into media
protocols. For example, workers attempt a create-only key such as
service/transcoder-a/leases/front-door-video-browser-h264-720p with a short TTL. The winning
worker publishes or renews the lease with compare-and-set. Other workers observe it and stay
standby. When the lease expires, a standby can safely attempt a new create-only claim.
The lease determines which service should act. It does not reserve a media variant. The eventual
StartPublication remains the authoritative variant ownership check and can still return a
conflict if topology changed.
Computer-vision coordination follows the same pattern: intent assigns a model profile to a stable source and stream; the worker starts ordinary subscriptions, publishes only validated events, and reports health separately. Event truth remains in the event catalog and router, not in state entries.
Groups use their own authoritative control state. A client may store a preferred group,
local mute layout, or selected audio device in its private state namespace. It must not use shared
state to claim membership, grant permission, or assert that another
participant is speaking. Those facts come only from GroupState after a successful join.
This distinction prevents an expired preference or a malicious shared-state write from becoming a media authorization decision.
- A writer that loses compare-and-set retries after rereading current state.
- A TTL expiry means the owner stopped refreshing; consumers stop acting on that intent unless a newer entry replaces it.
- A watcher reconnects by requesting a fresh snapshot and reconciling desired state with current capabilities.
- A service failure can leave desired state intact while its observed lease expires, allowing a standby to take over.
- An unavailable source session never invalidates stable intent; workers wait for or resolve a new
source session through
ServerCapabilities. - KeepPeek restart restores durable state entries according to their TTL and namespace policy, while clients and services reestablish watches.
The configuration ZIP and the runtime store are two separate recovery sets.
A format-3 configuration ZIP contains exactly config.toml and plaintext
secrets.toml, with the manifest in the ZIP comment. It contains no database
or media. Settings-backed state entries live in the state_store section of
config.toml, so they travel inside the configuration ZIP and restore with
it. Everything else in the state store needs its own archive.
The generic runtime store is a separate consistent archive and recovery set.
The server keeps it in state-store.db inside the configured long-term
storage directory and serves reads and watches from memory while committing
every mutation to that file first. Back it up as one unit: the state database
file plus any accompanying sidecar files, copied while KeepPeek is stopped,
or through a tested snapshot procedure that covers every involved volume and
writer. A live copy of the database file alone can disagree with its journal
or sidecars, exactly as a live catalog copy can. The contract tests in
tests/state_store_contract.rs pin the bundle half of this boundary; expiry,
quota accounting, and reopen behavior are pinned by the DurableStore unit
tests.
Generic dispatch and the keeppeek.state-store.v1 capability turn on only
after the database opens and its namespaces, revisions, and entries load into
memory. Startup first rejects restored data with excessive counts or bytes,
invalid identifiers or schemas, orphan entries, and inconsistent revisions.
It checks metadata before decoding bounded documents. If the file cannot open, the server keeps running with an empty
in-memory registry, generic commands stay rejected, and the capability stays
unadvertised: no client ever sees durability the server cannot deliver.
Every mutation commits to the database before it applies in memory, and the database is the sole revision authority: watch snapshots expire due leases in the database first and replay the exact committed revisions, so disk and cache never report different revisions for the same key. A watch snapshot is additionally bounded to 64 entries and a 64 KiB encoded response; broader watches are rejected so the client can narrow the prefix. When the database is attached, a full store fails the mutation instead of silently evicting the oldest entries.
Restore reinstates counters before watches. A runtime-store restore must bring back namespace revisions, entry revisions, and byte counters together before the server accepts new watches. Clients re-establish watches with a fresh snapshot after a restore and reconcile against current capabilities. No resume token crosses a restore boundary: a sequence gap, a closed watch, or a restored database always means a new snapshot, following the rules in "Watches without a snapshot gap".
Quota, TTL, and full-disk behavior stay explicit. A TTL below one second is rejected; a TTL above 24 hours is clamped to 24 hours. Writes that would exceed the per-namespace entry bound or the total value-byte ceiling are rejected before admission, so a failed write never reserves store space. A storage or disk failure surfaces as a storage error on the mutating call and never reports a silent success, while previously committed entries stay readable. Settings-backed entries reject every TTL because configuration is not a lease.
The reproducible qualification commands are:
cargo test --locked --release -p keeppeek --lib qualification -- --nocapture --test-threads=1
cargo test --locked --release -p keeppeek --lib full_watch_capacity_fanout -- --nocapture --test-threads=1
cargo test --locked -p keeppeek --lib continuous_churn -- --nocapture --test-threads=1On Windows 11 Pro, AMD Ryzen 5 5600G, rustc 1.98.1, the release workload uses 127 simulated owner IDs across 30 serial CAS rounds (3,810 operations per variant). Both variants construct the same 2,193-byte schema-valid document. The deterministic loop has no random workload seed. Measurements are in microseconds per operation:
| Operation | p50 | p95 | Maximum |
|---|---|---|---|
| In-memory CAS baseline | 7 | 11 | 119 |
| Durable CAS | 853 | 1,387 | 3,286 |
| One-entry snapshot | 2 | 3 | 17 |
Durability adds 846 microseconds at p50 and 1,376 microseconds at p95. These
are control-plane measurements, not media throughput or 127 simultaneous
browser sessions. The test rejects any measured operation taking five seconds;
that stall ceiling is not a latency service-level guarantee. A 300 ms idle
sample measured 0% process CPU and 15,552,512 resident bytes. Sampling after
each of 30 rounds observed a maximum of 19,861,504 resident bytes; this is a
sampled process maximum, not an operating-system lifetime high-water mark.
Run the workload alone or with --test-threads=1 to avoid including another
test's work in process measurements.
At full capacity, 1,024 watches across 127 sessions received 30 fanout rounds. Fanout measured 742 microseconds at p50, 860 at p95, and 940 maximum. The workload held 30 pending updates per watch, then verified termination when additional unacknowledged updates exceeded the 32-update bound. This isolates the watch registry with a successful enqueue callback; the separate browser tests cover actual transport delivery and timeout closure.
The quota workload admits 30,601 documents totaling 67,107,993 value bytes, rejects the next document at the 67,108,864-byte ceiling without mutating retained state, reopens the store, and verifies that deletion permits a new write. Namespace and per-namespace entry limits are tested separately.
Continuous disk churn fills and drains 256 entries for 24 cycles on one open store. The observed database-plus-WAL maximum was 4,126,856 bytes; automatic checkpointing reclaimed the WAL without a restart. The regression ceiling is 5 MiB for this fixed workload, not the ceiling for a fully populated store. Opening the store also truncates the WAL. The existing restart-churn test covers 1,024-entry cycles and page reuse after reopening.
The implementation is complete when these behaviors pass end to end:
- Two writers cannot overwrite the same orchestration key when compare-and-set revisions differ.
- A lease expiry reaches every active watcher as one ordered
EXPIREupdate. - A watch started during a write receives either the entry in its snapshot or one later update, never neither or both at the same revision.
- A transcoder reacts to a publication intent only after resolving current capabilities and successfully owns the actual output variant.
- A viewer uses a subscription intent to choose a desired variant but waits for its ready capability before subscribing.
- A group client can persist a preferred group without gaining membership or the right to publish.
- State values exceeding configured bounds, containing disallowed schemas, or written outside namespace authorization are rejected.
- KeepPeek
- Feature guide and navigation
- Get started
- Authentication and access control
- Camera controls
- Demo videos
- Live wall and kiosk display
- Recording and evidence
- Digital zoom and pan
- Camera and stream health
- Visual configuration management
- Configuration reference
- Export and import configuration
- Backup and restore
- Upgrades and migrations
- Recording maintenance
- Recording archive recovery
- Native camera events
- External analysis
- Notifications and integrations
- Home Assistant card
- Groups and two-way audio: current status
- How KeepPeek works
- Users and design choices
- Release readiness and known limitations
- Reporting bugs
- Open source and licensing
- Contributing
- Access control
- Backup and restore
- Camera controls and vendor alignment
- Computer Vision Service Scenario
- Visual configuration management
- Demo Video Publishing
- MQTT 5 Event Forwarder
- Event-Loop Runtime
- Event presentation metadata
- Event browsing, search, and encoded previews
- Event Review and Shared Bookmarks
- Evidence exports
- External authentication proposal
- Operate external authentication
- External authentication performance evidence
- External authentication verification
- Group Client Scenarios
- Hikvision ISAPI integration notes
- Home Assistant Card
- macOS installation
- Native event recording performance
- Notification rules and delivery
- Native event acceptance evidence
- Generic ONVIF and RTSP events
- Performance benchmarks
- Event pre-recording contract proposal
- Event pre-recording performance qualification
- Event pre-recording acceptance evidence
- Pushover notifications
- Recording integrity
- Recording policy audit
- Runtime profiles implementation contract
- Secrets configuration
- Shared State Store Scenario
- Transcoding Service Scenario
- Viewer Application Scenario
- Wiki publishing
- Windows service