mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 21:41:09 +00:00
[AZ-651] [AZ-668] lost-link failsafe ladder + mapobjects persistence (batch 7)
AZ-651 (mission_executor lost-link ladder):
- LostLinkLadder pure-logic state machine (LinkOk -> Degraded -> Lost
-> LinkLostInFollow + MavlinkLost branch). Configurable thresholds
via LostLinkConfig.
- LostLinkCommandIssuer trait + MavlinkCommandIssuer production impl
emitting MAV_CMD_NAV_RETURN_TO_LAUNCH via MavlinkHandle::send_command.
- LostLinkDriver task wires the ladder to operator-link watch, MAVLink
LinkEvent broadcast, and optional target-follow signal. On RTL,
driver calls the issuer THEN MissionExecutorHandle::failsafe_trigger.
- failsafe_trigger(LinkLost | LinkLostInFollow) short-circuits FlyMission
-> Land via direct FSM state mutation + TransitionEvent emission;
Paused state is intentionally NOT overridden.
- Tests: 4/4 ACs locally green (degraded-no-rtl; lost-fires-once;
follow-grace; mavlink-loss-no-rtl) plus driver + FSM integration.
AZ-668 (mapobjects_store persistence):
- Snapshot serializable shape + Store::{to_snapshot,from_snapshot}
round trip.
- MapObjectsPersistence async trait + JsonSnapshotEngine default impl
(write to .tmp, sync_all, atomic rename, best-effort parent fsync).
- PersistenceError::{Corrupt, SchemaMismatch} surfaces explicit errors
on bad blob; PersistenceMetrics tracks last_snapshot_ts,
snapshot_size_bytes, snapshot_errors_total.
- MapObjectsStore::from_snapshot factory for crash recovery from the
composition root.
- Tests: 4/4 ACs locally green (round-trip; atomic rename ignores
partial .tmp; crash recovery preserves pending; corruption returns
explicit error) plus schema-mismatch + metrics smoke checks.
Quality gates:
- cargo fmt: clean.
- cargo clippy -p mission_executor -p mapobjects_store --tests: 0 warns.
- cargo test --workspace: all green.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,7 @@ use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use h3o::CellIndex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::error::Result;
|
||||
use shared::models::mapobject::{
|
||||
BundleFreshness, DiffKind, IgnoredItem, IgnoredItemSource, MapObject, MapObjectObservation,
|
||||
@@ -24,13 +25,15 @@ use uuid::Uuid;
|
||||
use super::h3_index::{cell_of, grid_disk, haversine_m, DEFAULT_K_RING, DEFAULT_RESOLUTION};
|
||||
use super::ignored::IgnoredSet;
|
||||
use super::passes::{bbox_contains, PassTracker, RegionBbox};
|
||||
use super::snapshot::{Snapshot, SnapshotMapObject};
|
||||
|
||||
/// Sync state machine surfaced to `scan_controller` + health aggregator.
|
||||
///
|
||||
/// See `_docs/02_document/components/mapobjects_store/description.md §3`.
|
||||
/// `Failed` is the bounded-retries-exhausted terminal state for the
|
||||
/// post-flight push (Frozen choice 7 / `description.md §7`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncState {
|
||||
/// Initial state at process boot; no hydrate has run yet.
|
||||
FreshBoot,
|
||||
@@ -453,6 +456,82 @@ impl Store {
|
||||
self.last_push_ts = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Materialise the in-memory state into a serializable [`Snapshot`].
|
||||
/// Open passes are intentionally NOT captured — they are transient
|
||||
/// in-flight state and should restart after a process restart.
|
||||
pub fn to_snapshot(&self, mission_id: String) -> Snapshot {
|
||||
let map_objects: Vec<SnapshotMapObject> = self
|
||||
.by_cell
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|o| SnapshotMapObject {
|
||||
id: o.id,
|
||||
h3_cell: u64::from(o.h3_cell),
|
||||
mgrs: o.mgrs.clone(),
|
||||
class: o.class.clone(),
|
||||
class_group: o.class_group.clone(),
|
||||
gps_lat: o.gps_lat,
|
||||
gps_lon: o.gps_lon,
|
||||
size_width_m: o.size_width_m,
|
||||
size_length_m: o.size_length_m,
|
||||
confidence: o.confidence,
|
||||
first_seen: o.first_seen,
|
||||
last_seen: o.last_seen,
|
||||
mission_id: o.mission_id.clone(),
|
||||
})
|
||||
.collect();
|
||||
let ignored_items: Vec<IgnoredItem> = self.ignored.items().cloned().collect();
|
||||
Snapshot {
|
||||
schema_version: Snapshot::CURRENT_SCHEMA_VERSION,
|
||||
mission_id,
|
||||
as_of: Utc::now(),
|
||||
map_objects,
|
||||
ignored_items,
|
||||
pending_observations: self.pending_observations.clone(),
|
||||
pending_ignored: self.pending_ignored.clone(),
|
||||
sync_state: self.sync_state,
|
||||
last_pull_ts: self.last_pull_ts,
|
||||
last_push_ts: self.last_push_ts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rehydrate from a [`Snapshot`]. Re-keys map objects into their
|
||||
/// canonical H3 buckets using the supplied config's resolution
|
||||
/// (so a snapshot taken at one resolution can be loaded into a
|
||||
/// store configured differently — the spatial buckets are rebuilt
|
||||
/// either way).
|
||||
pub fn from_snapshot(config: MapObjectsStoreConfig, snapshot: Snapshot) -> Result<Self> {
|
||||
let mut store = Self::new(config);
|
||||
for mo in snapshot.map_objects {
|
||||
let cell = cell_of(mo.gps_lat, mo.gps_lon, store.config.h3_resolution)?;
|
||||
store.by_cell.entry(cell).or_default().push(StoredMapObject {
|
||||
id: mo.id,
|
||||
h3_cell: cell,
|
||||
mgrs: mo.mgrs,
|
||||
class: mo.class,
|
||||
class_group: mo.class_group,
|
||||
gps_lat: mo.gps_lat,
|
||||
gps_lon: mo.gps_lon,
|
||||
size_width_m: mo.size_width_m,
|
||||
size_length_m: mo.size_length_m,
|
||||
confidence: mo.confidence,
|
||||
first_seen: mo.first_seen,
|
||||
last_seen: mo.last_seen,
|
||||
mission_id: mo.mission_id,
|
||||
});
|
||||
store.len += 1;
|
||||
}
|
||||
for item in snapshot.ignored_items {
|
||||
store.ignored.append(item);
|
||||
}
|
||||
store.pending_observations = snapshot.pending_observations;
|
||||
store.pending_ignored = snapshot.pending_ignored;
|
||||
store.sync_state = snapshot.sync_state;
|
||||
store.last_pull_ts = snapshot.last_pull_ts;
|
||||
store.last_push_ts = snapshot.last_push_ts;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Resolve a raw class string to its canonical group key.
|
||||
///
|
||||
/// The first class listed in a `similar_classes` group is the group
|
||||
|
||||
Reference in New Issue
Block a user