Skip to content

Rewrite shadow system with KV-based persistence and derive macros#82

Open
MathiasKoch wants to merge 27 commits intofeature/asyncfrom
feature/kvstore-shadow-persistence
Open

Rewrite shadow system with KV-based persistence and derive macros#82
MathiasKoch wants to merge 27 commits intofeature/asyncfrom
feature/kvstore-shadow-persistence

Conversation

@MathiasKoch
Copy link
Member

@MathiasKoch MathiasKoch commented Feb 17, 2026

Summary

Replaces the monolithic PersistentShadow/ShadowPatch/ShadowState architecture with a trait-based KV-persistence system. Shadows are now defined with #[shadow_root] and #[shadow_node] derive macros that generate field-level serialization, delta types, and persistence code — eliminating the miniconf dependency.

The new architecture separates concerns into composable layers: ShadowNode (delta/reported generation), KVPersist (field-level storage), StateStore (state management abstraction), and Shadow (MQTT cloud connectivity). This enables features that were impossible with the old system: OTA-safe schema migrations, adjacently-tagged enum support, map-type collections, and report-only fields.

Architecture

Core Traits (src/shadows/mod.rs)

  • ShadowNode — Defines Delta and Reported associated types, parse_delta(), apply_delta(), compile-time SCHEMA_HASH
  • KVPersist — Field-level load/persist/collect with self-managed buffer types (ValueBuf, MaxKeyLEN)
  • ShadowRoot — Top-level shadow metadata: name, MQTT topic prefix, max payload size
  • MapKey — Key trait for map-based collections with compile-time display length
  • VariantResolver — Resolves current variant for adjacently-tagged enum deltas missing the tag field

StateStore Abstraction (src/shadows/store/)

  • InMemory<S> — Volatile, Mutex-wrapped state (testing)
  • SequentialKVStore — Flash-based field-level persistence via sequential-storage (feature-gated)
  • FileKVStore — File-based persistence for std environments

Derive Macro System (rustot_derive/src/codegen/)

  • Generates Delta{Name} (all fields Option<T>) and Reported{Name} (with skip_serializing_if) structs
  • Supports structs, regular enums (two-phase _variant key loading), and adjacently-tagged enums (flat union reporting)
  • Field attributes: #[shadow_attr(opaque)], #[shadow_attr(report_only)], #[shadow_attr(migrate(from = "old_key"))], #[shadow_attr(default = value)]
  • impl_opaque! macro implements ShadowNode + KVPersist for primitive types

OTA-Safe Migrations

  • SCHEMA_HASH enables compile-time detection of schema changes
  • load_from_kv_with_migration() handles renamed fields with optional value conversion
  • commit() finalizes schema hash and garbage-collects orphaned keys after successful boot

Changelog

  • Replace PersistentShadow, ShadowPatch, ShadowState with ShadowNode/KVPersist trait system
  • Remove miniconf dependency; add sequential-storage, postcard, darling
  • Add #[shadow_root] and #[shadow_node] derive macros with per-field KV codegen
  • Add StateStore abstraction with InMemory, SequentialKVStore, and FileKVStore implementations
  • Add impl_opaque! macro for primitive ShadowNode implementations
  • Add adjacently-tagged enum support with flat union Reported serialization
  • Add map-type ShadowNode implementations with Patch<T> semantics and per-entry KV persistence
  • Add OTA-safe schema migration with SCHEMA_HASH, LoadResult, and commit() GC
  • Add Shadow struct for combined KV persistence + MQTT cloud connectivity
  • Add shadow end-to-end integration tests
  • Revamp rustot_derive macro architecture with Darling-based attribute parsing
  • Bump toolchain, embassy, and embedded-mqtt dependencies

Fixes #47

MathiasKoch and others added 27 commits January 24, 2026 10:20
Refactor the derive macros to use more idiomatic Rust patterns while
maintaining identical functionality.

