use std::{
sync::Arc,
sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
time::{Instant, SystemTime},
};
use arc_swap::ArcSwap;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use sqry_core::graph::CodeGraph;
use sqry_core::watch::{ChangeSet, LastIndexedGitState};
use sqry_nl::Translator;
use crate::error::DaemonError;
use super::state::{WorkspaceKey, WorkspaceState};
#[derive(Debug, Clone)]
pub struct PendingRebuild {
pub changes: ChangeSet,
pub enqueued_at: Instant,
pub git_state_at_enqueue: Option<LastIndexedGitState>,
}
#[derive(Debug)]
pub struct LoadedWorkspace {
pub key: WorkspaceKey,
pub graph: ArcSwap<CodeGraph>,
pub state: std::sync::atomic::AtomicU8,
pub last_accessed: RwLock<Instant>,
pub memory_bytes: AtomicUsize,
pub memory_high_water_bytes: AtomicUsize,
pub pinned: bool,
pub last_error: RwLock<Option<DaemonError>>,
pub last_good_at: RwLock<Option<SystemTime>>,
pub retry_count: AtomicU32,
pub rebuild_lane: tokio::sync::Mutex<Option<PendingRebuild>>,
pub rebuild_cancelled: AtomicBool,
pub rebuild_in_flight: AtomicBool,
pub last_indexed_git_state: RwLock<Option<LastIndexedGitState>>,
pub nl_translator: OnceCell<Arc<Translator>>,
}
impl LoadedWorkspace {
#[must_use]
pub fn new(key: WorkspaceKey, pinned: bool) -> Self {
Self {
key,
graph: ArcSwap::from_pointee(CodeGraph::new()),
state: std::sync::atomic::AtomicU8::new(WorkspaceState::Unloaded.as_u8()),
last_accessed: RwLock::new(Instant::now()),
memory_bytes: AtomicUsize::new(0),
memory_high_water_bytes: AtomicUsize::new(0),
pinned,
last_error: RwLock::new(None),
last_good_at: RwLock::new(None),
retry_count: AtomicU32::new(0),
rebuild_lane: tokio::sync::Mutex::new(None),
rebuild_cancelled: AtomicBool::new(false),
rebuild_in_flight: AtomicBool::new(false),
last_indexed_git_state: RwLock::new(None),
nl_translator: OnceCell::new(),
}
}
pub fn load_state(&self) -> WorkspaceState {
let raw = self.state.load(Ordering::Acquire);
WorkspaceState::from_u8(raw)
.unwrap_or_else(|| unreachable!("invalid WorkspaceState discriminant {raw}"))
}
pub fn store_state(&self, new_state: WorkspaceState) {
self.state.store(new_state.as_u8(), Ordering::Release);
}
pub fn update_memory(&self, new_bytes: usize) -> usize {
let prior = self.memory_bytes.swap(new_bytes, Ordering::AcqRel);
self.memory_high_water_bytes
.fetch_max(new_bytes, Ordering::Relaxed);
prior
}
pub fn touch(&self) {
*self.last_accessed.write() = Instant::now();
}
pub fn record_success(&self, at: SystemTime) {
*self.last_good_at.write() = Some(at);
*self.last_error.write() = None;
self.retry_count.store(0, Ordering::Release);
}
pub fn record_failure(&self, err: DaemonError) -> u32 {
*self.last_error.write() = Some(err);
self.retry_count.fetch_add(1, Ordering::AcqRel) + 1
}
#[doc(hidden)]
pub fn set_last_good_at_for_test(&self, at: Option<SystemTime>) {
*self.last_good_at.write() = at;
}
}
#[cfg(test)]
mod tests {
use std::{path::PathBuf, time::Duration};
use sqry_core::project::ProjectRootMode;
use super::*;
fn make_key() -> WorkspaceKey {
WorkspaceKey::new(
PathBuf::from("/repos/example"),
ProjectRootMode::GitRoot,
0x1,
)
}
#[test]
fn new_workspace_defaults() {
let ws = LoadedWorkspace::new(make_key(), false);
assert_eq!(ws.load_state(), WorkspaceState::Unloaded);
assert_eq!(ws.memory_bytes.load(Ordering::Relaxed), 0);
assert_eq!(ws.memory_high_water_bytes.load(Ordering::Relaxed), 0);
assert_eq!(ws.retry_count.load(Ordering::Relaxed), 0);
assert!(!ws.rebuild_cancelled.load(Ordering::Relaxed));
assert!(
!ws.rebuild_in_flight.load(Ordering::Relaxed),
"new workspace must start with in_flight=false (no runner)"
);
assert!(!ws.pinned);
assert!(ws.last_error.read().is_none());
assert!(ws.last_good_at.read().is_none());
}
#[test]
fn state_atomicity_round_trips() {
let ws = LoadedWorkspace::new(make_key(), false);
ws.store_state(WorkspaceState::Loading);
assert_eq!(ws.load_state(), WorkspaceState::Loading);
ws.store_state(WorkspaceState::Rebuilding);
assert_eq!(ws.load_state(), WorkspaceState::Rebuilding);
ws.store_state(WorkspaceState::Failed);
assert_eq!(ws.load_state(), WorkspaceState::Failed);
}
#[test]
fn update_memory_is_monotonic_high_water() {
let ws = LoadedWorkspace::new(make_key(), false);
assert_eq!(ws.update_memory(1_000), 0);
assert_eq!(ws.memory_bytes.load(Ordering::Relaxed), 1_000);
assert_eq!(ws.memory_high_water_bytes.load(Ordering::Relaxed), 1_000);
assert_eq!(ws.update_memory(5_000), 1_000);
assert_eq!(ws.memory_high_water_bytes.load(Ordering::Relaxed), 5_000);
assert_eq!(ws.update_memory(2_000), 5_000);
assert_eq!(ws.memory_bytes.load(Ordering::Relaxed), 2_000);
assert_eq!(
ws.memory_high_water_bytes.load(Ordering::Relaxed),
5_000,
"high-water mark must be monotonic across rebuilds with smaller graphs",
);
}
#[test]
fn record_failure_increments_retry_count() {
let ws = LoadedWorkspace::new(make_key(), false);
let err = || DaemonError::WorkspaceBuildFailed {
root: PathBuf::from("/repos/example"),
reason: "boom".into(),
};
assert_eq!(ws.record_failure(err()), 1);
assert_eq!(ws.record_failure(err()), 2);
assert_eq!(ws.record_failure(err()), 3);
assert!(ws.last_error.read().is_some());
}
#[test]
fn record_success_clears_error_and_resets_retry() {
let ws = LoadedWorkspace::new(make_key(), false);
let err = DaemonError::WorkspaceBuildFailed {
root: PathBuf::from("/repos/example"),
reason: "boom".into(),
};
assert_eq!(ws.record_failure(err), 1);
ws.record_success(SystemTime::now());
assert!(ws.last_error.read().is_none());
assert!(ws.last_good_at.read().is_some());
assert_eq!(ws.retry_count.load(Ordering::Relaxed), 0);
}
#[test]
fn touch_updates_last_accessed() {
let ws = LoadedWorkspace::new(make_key(), false);
let before = *ws.last_accessed.read();
std::thread::sleep(Duration::from_millis(5));
ws.touch();
let after = *ws.last_accessed.read();
assert!(after > before);
}
#[test]
fn pinned_flag_is_immutable_via_constructor() {
let pinned = LoadedWorkspace::new(make_key(), true);
assert!(pinned.pinned);
let unpinned = LoadedWorkspace::new(make_key(), false);
assert!(!unpinned.pinned);
}
}