mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 14:31:09 +00:00
[AZ-649] [AZ-674] [AZ-667] telemetry + vlm schema + mapobjects hydrate batch 6
AZ-649 mission_executor telemetry forwarding: - shared::models::telemetry::UavTelemetry canonical model - TelemetryForwarder with atomic ArcSwap snapshot + 3 lossy tokio::sync::broadcast channels (MissionExecutor, ScanController, MavlinkUplink) + per-consumer drop counters - MavlinkProjection::from_mavlink for HEARTBEAT/GLOBAL_POSITION_INT/ ATTITUDE/SYS_STATUS - spawn_mavlink_pump bridges mavlink_layer into the forwarder at the binary edge AZ-674 vlm_client schema validation + model_version tracking: - AssessmentParser owns schema validation + model-version state - wire::read_response_raw splits raw bytes from parsing so invalid payloads can be logged size-capped - VlmStatus gains an Inconclusive variant; exhaustive-match test guards downstream consumers - VlmPipelineStatus mirrors the new variant in shared::models::poi AZ-667 mapobjects_store hydrate + pending logs + cascade: - SyncState enum aligned with description.md (FreshBoot, Synced, CachedFallback, Degraded, Failed) - Store::hydrate(MapObjectsBundle) replaces in-memory map atomically; freshness=Stale -> CachedFallback - classify() + end_of_pass append MapObjectObservation events to pending_observations (New/Moved/Existing/RemovedCandidate) - apply_decline + LocalAppended ignored items append to pending_ignored - drain_pending() returns and clears both logs - cascade_mission(id) purges by_cell + IgnoredSet + pending logs - Health surface reports sync_state, pending_obs, pending_ign Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -69,6 +69,20 @@ impl IgnoredSet {
|
||||
pub fn items(&self) -> impl Iterator<Item = &IgnoredItem> {
|
||||
self.items.values()
|
||||
}
|
||||
|
||||
/// Drop every `IgnoredItem` whose `mission_id` matches the
|
||||
/// supplied id. Used by the `DELETE /missions/{id}` cascade
|
||||
/// (AZ-667 AC-5). The keyset is rebuilt from the surviving items
|
||||
/// because a single `(mgrs, class_group)` pair may still appear
|
||||
/// under a different mission.
|
||||
pub fn drop_by_mission(&mut self, mission_id: &str) {
|
||||
self.items.retain(|_, v| v.mission_id != mission_id);
|
||||
self.keys.clear();
|
||||
for item in self.items.values() {
|
||||
self.keys
|
||||
.insert((item.mgrs.clone(), item.class_group.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,13 +15,40 @@ use std::collections::HashMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use h3o::CellIndex;
|
||||
use shared::error::Result;
|
||||
use shared::models::mapobject::IgnoredItem;
|
||||
use shared::models::mapobject::{
|
||||
BundleFreshness, DiffKind, IgnoredItem, IgnoredItemSource, MapObject, MapObjectObservation,
|
||||
MapObjectsBundle,
|
||||
};
|
||||
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};
|
||||
|
||||
/// 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)]
|
||||
pub enum SyncState {
|
||||
/// Initial state at process boot; no hydrate has run yet.
|
||||
FreshBoot,
|
||||
/// Last pull / push succeeded against the central API.
|
||||
Synced,
|
||||
/// Last pull failed but the on-device cache was applied as a
|
||||
/// fallback. `scan_controller` MUST gate this on operator
|
||||
/// acknowledgement before takeoff.
|
||||
CachedFallback,
|
||||
/// Stale cache or transient push failure; new MapObject diff
|
||||
/// classifications are suppressed by `scan_controller`.
|
||||
Degraded,
|
||||
/// Bounded retries exhausted (post-flight push). Operator-visible
|
||||
/// warning; mission's central data integrity at risk until
|
||||
/// manually replayed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Per-detection input to `classify`. This bundles the georeferenced
|
||||
/// payload the architecture-level "detection" carries (gps, class, conf,
|
||||
/// size — see `system-flows.md §F7`) without forcing the shared
|
||||
@@ -38,6 +65,18 @@ pub struct ClassifyInput {
|
||||
pub confidence: f32,
|
||||
pub mission_id: String,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
/// Airframe identifier the detection originated from. Threaded into
|
||||
/// `MapObjectObservation::uav_id` for the post-flight push log
|
||||
/// (AZ-667). Empty string is acceptable for single-UAV deployments
|
||||
/// and unit tests; production callers (`scan_controller`) supply
|
||||
/// the configured UAV id.
|
||||
#[doc(alias = "uav")]
|
||||
pub uav_id: String,
|
||||
/// Monotonic clock reading at detection time. Threaded into
|
||||
/// `MapObjectObservation::observed_at_monotonic_ns` so observation
|
||||
/// ordering survives wallclock skew. `0` is acceptable when the
|
||||
/// caller has no monotonic source (e.g. unit tests).
|
||||
pub observed_at_monotonic_ns: u64,
|
||||
}
|
||||
|
||||
/// Configuration for the spatial-index + classification policy.
|
||||
@@ -139,6 +178,17 @@ pub struct Store {
|
||||
len: usize,
|
||||
ignored: IgnoredSet,
|
||||
passes: PassTracker,
|
||||
/// Append-only log of NEW / MOVED / EXISTING / REMOVED-CANDIDATE
|
||||
/// events for the post-flight push (AZ-667). Drained by
|
||||
/// `mission_client::push_mapobjects_diff` after landing — central
|
||||
/// writes mid-flight are forbidden (Frozen choice 6).
|
||||
pending_observations: Vec<MapObjectObservation>,
|
||||
/// Append-only log of locally-appended `IgnoredItem`s for the
|
||||
/// post-flight push (AZ-667).
|
||||
pending_ignored: Vec<IgnoredItem>,
|
||||
sync_state: SyncState,
|
||||
last_pull_ts: Option<DateTime<Utc>>,
|
||||
last_push_ts: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
@@ -149,6 +199,11 @@ impl Store {
|
||||
len: 0,
|
||||
ignored: IgnoredSet::new(),
|
||||
passes: PassTracker::new(),
|
||||
pending_observations: Vec::new(),
|
||||
pending_ignored: Vec::new(),
|
||||
sync_state: SyncState::FreshBoot,
|
||||
last_pull_ts: None,
|
||||
last_push_ts: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +223,13 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Append an `IgnoredItem` (operator declined a POI, or a hydrate
|
||||
/// from `mission_client` pulled it down).
|
||||
/// from `mission_client` pulled it down). When the item is
|
||||
/// `LocalAppended` it ALSO joins `pending_ignored` so the
|
||||
/// post-flight push surfaces it to central.
|
||||
pub fn append_ignored(&mut self, item: IgnoredItem) {
|
||||
if matches!(item.source, IgnoredItemSource::LocalAppended) {
|
||||
self.pending_ignored.push(item.clone());
|
||||
}
|
||||
self.ignored.append(item);
|
||||
}
|
||||
|
||||
@@ -188,6 +248,10 @@ impl Store {
|
||||
/// Close the pass over `bbox` and return objects in the region that
|
||||
/// were not observed since the pass started, excluding ignored
|
||||
/// objects. Returns an empty vec if no pass was open.
|
||||
///
|
||||
/// Each returned `RemovedCandidate` is also appended to the
|
||||
/// `pending_observations` log as a `DiffKind::RemovedCandidate`
|
||||
/// event so the post-flight push surfaces it to central.
|
||||
pub fn end_of_pass(&mut self, bbox: &RegionBbox) -> Vec<RemovedCandidate> {
|
||||
let Some(result) = self.passes.pass_end(bbox) else {
|
||||
return Vec::new();
|
||||
@@ -222,13 +286,173 @@ impl Store {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Mirror each removed candidate into the pending observation
|
||||
// log; lookup of the stored object's mission_id keeps the
|
||||
// observation traceable end-to-end.
|
||||
let ended_at = Utc::now();
|
||||
for r in &out {
|
||||
let mission_id = self.find_mission_id(r.id).unwrap_or_default();
|
||||
self.pending_observations.push(MapObjectObservation {
|
||||
id: r.id,
|
||||
h3_cell: u64::from(
|
||||
cell_of(r.gps_lat, r.gps_lon, self.config.h3_resolution)
|
||||
.expect("H3 cell lookup must succeed for stored coordinates"),
|
||||
),
|
||||
class: r.class.clone(),
|
||||
class_group: r.class_group.clone(),
|
||||
mission_id,
|
||||
uav_id: String::new(),
|
||||
observed_at_monotonic_ns: 0,
|
||||
observed_at_wallclock: ended_at,
|
||||
gps_lat: r.gps_lat,
|
||||
gps_lon: r.gps_lon,
|
||||
mgrs: r.mgrs.clone(),
|
||||
size_width_m: 0.0,
|
||||
size_length_m: 0.0,
|
||||
confidence: 0.0,
|
||||
diff_kind: DiffKind::RemovedCandidate,
|
||||
photo_ref: None,
|
||||
raw_evidence: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find_mission_id(&self, id: Uuid) -> Option<String> {
|
||||
self.by_cell.values().flatten().find_map(|o| {
|
||||
if o.id == id {
|
||||
Some(o.mission_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_passes(&self) -> usize {
|
||||
self.passes.open_passes()
|
||||
}
|
||||
|
||||
/// Number of unpushed local observations.
|
||||
pub fn pending_observations_count(&self) -> usize {
|
||||
self.pending_observations.len()
|
||||
}
|
||||
|
||||
/// Number of unpushed locally-declined items.
|
||||
pub fn pending_ignored_count(&self) -> usize {
|
||||
self.pending_ignored.len()
|
||||
}
|
||||
|
||||
pub fn sync_state(&self) -> SyncState {
|
||||
self.sync_state
|
||||
}
|
||||
|
||||
pub fn last_pull_ts(&self) -> Option<DateTime<Utc>> {
|
||||
self.last_pull_ts
|
||||
}
|
||||
|
||||
pub fn last_push_ts(&self) -> Option<DateTime<Utc>> {
|
||||
self.last_push_ts
|
||||
}
|
||||
|
||||
pub fn set_sync_state(&mut self, state: SyncState) {
|
||||
self.sync_state = state;
|
||||
}
|
||||
|
||||
/// Load the in-memory map from a central-pulled bundle. Replaces
|
||||
/// any existing entries (the bundle is authoritative). The
|
||||
/// sync_state moves to `Synced` for a fresh bundle or
|
||||
/// `CachedFallback` for a `Stale` one. `last_pull_ts` is set to
|
||||
/// `bundle.as_of`.
|
||||
pub fn hydrate(&mut self, bundle: MapObjectsBundle) -> Result<()> {
|
||||
self.by_cell.clear();
|
||||
self.len = 0;
|
||||
// Replace the IgnoredSet entirely — central is authoritative.
|
||||
self.ignored = IgnoredSet::new();
|
||||
let MapObjectsBundle {
|
||||
map_objects,
|
||||
ignored_items,
|
||||
as_of,
|
||||
freshness,
|
||||
..
|
||||
} = bundle;
|
||||
|
||||
for mo in map_objects {
|
||||
self.insert_hydrated(mo)?;
|
||||
}
|
||||
for item in ignored_items {
|
||||
self.ignored.append(item);
|
||||
}
|
||||
|
||||
self.sync_state = match freshness {
|
||||
Some(BundleFreshness::Stale) => SyncState::CachedFallback,
|
||||
_ => SyncState::Synced,
|
||||
};
|
||||
self.last_pull_ts = Some(as_of);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_hydrated(&mut self, mo: MapObject) -> Result<()> {
|
||||
let cell = cell_of(mo.gps_lat, mo.gps_lon, self.config.h3_resolution)?;
|
||||
self.by_cell.entry(cell).or_default().push(StoredMapObject {
|
||||
id: Uuid::new_v4(),
|
||||
h3_cell: cell,
|
||||
mgrs: mo.mgrs_key,
|
||||
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,
|
||||
});
|
||||
self.len += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain and return all pending observations + ignored items. The
|
||||
/// store's pending counts return to 0. Called by
|
||||
/// `mission_client::push_mapobjects_diff` post-flight.
|
||||
pub fn drain_pending(&mut self) -> (Vec<MapObjectObservation>, Vec<IgnoredItem>) {
|
||||
(
|
||||
std::mem::take(&mut self.pending_observations),
|
||||
std::mem::take(&mut self.pending_ignored),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cascade-delete every object, ignored entry, and pending log
|
||||
/// row whose `mission_id` matches. Mirrors the central
|
||||
/// `DELETE /missions/{id}` semantics.
|
||||
pub fn cascade_mission(&mut self, mission_id: &str) {
|
||||
let mut empty_cells = Vec::new();
|
||||
let mut removed = 0usize;
|
||||
for (cell, bucket) in self.by_cell.iter_mut() {
|
||||
let before = bucket.len();
|
||||
bucket.retain(|o| o.mission_id != mission_id);
|
||||
removed += before - bucket.len();
|
||||
if bucket.is_empty() {
|
||||
empty_cells.push(*cell);
|
||||
}
|
||||
}
|
||||
for c in empty_cells {
|
||||
self.by_cell.remove(&c);
|
||||
}
|
||||
self.len = self.len.saturating_sub(removed);
|
||||
self.ignored.drop_by_mission(mission_id);
|
||||
self.pending_observations
|
||||
.retain(|o| o.mission_id != mission_id);
|
||||
self.pending_ignored.retain(|i| i.mission_id != mission_id);
|
||||
}
|
||||
|
||||
/// Mark a post-flight push as acknowledged. Resets sync_state to
|
||||
/// `Synced` and records the push timestamp.
|
||||
pub fn mark_pushed_ok(&mut self) {
|
||||
self.sync_state = SyncState::Synced;
|
||||
self.last_push_ts = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Resolve a raw class string to its canonical group key.
|
||||
///
|
||||
/// The first class listed in a `similar_classes` group is the group
|
||||
@@ -282,7 +506,7 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
let classification = match best {
|
||||
Some((cell, idx, delta_m)) if delta_m >= self.config.move_threshold_m => {
|
||||
// MOVED — update stored position to the new observation.
|
||||
let bucket = self
|
||||
@@ -292,6 +516,8 @@ impl Store {
|
||||
let obj = &mut bucket[idx];
|
||||
let from_mgrs = obj.mgrs.clone();
|
||||
let id = obj.id;
|
||||
let class_group = obj.class_group.clone();
|
||||
let class = obj.class.clone();
|
||||
obj.gps_lat = input.gps_lat;
|
||||
obj.gps_lon = input.gps_lon;
|
||||
obj.mgrs = input.mgrs.clone();
|
||||
@@ -313,11 +539,19 @@ impl Store {
|
||||
});
|
||||
}
|
||||
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
|
||||
Ok(Classification::Moved {
|
||||
self.append_observation(
|
||||
id,
|
||||
query_cell,
|
||||
&class,
|
||||
&class_group,
|
||||
&input,
|
||||
DiffKind::Moved,
|
||||
);
|
||||
Classification::Moved {
|
||||
id,
|
||||
from_mgrs,
|
||||
to_mgrs: input.mgrs,
|
||||
})
|
||||
to_mgrs: input.mgrs.clone(),
|
||||
}
|
||||
}
|
||||
Some((cell, idx, _)) => {
|
||||
// EXISTING — just refresh last_seen.
|
||||
@@ -328,8 +562,11 @@ impl Store {
|
||||
let obj = &mut bucket[idx];
|
||||
obj.last_seen = input.observed_at;
|
||||
let id = obj.id;
|
||||
let class_group = obj.class_group.clone();
|
||||
let class = obj.class.clone();
|
||||
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
|
||||
Ok(Classification::Existing { id })
|
||||
self.append_observation(id, cell, &class, &class_group, &input, DiffKind::Existing);
|
||||
Classification::Existing { id }
|
||||
}
|
||||
None => {
|
||||
// NEW — insert.
|
||||
@@ -339,7 +576,7 @@ impl Store {
|
||||
h3_cell: query_cell,
|
||||
mgrs: input.mgrs.clone(),
|
||||
class: input.class.clone(),
|
||||
class_group: group,
|
||||
class_group: group.clone(),
|
||||
gps_lat: input.gps_lat,
|
||||
gps_lon: input.gps_lon,
|
||||
size_width_m: input.size_width_m,
|
||||
@@ -352,9 +589,52 @@ impl Store {
|
||||
self.by_cell.entry(query_cell).or_default().push(stored);
|
||||
self.len += 1;
|
||||
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
|
||||
Ok(Classification::New { id })
|
||||
self.append_observation(
|
||||
id,
|
||||
query_cell,
|
||||
&input.class,
|
||||
&group,
|
||||
&input,
|
||||
DiffKind::New,
|
||||
);
|
||||
Classification::New { id }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(classification)
|
||||
}
|
||||
|
||||
/// Build and append a `MapObjectObservation` to the post-flight
|
||||
/// push log. Called on every NEW / MOVED / EXISTING classification
|
||||
/// (the REMOVED-CANDIDATE variant is appended by `end_of_pass`).
|
||||
fn append_observation(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
cell: CellIndex,
|
||||
class: &str,
|
||||
class_group: &str,
|
||||
input: &ClassifyInput,
|
||||
diff_kind: DiffKind,
|
||||
) {
|
||||
self.pending_observations.push(MapObjectObservation {
|
||||
id,
|
||||
h3_cell: u64::from(cell),
|
||||
class: class.to_string(),
|
||||
class_group: class_group.to_string(),
|
||||
mission_id: input.mission_id.clone(),
|
||||
uav_id: input.uav_id.clone(),
|
||||
observed_at_monotonic_ns: input.observed_at_monotonic_ns,
|
||||
observed_at_wallclock: input.observed_at,
|
||||
gps_lat: input.gps_lat,
|
||||
gps_lon: input.gps_lon,
|
||||
mgrs: input.mgrs.clone(),
|
||||
size_width_m: input.size_width_m,
|
||||
size_length_m: input.size_length_m,
|
||||
confidence: input.confidence,
|
||||
diff_kind,
|
||||
photo_ref: None,
|
||||
raw_evidence: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +653,8 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
mission_id: "m1".into(),
|
||||
observed_at: Utc::now(),
|
||||
uav_id: "uav1".into(),
|
||||
observed_at_monotonic_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,34 +15,24 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mapobject::{IgnoredItem, IgnoredItemSource, MapObjectsBundle, RetentionScope};
|
||||
use shared::models::mapobject::{
|
||||
IgnoredItem, IgnoredItemSource, MapObjectObservation, MapObjectsBundle, RetentionScope,
|
||||
};
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
mod internal;
|
||||
|
||||
pub use internal::passes::RegionBbox;
|
||||
pub use internal::store::{Classification, ClassifyInput, MapObjectsStoreConfig, RemovedCandidate};
|
||||
|
||||
const NAME: &str = "mapobjects_store";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncState {
|
||||
/// Bundle pulled centrally and applied.
|
||||
Hydrated,
|
||||
/// Local-observed records exist but have not been pushed.
|
||||
Pending,
|
||||
/// Push acknowledged centrally.
|
||||
PushedOk,
|
||||
/// Push failed; will retry from `pending_pushes/`.
|
||||
PushDeferred,
|
||||
}
|
||||
pub use internal::passes::RegionBbox;
|
||||
pub use internal::store::{
|
||||
Classification, ClassifyInput, MapObjectsStoreConfig, RemovedCandidate, SyncState,
|
||||
};
|
||||
|
||||
/// Owns the in-memory map. Construct once at the composition root and
|
||||
/// share via the cloneable `MapObjectsStoreHandle`.
|
||||
@@ -176,32 +166,122 @@ impl MapObjectsStoreHandle {
|
||||
Ok(guard.end_of_pass(bbox))
|
||||
}
|
||||
|
||||
pub async fn dump_pending(&self) -> Result<MapObjectsBundle> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::dump_pending (AZ-667)",
|
||||
))
|
||||
/// Load the in-memory map from a central-pulled bundle. Replaces
|
||||
/// any existing entries — central is authoritative on hydrate.
|
||||
/// Sets `sync_state` to `Synced` for a fresh bundle or
|
||||
/// `CachedFallback` for one tagged `Stale`. See AZ-667 AC-1 / AC-2.
|
||||
pub fn hydrate(&self, bundle: MapObjectsBundle) -> Result<()> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
guard.hydrate(bundle)
|
||||
}
|
||||
|
||||
pub async fn hydrate(&self, _bundle: MapObjectsBundle) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::hydrate (AZ-667)",
|
||||
))
|
||||
/// Drain the pending observation + ignored append logs for the
|
||||
/// post-flight push. Counts return to zero. See AZ-667 AC-4.
|
||||
pub fn drain_pending(&self) -> Result<(Vec<MapObjectObservation>, Vec<IgnoredItem>)> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.drain_pending())
|
||||
}
|
||||
|
||||
pub async fn set_sync_state(&self, _state: SyncState) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::set_sync_state (AZ-667)",
|
||||
))
|
||||
/// Drop every record (indexed object, ignored entry, pending log
|
||||
/// row) whose `mission_id` matches the supplied id. Mirrors the
|
||||
/// central `DELETE /missions/{id}` cascade. See AZ-667 AC-5.
|
||||
pub fn cascade_mission(&self, mission_id: &str) -> Result<()> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
guard.cascade_mission(mission_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_state(&self, state: SyncState) -> Result<()> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
guard.set_sync_state(state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sync_state(&self) -> Result<SyncState> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.sync_state())
|
||||
}
|
||||
|
||||
pub fn pending_observations_count(&self) -> Result<usize> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.pending_observations_count())
|
||||
}
|
||||
|
||||
pub fn pending_ignored_count(&self) -> Result<usize> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.pending_ignored_count())
|
||||
}
|
||||
|
||||
pub fn last_pull_ts(&self) -> Result<Option<DateTime<Utc>>> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.last_pull_ts())
|
||||
}
|
||||
|
||||
pub fn last_push_ts(&self) -> Result<Option<DateTime<Utc>>> {
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
Ok(guard.last_push_ts())
|
||||
}
|
||||
|
||||
/// Record a successful post-flight push: sets sync_state to
|
||||
/// `Synced` and stores the wallclock as `last_push_ts`.
|
||||
pub fn mark_pushed_ok(&self) -> Result<()> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
|
||||
guard.mark_pushed_ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
match self.inner.lock() {
|
||||
Ok(guard) => ComponentHealth::green(NAME).with_detail(format!(
|
||||
"indexed_objects={} ignored={} open_passes={}",
|
||||
guard.len(),
|
||||
guard.ignored_len(),
|
||||
guard.open_passes(),
|
||||
)),
|
||||
Ok(guard) => {
|
||||
let level = match guard.sync_state() {
|
||||
SyncState::Degraded | SyncState::Failed => {
|
||||
ComponentHealth::red(NAME, "sync state degraded")
|
||||
}
|
||||
SyncState::CachedFallback => {
|
||||
ComponentHealth::yellow(NAME, "operating on cached fallback")
|
||||
}
|
||||
SyncState::FreshBoot | SyncState::Synced => ComponentHealth::green(NAME),
|
||||
};
|
||||
level.with_detail(format!(
|
||||
"sync={:?} indexed={} ignored={} open_passes={} pending_obs={} pending_ign={}",
|
||||
guard.sync_state(),
|
||||
guard.len(),
|
||||
guard.ignored_len(),
|
||||
guard.open_passes(),
|
||||
guard.pending_observations_count(),
|
||||
guard.pending_ignored_count(),
|
||||
))
|
||||
}
|
||||
Err(_) => ComponentHealth::red(NAME, "mutex poisoned"),
|
||||
}
|
||||
}
|
||||
@@ -234,6 +314,8 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
mission_id: "m1".into(),
|
||||
observed_at: Utc::now(),
|
||||
uav_id: "uav1".into(),
|
||||
observed_at_monotonic_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,8 +352,9 @@ mod tests {
|
||||
// Assert
|
||||
assert_eq!(health.level, shared::health::HealthLevel::Green);
|
||||
let detail = health.detail.as_deref().unwrap();
|
||||
assert!(detail.contains("indexed_objects=1"));
|
||||
assert!(detail.contains("indexed=1"));
|
||||
assert!(detail.contains("ignored=0"));
|
||||
assert!(detail.contains("open_passes=0"));
|
||||
assert!(detail.contains("pending_obs=1"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user