Key changes:
- Replace builder/visitor pattern with declarative TypeTransformConfig
- Add proper error handling with syn::Result and span information
- Cleaner Option<T> detection using type path analysis
- Separate concerns into dedicated modules:
  - attr/: Attribute parsing (#[shadow], #[shadow_patch], #[shadow_attr])
  - types/: Type utilities (primitive detection, Option extraction)
  - codegen/: Code generation (type defs, ShadowPatch/ShadowState impls)
  - transform.rs: Declarative type transformations
- Document magic constants (DEFAULT_TOPIC_PREFIX, DEFAULT_MAX_PAYLOAD_SIZE)
- Add comprehensive unit tests for new modules

The public API remains unchanged. All existing tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement the foundational KV storage layer for shadow persistence:
- KVStore trait with async fetch/store/remove/remove_if methods
- SequentialKVStore for embedded NOR flash (sequential-storage v7)
- FileKVStore for std environments and testing
- Migration types (MigrationSource, MigrationError, LoadResult)
- CommitStats for tracking save operations
- FNV-1a hash functions for schema versioning
- Updated error types (KvError, EnumFieldError, ScanError)

Note: SequentialKVStore stores MapStorage in mutex (deviation from
plan due to sequential-storage v7 API). See planning/deviations.md.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Implement lightweight no_std JSON scanner for tag/content fields
- Find byte ranges in single pass, order independent
- Handle nested objects, arrays, and string escapes correctly
- Support partial updates (tag or content may be absent)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add ReportedUnionFields trait for flat union serialization
- Add serialize_null_fields() helper for inactive variant fields
- Add ShadowNode trait with:
  - Associated types: Delta, Reported
  - Constants: MAX_DEPTH, MAX_KEY_LEN, MAX_VALUE_LEN, SCHEMA_HASH
  - Core methods: apply_and_persist(), into_reported()
  - Key enumeration: keys(KeySet), migration_sources()
  - Custom defaults: apply_field_default()
  - Enum handling: enum_fields(), set/get_enum_variant(), is_field_active()
- Add ShadowRoot trait extending ShadowNode with NAME constant
- Clean up phase references from module comments

These traits define the interface for KV-based shadow persistence,
to be implemented by the derive macro in subsequent work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…e 4)

- Add NoPersist KVStore: zero-cost noop for non-persisted shadows
  - All operations return success
  - fetch() returns None, triggering first-boot behavior
- Add KvShadow<'a, S, K> struct with borrowed KVStore reference (&'a K)
  - Enables multiple shadows to share the same KVStore
  - Named KvShadow to avoid conflict with existing MQTT Shadow
- Add two constructors:
  - new_in_memory() -> KvShadow<'static, S, NoPersist>
  - new_persistent(&kv) -> KvShadow<'a, S, K>
- Implement load() with three-way behavior:
  - First boot: initialize defaults, persist all fields, write hash
  - Normal boot: load fields from KV (hash matches)
  - Migration: load fields, set schema_changed flag (hash mismatch)
- Add KeyTooLong variant to KvError
- Add FileKVStore::temp() helper for test fixtures
- Enable miniconf postcard feature for path-based serialization
- Add ignored tests for Phase 8 derive macro integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…se 5)

- Implement load_fields_with_migration() with full migration support:
  - Tries primary key first, falls back to migration sources
  - Applies type conversion functions when specified
  - Performs "soft migration": writes to new key, preserves old for rollback

- Add try_migrations() helper for iterating migration sources

- Add commit() method for finalizing schema changes:
  - Writes new schema hash after boot confirmed successful
  - Removes orphaned keys using remove_if() with FnvIndexSet lookup
  - Returns CommitStats with cleanup information

- Add comprehensive Phase 5 test stubs (ignored until Phase 8 derive macros)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- load_fields() now reads _variant keys first to set up enum variants
  before loading field values
- load_fields_with_migration() uses same two-phase approach
- persist_all_fields() writes _variant keys as plain UTF-8, then fields
- _variant keys stored as UTF-8 (not postcard) for debuggability
- Inactive variant fields preserved in KV (not treated as orphans)
- If no _variant key exists, enum uses its #[default] variant
- Added Phase 6 tests (ignored until Phase 8 derive macros)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add apply_and_save() method on KvShadow as thin wrapper
- Takes delta by reference (&S::Delta) so caller retains ownership
- Delegates to ShadowNode::apply_and_persist() generated by derive macro
- Only Some fields are written to KV - reduces flash wear
- No Clone bound needed on Delta (reference passing)
- Enables wait_delta() pattern to return delta to caller
- Add Phase 7 tests (ignored until Phase 8 provides derive macro support)

