Repository navigation
feat(mesh): wire TreeSnapshot into mesh sync protocol (Phase 3) #983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
bed6cd4
96f98f6
dbc41f2
053c369
fcde807
02af584
cb37fac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,7 @@ use tracing::{debug, trace}; | |
| use super::{ | ||
| service::gossip::StateUpdate, | ||
| stores::{AppState, MembershipState, PolicyState, StateStores, StoreType, WorkerState}, | ||
| tree_ops::{TreeState, TreeStateDelta}, | ||
| tree_ops::TreeStateDelta, | ||
| }; | ||
|
|
||
| /// Trait for extracting version from state types | ||
|
|
@@ -252,33 +252,47 @@ impl IncrementalUpdateCollector { | |
| let unsent_start = already_sent.min(pending.len()); | ||
| let unsent_ops = &pending[unsent_start..]; | ||
| if !unsent_ops.is_empty() { | ||
| // Cap ops per delta to keep serialized size | ||
| // under the gRPC message limit. Each op can | ||
| // be up to ~20 KB (long prompt text), so 200 | ||
| // ops ≈ 4 MB which fits in 10 MB with overhead. | ||
| const MAX_OPS_PER_DELTA: usize = 200; | ||
| let capped_ops = if unsent_ops.len() > MAX_OPS_PER_DELTA { | ||
| &unsent_ops[..MAX_OPS_PER_DELTA] | ||
| } else { | ||
| unsent_ops | ||
| }; | ||
| let capped_version = last_sent_version + capped_ops.len() as u64; | ||
|
|
||
| let model_id = key.strip_prefix("tree:").unwrap_or(key).to_string(); | ||
| let delta = TreeStateDelta { | ||
| model_id: model_id.clone(), | ||
| operations: unsent_ops.to_vec(), | ||
| operations: capped_ops.to_vec(), | ||
| base_version: last_sent_version, | ||
| new_version: current_version, | ||
| new_version: capped_version, | ||
| }; | ||
| if let Ok(delta_bytes) = delta.to_bytes() { | ||
| let delta_policy = PolicyState { | ||
| model_id, | ||
| policy_type: "tree_state_delta".to_string(), | ||
| config: delta_bytes, | ||
| version: current_version, | ||
| version: capped_version, | ||
| }; | ||
| if let Ok(serialized) = bincode::serialize(&delta_policy) { | ||
| updates.push(StateUpdate { | ||
| key: key.clone(), | ||
| value: serialized, | ||
| version: current_version, | ||
| version: capped_version, | ||
| actor: self.self_name.clone(), | ||
| timestamp, | ||
| }); | ||
| debug!( | ||
| "Collected tree delta: {} ({} ops, version: {})", | ||
| "Collected tree delta: {} ({}/{} ops, version: {}->{})", | ||
| key, | ||
| capped_ops.len(), | ||
| unsent_ops.len(), | ||
| current_version | ||
| last_sent_version, | ||
| capped_version, | ||
| ); | ||
| sent_delta = true; | ||
| emitted_tree_keys.insert(key.clone()); | ||
|
|
@@ -288,65 +302,40 @@ impl IncrementalUpdateCollector { | |
| } | ||
|
|
||
| if !sent_delta { | ||
| // Full-state fallback: build TreeState from | ||
| // tree_configs (if checkpointed) + pending ops. | ||
| let model_id = key.strip_prefix("tree:").unwrap_or(key).to_string(); | ||
| // Clone bytes out of the DashMap ref so no guard | ||
| // is held while iterating tree_ops_pending. | ||
| let config_bytes = self | ||
| .stores | ||
| .tree_configs | ||
| .get(key.as_str()) | ||
| .map(|r| r.clone()); | ||
| let mut tree_state = match config_bytes.as_deref() { | ||
| Some(bytes) if !bytes.is_empty() => { | ||
| match TreeState::from_bytes(bytes) { | ||
| Ok(ts) => ts, | ||
| Err(_) => { | ||
| debug!("Skipping full-state fallback for {} — config corrupted", key); | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| _ => TreeState::new(model_id.clone()), | ||
| }; | ||
| for op in &**pending { | ||
| tree_state.add_operation(op.clone()); | ||
| } | ||
| let tree_version = tree_state.version; | ||
| let full_state = PolicyState { | ||
| model_id, | ||
| policy_type: "tree_state".to_string(), | ||
| config: tree_state.to_bytes().unwrap_or_default(), | ||
| version: tree_version, | ||
| }; | ||
| if let Ok(serialized) = bincode::serialize(&full_state) { | ||
| updates.push(StateUpdate { | ||
| key: key.clone(), | ||
| value: serialized, | ||
| version: current_version, | ||
| actor: self.self_name.clone(), | ||
| timestamp, | ||
| }); | ||
| debug!( | ||
| "Collected full tree state fallback: {} (version: {})", | ||
| key, current_version | ||
| ); | ||
| emitted_tree_keys.insert(key.clone()); | ||
| } | ||
| // No delta available (version gap). Don't send | ||
| // full-state — it can be tens of MB with long | ||
| // prompts and will exceed the gRPC message limit. | ||
| // The next checkpoint cycle will fold pending ops | ||
| // into tree_configs, resetting the delta window. | ||
| // The peer will receive the snapshot via the | ||
| // initial snapshot exchange (ping_server) or | ||
| // Phase 2 (tree_configs-only entries) instead. | ||
| debug!( | ||
| "Skipping full-state fallback for {} (version gap: last_sent={}, current={})", | ||
| key, last_sent_version, current_version | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Phase 2: Scan tree_configs for keys not yet emitted | ||
| // (e.g., after checkpoint + buffer drain, or remote-only entries). | ||
| // tree_configs already stores serialized TreeState bytes, so | ||
| // we use them directly as the PolicyState config instead of | ||
| // round-tripping through deserialize + re-serialize. | ||
| // Phase 2: Scan tree_configs for keys not yet emitted. | ||
| // Only emit keys that have NO pending ops — those are | ||
| // remote-only entries that can't be covered by deltas. | ||
| // Keys with pending ops are already covered by Phase 1 | ||
| // (delta or full-state fallback). | ||
| for entry in &self.stores.tree_configs { | ||
| let key = entry.key(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Nit: Stale comment — "Keys with pending ops are already covered by Phase 1 (delta or full-state fallback)." The full-state fallback was removed in this PR (replaced by the |
||
| if emitted_tree_keys.contains(key.as_str()) { | ||
| continue; | ||
| } | ||
| // Skip keys that have pending ops — Phase 1 handles them. | ||
| if self | ||
| .stores | ||
| .tree_ops_pending | ||
| .get(key.as_str()) | ||
| .is_some_and(|p| !p.is_empty()) | ||
| { | ||
| continue; | ||
| } | ||
| let current_version = self.stores.tree_version(key); | ||
| let last_sent_version = | ||
| last_sent.policy.get(key.as_str()).copied().unwrap_or(0); | ||
|
|
@@ -358,24 +347,18 @@ impl IncrementalUpdateCollector { | |
| if config_bytes.is_empty() { | ||
| continue; | ||
| } | ||
| // Validate the bytes are a valid TreeState and extract | ||
| // the version, but use the raw bytes directly as the | ||
| // config payload to avoid redundant re-serialization. | ||
| let tree_version = match TreeState::from_bytes(&config_bytes) { | ||
| Ok(ts) => ts.version, | ||
| Err(_) => { | ||
| debug!( | ||
| "Skipping tree_configs full-state for {} — config corrupted", | ||
| key | ||
| ); | ||
| continue; | ||
| } | ||
| }; | ||
| if kv_index::snapshot::TreeSnapshot::from_bytes(&config_bytes).is_err() { | ||
| debug!( | ||
| "Skipping tree_configs full-state for {} — config corrupted", | ||
| key | ||
| ); | ||
| continue; | ||
| } | ||
| let full_state = PolicyState { | ||
| model_id, | ||
| policy_type: "tree_state".to_string(), | ||
| policy_type: "tree_snapshot".to_string(), | ||
| config: config_bytes, | ||
| version: tree_version, | ||
| version: current_version, | ||
| }; | ||
| if let Ok(serialized) = bincode::serialize(&full_state) { | ||
| updates.push(StateUpdate { | ||
|
|
@@ -386,7 +369,7 @@ impl IncrementalUpdateCollector { | |
| timestamp, | ||
| }); | ||
| debug!( | ||
| "Collected tree_configs full state: {} (version: {})", | ||
| "Collected tree_configs full snapshot: {} (version: {})", | ||
| key, current_version | ||
| ); | ||
| } | ||
|
|
@@ -463,7 +446,10 @@ impl IncrementalUpdateCollector { | |
| updates | ||
| } | ||
|
|
||
| /// Collect all incremental updates across all stores | ||
| /// Collect all incremental updates across all stores. | ||
| /// | ||
| /// Tree updates are split into per-key batches so a single oversized | ||
| /// snapshot doesn't block delivery of small deltas or policy updates. | ||
|
Comment on lines
+333
to
+336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Splitting
Also applies to: 492-517 🤖 Prompt for AI Agents |
||
| pub fn collect_all_updates(&self) -> Vec<(StoreType, Vec<StateUpdate>)> { | ||
| let mut all_updates = Vec::new(); | ||
|
|
||
|
|
@@ -475,7 +461,33 @@ impl IncrementalUpdateCollector { | |
| StoreType::RateLimit, | ||
| ] { | ||
| let updates = self.collect_updates_for_store(store_type); | ||
| if !updates.is_empty() { | ||
| if updates.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| if store_type == StoreType::Policy { | ||
| // Split tree entries from policy entries so each gets | ||
| // its own batch with independent size checking. | ||
| // A 28 MB tree snapshot must not block 1 KB deltas. | ||
| let mut policy_updates = Vec::new(); | ||
| let mut tree_batches: Vec<Vec<StateUpdate>> = Vec::new(); | ||
|
|
||
| for update in updates { | ||
| if update.key.starts_with("tree:") { | ||
| // Each tree key gets its own batch | ||
| tree_batches.push(vec![update]); | ||
| } else { | ||
| policy_updates.push(update); | ||
| } | ||
| } | ||
|
|
||
| if !policy_updates.is_empty() { | ||
| all_updates.push((StoreType::Policy, policy_updates)); | ||
| } | ||
| for batch in tree_batches { | ||
| all_updates.push((StoreType::Policy, batch)); | ||
| } | ||
| } else { | ||
| all_updates.push((store_type, updates)); | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
capped_versionis computed aslast_sent_version + capped_ops.len()but this assumes a 1:1 mapping between ops and version increments. If the underlying version scheme ever changes (e.g., version bumps by more than 1 per op, or version is set explicitly), this will silently produce an incorrectnew_versionin the delta, which the receiver uses for its version check (delta.base_version > current_version || current_version >= delta.new_version).More importantly, when ops are capped, the remaining unsent ops will be picked up on the next collection cycle. But
last_sent_versionis updated tocapped_version(via the StateUpdate's version field). On the next cycle,already_sent = capped_version - base_version, which correctly advances past the already-sent ops only if version increments are exactly 1-per-op. Ifbase_versionchanges between cycles (e.g., due to a concurrent checkpoint that resets it), the offset calculation could skip or duplicate ops.Consider using
base_version + unsent_start + capped_ops.len()to be explicit about the absolute position, or add a debug assertion thatcapped_version <= current_version.