Skip to content

Commit 1fe787b

Browse files
committed
feat(state): add versioned state serialization with automatic migration
This prevents 'tag for enum is not valid' errors when state format changes. Changes: - Add state_versioning module with VersionedState wrapper - Add CURRENT_STATE_VERSION (v2) and migration from v1 - Add #[serde(default)] to all ChainState fields for backward compat - Add explicit discriminants to JobStatus enum for stable serialization - Update storage to use versioned serialize/deserialize - Add deserialize_state_smart() that auto-detects and migrates old formats When adding new fields to ChainState: 1. Always add #[serde(default)] 2. Increment CURRENT_STATE_VERSION 3. Add migration logic in state_versioning.rs
1 parent 89fd1a4 commit 1fe787b

File tree

5 files changed

+355
-12
lines changed

5 files changed

+355
-12
lines changed

crates/core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod crypto;
99
pub mod error;
1010
pub mod message;
1111
pub mod state;
12+
pub mod state_versioning;
1213
pub mod types;
1314

1415
pub use challenge::*;
@@ -17,4 +18,5 @@ pub use crypto::*;
1718
pub use error::*;
1819
pub use message::*;
1920
pub use state::*;
21+
pub use state_versioning::*;
2022
pub use types::*;

crates/core/src/state.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ pub struct RequiredVersion {
1919
}
2020

2121
/// The complete chain state
22+
///
23+
/// IMPORTANT: When adding new fields, ALWAYS add `#[serde(default)]` to ensure
24+
/// backward compatibility with older serialized states. See state_versioning.rs
25+
/// for migration logic.
2226
#[derive(Clone, Debug, Serialize, Deserialize)]
27+
#[serde(default)]
2328
pub struct ChainState {
2429
/// Current block height
2530
pub block_height: BlockHeight,
@@ -34,38 +39,73 @@ pub struct ChainState {
3439
pub sudo_key: Hotkey,
3540

3641
/// Active validators
42+
#[serde(default)]
3743
pub validators: HashMap<Hotkey, ValidatorInfo>,
3844

3945
/// Active challenges (legacy, for SDK-based challenges)
46+
#[serde(default)]
4047
pub challenges: HashMap<ChallengeId, Challenge>,
4148

4249
/// Challenge container configs (for Docker-based challenges)
50+
#[serde(default)]
4351
pub challenge_configs: HashMap<ChallengeId, ChallengeContainerConfig>,
4452

4553
/// Mechanism weight configurations (mechanism_id -> config)
54+
#[serde(default)]
4655
pub mechanism_configs: HashMap<u8, MechanismWeightConfig>,
4756

4857
/// Challenge weight allocations (challenge_id -> allocation)
58+
#[serde(default)]
4959
pub challenge_weights: HashMap<ChallengeId, ChallengeWeightAllocation>,
5060

5161
/// Required validator version
62+
#[serde(default)]
5263
pub required_version: Option<RequiredVersion>,
5364

5465
/// Pending jobs
66+
#[serde(default)]
5567
pub pending_jobs: Vec<Job>,
5668

5769
/// State hash (for verification)
70+
#[serde(default)]
5871
pub state_hash: [u8; 32],
5972

6073
/// Last update timestamp
74+
#[serde(default = "default_timestamp")]
6175
pub last_updated: chrono::DateTime<chrono::Utc>,
6276

6377
/// All registered hotkeys from metagraph (miners + validators)
6478
/// Updated during metagraph sync, used for submission verification
79+
/// Added in V2
6580
#[serde(default)]
6681
pub registered_hotkeys: std::collections::HashSet<Hotkey>,
6782
}
6883

84+
fn default_timestamp() -> chrono::DateTime<chrono::Utc> {
85+
chrono::Utc::now()
86+
}
87+
88+
impl Default for ChainState {
89+
fn default() -> Self {
90+
Self {
91+
block_height: 0,
92+
epoch: 0,
93+
config: NetworkConfig::default(),
94+
sudo_key: Hotkey([0u8; 32]),
95+
validators: HashMap::new(),
96+
challenges: HashMap::new(),
97+
challenge_configs: HashMap::new(),
98+
mechanism_configs: HashMap::new(),
99+
challenge_weights: HashMap::new(),
100+
required_version: None,
101+
pending_jobs: Vec::new(),
102+
state_hash: [0u8; 32],
103+
last_updated: chrono::Utc::now(),
104+
registered_hotkeys: std::collections::HashSet::new(),
105+
}
106+
}
107+
}
108+
69109
impl ChainState {
70110
/// Create a new chain state with a custom sudo key
71111
pub fn new(sudo_key: Hotkey, config: NetworkConfig) -> Self {
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
//! State versioning and migration system
2+
//!
3+
//! This module provides backward-compatible state serialization with automatic
4+
//! migration support. When ChainState structure changes between versions,
5+
//! old data can still be loaded and migrated to the current format.
6+
//!
7+
//! # Usage
8+
//!
9+
//! Instead of directly serializing/deserializing ChainState, use:
10+
//! - `VersionedState::from_state()` to wrap a ChainState for serialization
11+
//! - `VersionedState::into_state()` to get the migrated ChainState
12+
//!
13+
//! # Adding a new version
14+
//!
15+
//! 1. Increment `CURRENT_STATE_VERSION`
16+
//! 2. Keep the old `ChainStateVX` struct as-is (rename current to VX)
17+
//! 3. Create new `ChainState` with your changes
18+
//! 4. Implement migration in `migrate_state()`
19+
//! 5. Add `#[serde(default)]` to any new fields
20+
21+
use crate::{
22+
BlockHeight, Challenge, ChallengeContainerConfig, ChallengeId, ChallengeWeightAllocation,
23+
Hotkey, Job, MechanismWeightConfig, NetworkConfig, Result, Stake, ValidatorInfo,
24+
};
25+
use serde::{Deserialize, Serialize};
26+
use std::collections::{HashMap, HashSet};
27+
use tracing::{info, warn};
28+
29+
/// Current state version - increment when ChainState structure changes
30+
pub const CURRENT_STATE_VERSION: u32 = 2;
31+
32+
/// Minimum supported version for migration
33+
pub const MIN_SUPPORTED_VERSION: u32 = 1;
34+
35+
/// Versioned state wrapper for serialization
36+
///
37+
/// This wrapper allows us to detect the version of serialized state and
38+
/// migrate it to the current format automatically.
39+
#[derive(Clone, Debug, Serialize, Deserialize)]
40+
pub struct VersionedState {
41+
/// State format version
42+
pub version: u32,
43+
/// Serialized state data (version-specific format)
44+
pub data: Vec<u8>,
45+
}
46+
47+
impl VersionedState {
48+
/// Create a versioned state from current ChainState
49+
pub fn from_state(state: &crate::ChainState) -> Result<Self> {
50+
let data = bincode::serialize(state)
51+
.map_err(|e| crate::MiniChainError::Serialization(e.to_string()))?;
52+
Ok(Self {
53+
version: CURRENT_STATE_VERSION,
54+
data,
55+
})
56+
}
57+
58+
/// Deserialize and migrate to current ChainState
59+
pub fn into_state(self) -> Result<crate::ChainState> {
60+
if self.version == CURRENT_STATE_VERSION {
61+
// Current version - deserialize directly
62+
bincode::deserialize(&self.data)
63+
.map_err(|e| crate::MiniChainError::Serialization(e.to_string()))
64+
} else if self.version >= MIN_SUPPORTED_VERSION {
65+
// Old version - migrate
66+
info!(
67+
"Migrating state from version {} to {}",
68+
self.version, CURRENT_STATE_VERSION
69+
);
70+
migrate_state(self.version, &self.data)
71+
} else {
72+
Err(crate::MiniChainError::Serialization(format!(
73+
"State version {} is too old (minimum supported: {})",
74+
self.version, MIN_SUPPORTED_VERSION
75+
)))
76+
}
77+
}
78+
}
79+
80+
// ============================================================================
81+
// Version 1 State (original format, before registered_hotkeys)
82+
// ============================================================================
83+
84+
/// ChainState V1 - original format without registered_hotkeys
85+
#[derive(Clone, Debug, Serialize, Deserialize)]
86+
pub struct ChainStateV1 {
87+
pub block_height: BlockHeight,
88+
pub epoch: u64,
89+
pub config: NetworkConfig,
90+
pub sudo_key: Hotkey,
91+
pub validators: HashMap<Hotkey, ValidatorInfo>,
92+
pub challenges: HashMap<ChallengeId, Challenge>,
93+
pub challenge_configs: HashMap<ChallengeId, ChallengeContainerConfig>,
94+
pub mechanism_configs: HashMap<u8, MechanismWeightConfig>,
95+
pub challenge_weights: HashMap<ChallengeId, ChallengeWeightAllocation>,
96+
pub required_version: Option<crate::RequiredVersion>,
97+
pub pending_jobs: Vec<Job>,
98+
pub state_hash: [u8; 32],
99+
pub last_updated: chrono::DateTime<chrono::Utc>,
100+
// V1 did NOT have registered_hotkeys
101+
}
102+
103+
impl ChainStateV1 {
104+
/// Migrate V1 to current ChainState
105+
pub fn migrate(self) -> crate::ChainState {
106+
crate::ChainState {
107+
block_height: self.block_height,
108+
epoch: self.epoch,
109+
config: self.config,
110+
sudo_key: self.sudo_key,
111+
validators: self.validators,
112+
challenges: self.challenges,
113+
challenge_configs: self.challenge_configs,
114+
mechanism_configs: self.mechanism_configs,
115+
challenge_weights: self.challenge_weights,
116+
required_version: self.required_version,
117+
pending_jobs: self.pending_jobs,
118+
state_hash: self.state_hash,
119+
last_updated: self.last_updated,
120+
// New field in V2 - initialize empty, will be populated from metagraph
121+
registered_hotkeys: HashSet::new(),
122+
}
123+
}
124+
}
125+
126+
// ============================================================================
127+
// Migration Logic
128+
// ============================================================================
129+
130+
/// Migrate state from an old version to current
131+
fn migrate_state(version: u32, data: &[u8]) -> Result<crate::ChainState> {
132+
match version {
133+
1 => {
134+
// V1 -> V2: Add registered_hotkeys field
135+
let v1: ChainStateV1 = bincode::deserialize(data)
136+
.map_err(|e| crate::MiniChainError::Serialization(format!("V1 migration failed: {}", e)))?;
137+
info!(
138+
"Migrated state V1->V2: block_height={}, validators={}",
139+
v1.block_height,
140+
v1.validators.len()
141+
);
142+
Ok(v1.migrate())
143+
}
144+
_ => Err(crate::MiniChainError::Serialization(format!(
145+
"Unknown state version: {}",
146+
version
147+
))),
148+
}
149+
}
150+
151+
// ============================================================================
152+
// Smart Deserialization (tries versioned first, then raw, then legacy)
153+
// ============================================================================
154+
155+
/// Deserialize state with automatic version detection and migration
156+
///
157+
/// This function tries multiple strategies to load state:
158+
/// 1. Try as VersionedState (new format with version header)
159+
/// 2. Try as current ChainState directly (for states saved without version)
160+
/// 3. Try as ChainStateV1 (legacy format)
161+
/// 4. Return error if all fail
162+
pub fn deserialize_state_smart(data: &[u8]) -> Result<crate::ChainState> {
163+
// Strategy 1: Try as VersionedState (preferred format)
164+
if let Ok(versioned) = bincode::deserialize::<VersionedState>(data) {
165+
return versioned.into_state();
166+
}
167+
168+
// Strategy 2: Try as current ChainState (unversioned but current format)
169+
if let Ok(state) = bincode::deserialize::<crate::ChainState>(data) {
170+
info!("Loaded unversioned state (current format)");
171+
return Ok(state);
172+
}
173+
174+
// Strategy 3: Try as V1 (legacy format without registered_hotkeys)
175+
if let Ok(v1) = bincode::deserialize::<ChainStateV1>(data) {
176+
warn!("Loaded legacy V1 state, migrating...");
177+
return Ok(v1.migrate());
178+
}
179+
180+
// All strategies failed
181+
Err(crate::MiniChainError::Serialization(
182+
"Failed to deserialize state: incompatible format".to_string(),
183+
))
184+
}
185+
186+
/// Serialize state with version header
187+
pub fn serialize_state_versioned(state: &crate::ChainState) -> Result<Vec<u8>> {
188+
let versioned = VersionedState::from_state(state)?;
189+
bincode::serialize(&versioned)
190+
.map_err(|e| crate::MiniChainError::Serialization(e.to_string()))
191+
}
192+
193+
// ============================================================================
194+
// Tests
195+
// ============================================================================
196+
197+
#[cfg(test)]
198+
mod tests {
199+
use super::*;
200+
use crate::{Keypair, NetworkConfig};
201+
202+
fn create_test_state() -> crate::ChainState {
203+
let sudo = Keypair::generate();
204+
crate::ChainState::new(sudo.hotkey(), NetworkConfig::default())
205+
}
206+
207+
#[test]
208+
fn test_versioned_roundtrip() {
209+
let original = create_test_state();
210+
211+
// Serialize with version
212+
let data = serialize_state_versioned(&original).unwrap();
213+
214+
// Deserialize
215+
let loaded = deserialize_state_smart(&data).unwrap();
216+
217+
assert_eq!(original.block_height, loaded.block_height);
218+
assert_eq!(original.epoch, loaded.epoch);
219+
}
220+
221+
#[test]
222+
fn test_v1_migration() {
223+
// Create a V1 state
224+
let sudo = Keypair::generate();
225+
let v1 = ChainStateV1 {
226+
block_height: 100,
227+
epoch: 5,
228+
config: NetworkConfig::default(),
229+
sudo_key: sudo.hotkey(),
230+
validators: HashMap::new(),
231+
challenges: HashMap::new(),
232+
challenge_configs: HashMap::new(),
233+
mechanism_configs: HashMap::new(),
234+
challenge_weights: HashMap::new(),
235+
required_version: None,
236+
pending_jobs: Vec::new(),
237+
state_hash: [0u8; 32],
238+
last_updated: chrono::Utc::now(),
239+
};
240+
241+
// Serialize as V1
242+
let v1_data = bincode::serialize(&v1).unwrap();
243+
244+
// Wrap in VersionedState with version 1
245+
let versioned = VersionedState {
246+
version: 1,
247+
data: v1_data,
248+
};
249+
let versioned_bytes = bincode::serialize(&versioned).unwrap();
250+
251+
// Load and migrate
252+
let migrated = deserialize_state_smart(&versioned_bytes).unwrap();
253+
254+
assert_eq!(migrated.block_height, 100);
255+
assert_eq!(migrated.epoch, 5);
256+
assert!(migrated.registered_hotkeys.is_empty()); // New field initialized
257+
}
258+
259+
#[test]
260+
fn test_legacy_v1_direct() {
261+
// Test loading raw V1 data (no version wrapper)
262+
let sudo = Keypair::generate();
263+
let v1 = ChainStateV1 {
264+
block_height: 50,
265+
epoch: 2,
266+
config: NetworkConfig::default(),
267+
sudo_key: sudo.hotkey(),
268+
validators: HashMap::new(),
269+
challenges: HashMap::new(),
270+
challenge_configs: HashMap::new(),
271+
mechanism_configs: HashMap::new(),
272+
challenge_weights: HashMap::new(),
273+
required_version: None,
274+
pending_jobs: Vec::new(),
275+
state_hash: [0u8; 32],
276+
last_updated: chrono::Utc::now(),
277+
};
278+
279+
// Serialize raw V1 (no version wrapper)
280+
let raw_v1 = bincode::serialize(&v1).unwrap();
281+
282+
// Smart deserialize should detect and migrate
283+
let migrated = deserialize_state_smart(&raw_v1).unwrap();
284+
285+
assert_eq!(migrated.block_height, 50);
286+
}
287+
288+
#[test]
289+
fn test_version_constants() {
290+
assert!(CURRENT_STATE_VERSION >= MIN_SUPPORTED_VERSION);
291+
assert_eq!(CURRENT_STATE_VERSION, 2);
292+
}
293+
}

0 commit comments

Comments
 (0)