Note: The actual delta iteration and persistence logic is generated
by the derive macro in Phase 8.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Generate ShadowNode trait implementations via #[shadow] and #[shadow_patch] macros:

- Parse field attributes: migrate(from=), default, opaque, report_only, leaf
- Generate apply_and_persist() with per-field codegen for structs and enums
- Generate enum_fields(), set_enum_variant(), get_enum_variant() for enums
- Generate MAX_VALUE_LEN, SCHEMA_HASH constants
- Generate Delta and Reported types with proper serde attributes
- Support adjacently-tagged enums with struct-shaped Delta
- Generate ReportedUnionFields for flat union serialization

Known limitations:
- enum_fields() only generates for enum types, not for structs containing enums
- This means nested struct -> enum loading fails (see ignored test)
- Next: Implement load_from_kv()/persist_to_kv() with per-field recursive codegen
  to make each type self-contained and remove miniconf dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace miniconf-based schema traversal with direct per-field code generation:
- Add load_from_kv() and persist_to_kv() methods to ShadowNode trait
- Add collect_valid_keys() for GC in commit()
- Generate per-field loading/persistence code for structs and enums
- Enums read _variant key and construct appropriate variant
- Remove miniconf::Tree derives from all types
- Remove KeyPath, path_to_key(), try_path_to_key() helpers
- Simplify KvShadow methods to use new trait methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement ShadowNode and ShadowPatch for all primitive types using a new
impl_opaque! macro, eliminating the need for is_primitive() checks in
codegen entirely.

Key changes:
- Add src/shadows/opaque.rs with impl_opaque! macro implementing
  ShadowNode and ShadowPatch for primitives (bool, char, integers,
  floats) and generic containers (heapless::String, heapless::Vec)
- Delete rustot_derive/src/types/primitive.rs and is_primitive()
- Update codegen to use attrs.opaque instead of is_primitive() checks
- Fields with migration attributes are still treated as leaf types to
  ensure migration logic runs at struct level
- Adjacently-tagged enum serialization uses FIELD_NAMES.is_empty()
  runtime check (optimized away by compiler) for leaf detection

The key insight: primitives now implement ShadowNode with Delta = Self,
so <u32 as ShadowNode>::Delta = u32, allowing codegen to uniformly use
<T as ShadowNode>::Delta for all fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Integrate MQTT-based AWS IoT Shadow communication directly into KvShadow,
combining KV-based field-level persistence with cloud synchronization.

Changes:
- Add mqtt and subscription fields to KvShadow struct
- Make state field private with state() accessor
- Update constructors to require MQTT client reference:
  - new_in_memory(mqtt)
  - new_persistent(kv, mqtt)
- Port MQTT helper methods from ShadowHandler
- Add public cloud methods:
  - wait_delta() - subscribe, apply, persist, acknowledge
  - update(f) - report state changes to cloud
  - update_desired(f) - request state changes from cloud
  - sync_shadow() - fetch and sync from cloud
  - delete_shadow() - delete from cloud AND KV
- Make apply_and_save() private (internal helper)
- Add PREFIX and MAX_PAYLOAD_SIZE constants to ShadowRoot trait
- Add KvShadowKvOnly test struct for KV-only testing without MQTT

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove the legacy shadow system that was superseded by the KV-based
ShadowNode/ShadowRoot system. This removes:

- ShadowPatch, ShadowState traits
- Shadow, PersistedShadow, ShadowHandler structs
- ShadowDAO trait
- #[shadow] and #[shadow_patch] proc macros
- Related codegen (shadow_patch_impl, shadow_state_impl, type_def)
- ShadowPatch implementations from impl_opaque! macro
- alloc_impl.rs (std ShadowPatch impls)
- Legacy test files
- Unused derive crate modules (transform.rs, types/)

The modern KV-based system is preserved:
- ShadowNode, ShadowRoot traits
- KvShadow
- #[shadow_node] and #[shadow_root] macros
- impl_opaque! macro (now generates ShadowNode + ReportedUnionFields only)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Part 1: Split rustot_derive/src/codegen/shadow_node_impl.rs (2,302 lines)
- codegen/mod.rs: Entry point, rustot_crate_path(), ShadowNodeConfig
- codegen/struct_codegen.rs: generate_struct_code()
- codegen/enum_codegen.rs: generate_enum_code(), generate_simple_enum_code()
- codegen/adjacently_tagged.rs: generate_adjacently_tagged_enum_code()

Part 2: Split src/shadows/kv_shadow.rs (2,563 lines) + rename
- shadow/mod.rs: Shadow struct and persistence methods
- shadow/cloud.rs: MQTT cloud communication methods
- shadow/tests.rs: All tests (~1,400 lines)

Part 3: Rename KvShadow to Shadow across codebase
- Public API: KvShadow -> Shadow
- Test helper: KvShadowKvOnly -> ShadowKvOnly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add two new optional parameters to the shadow_root attribute macro:
- topic_prefix: sets ShadowRoot::PREFIX constant
- max_payload_len: sets ShadowRoot::MAX_PAYLOAD_SIZE constant

When not specified, the constants are not generated, allowing trait
defaults to apply.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The scan module was planned for handling adjacently-tagged enums where
the content field might appear before the tag field in JSON. However,
the actual implementation uses struct-shaped Delta types instead, which
is simpler and more idiomatic. The scan module was never integrated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Refactor the shadow persistence system to separate core functionality
from KV-specific persistence:

- ShadowNode trait now contains only core methods: apply_delta,
  into_reported, and SCHEMA_HASH constant
- New KVPersist trait (feature-gated with shadows_kv_persist) handles
  field-level KV persistence operations
- New StateStore trait provides state-level operations for Shadow struct
- Rename kv_store module to store
- InMemory now implements StateStore directly without serialization
- Update derive macros to generate split trait implementations
- Feature-gate KVPersist impl with #[cfg(feature = "shadows_kv_persist")]

This enables memory-efficient operation where Shadow doesn't hold state
internally, and supports multiple storage strategies (in-memory, blob,
field-level KV).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Enable tests that verify enum variant handling in KV persistence:
- test_commit_preserves_inactive_enum_variant_fields
- test_enum_apply_and_save_writes_variant_key_as_utf8
- test_enum_variant_switch_preserves_inactive_fields
- test_enum_variant_switch_and_back_restores_values_on_reload
- test_apply_and_save_enum_variant_change

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Apply semantic improvements from upstream feature/async commits:
- Use from_slice_escaped for all JSON deserialization to handle
  escaped characters correctly
- Wrap handle_delta() in a retry loop for clean session recovery
  instead of returning an error
- Increase topic format buffer from <64> to <65> for accepted/rejected
  topic subscriptions
- Add Debug, Clone derives to DeltaState for broader usability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use heapless-09 feature flag for sequential-storage to match the
crate's heapless 0.9 dependency, and import FnvIndexSet from the
correct module path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds tests/shadows.rs exercising the Shadow API against live AWS IoT:
wait_delta, update, sync_shadow, delete_shadow with FileKVStore
persistence and a report_only field.

Also fixes get_shadow_from_cloud to return the created shadow state
on 404 instead of discarding it, and adds FileKVStore::base_path()
accessor for test assertions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Each KVPersist impl now allocates its own serialization buffer internally:
no-std uses stack arrays sized by MAX_VALUE_LEN, std uses postcard::to_allocvec()
and kv.fetch_to_vec(). This fixes the MaxSize derive bug where String/Vec fields
failed to compile, removes the unused MAX_DEPTH constant, and eliminates the
hardcoded [0u8; 512] buffers in store implementations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…try KV persistence

Restructure src/shadows/opaque.rs into src/shadows/impls/ submodule with
separate files for opaque primitives, heapless containers, and std containers.

Add ShadowNode + KVPersist for heapless::LinearMap<K, V, N> and
std::collections::HashMap<K, V> with per-entry Patch::Set/Patch::Unset deltas
and manifest-based KV persistence (__keys__ stores active keys via postcard).

Introduce MapKey trait for map key types, collect_valid_prefixes() on KVPersist
for GC support of dynamic collections, and update commit() in both store
implementations to preserve prefix-matching keys. Propagate
collect_valid_prefixes in all three derive macro codegen files.

Add opaque ShadowNode impl for core::time::Duration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments