1#[path = "semantic_refresh/metrics.rs"]
2mod metrics;
3
4pub use metrics::{
5 SemanticRefreshMetrics, SemanticRefreshRunMetrics, SemanticRefreshRunOutcome,
6 SEMANTIC_REFRESH_RECENT_RUN_LIMIT,
7};
8
9use super::maintenance::{
10 validate_interval, MemoryMaintenanceContext, MemoryMaintenanceError, MemoryMaintenanceJob,
11 MemoryMaintenanceOutcome, ScheduledMemoryMaintenance,
12};
13use super::AgentMemory;
14use crate::durable_memory::{
15 DurableMemorySemanticRefreshCheckpoint, DurableMemorySemanticRefreshReceipt,
16 DurableMemorySemanticRefreshRun, SemanticRefreshEmbeddingCache,
17};
18use a3s_memory::vector::VectorMutationConsistency;
19use async_trait::async_trait;
20use metrics::SemanticRefreshRunObservation;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, RwLock};
23use std::time::Duration;
24use tokio_util::sync::CancellationToken;
25
26pub const SEMANTIC_REFRESH_JOB_NAME: &str = "v2_semantic_refresh";
28
29#[derive(Clone)]
39#[must_use = "a semantic refresh schedule does nothing until installed in maintenance options"]
40pub struct ScheduledSemanticRefresh {
41 interval: Duration,
42 state: Arc<RwLock<ScheduledSemanticRefreshState>>,
43 claimed: Arc<AtomicBool>,
44}
45
46#[derive(Default)]
47struct ScheduledSemanticRefreshState {
48 last_receipt: Option<DurableMemorySemanticRefreshReceipt>,
49 recovery_receipt: Option<DurableMemorySemanticRefreshReceipt>,
50 embedding_cache: Option<Arc<SemanticRefreshEmbeddingCache>>,
51 metrics: SemanticRefreshMetrics,
52}
53
54impl ScheduledSemanticRefresh {
55 pub fn try_new(interval: Duration) -> Result<Self, MemoryMaintenanceError> {
56 Self::try_new_inner(interval, None)
57 }
58
59 pub fn try_new_with_checkpoint(
65 interval: Duration,
66 checkpoint: DurableMemorySemanticRefreshCheckpoint,
67 ) -> Result<Self, MemoryMaintenanceError> {
68 checkpoint.verify().map_err(|error| {
69 invalid(
70 "semanticRefresh.checkpoint",
71 format!("failed validation: {error}"),
72 )
73 })?;
74 Self::try_new_inner(interval, Some(checkpoint.into_recovery_receipt()))
75 }
76
77 fn try_new_inner(
78 interval: Duration,
79 recovery_receipt: Option<DurableMemorySemanticRefreshReceipt>,
80 ) -> Result<Self, MemoryMaintenanceError> {
81 validate_interval(interval)?;
82 Ok(Self {
83 interval,
84 state: Arc::new(RwLock::new(ScheduledSemanticRefreshState {
85 recovery_receipt,
86 ..ScheduledSemanticRefreshState::default()
87 })),
88 claimed: Arc::new(AtomicBool::new(false)),
89 })
90 }
91
92 pub fn interval(&self) -> Duration {
93 self.interval
94 }
95
96 pub fn required_consistency(&self) -> VectorMutationConsistency {
97 VectorMutationConsistency::IndexRevisionCas
98 }
99
100 pub fn last_receipt(&self) -> Option<DurableMemorySemanticRefreshReceipt> {
108 read_unpoisoned(&self.state).last_receipt.clone()
109 }
110
111 pub fn metrics(&self) -> SemanticRefreshMetrics {
117 read_unpoisoned(&self.state).metrics.clone()
118 }
119
120 pub(super) fn validate_for(&self, memory: &AgentMemory) -> Result<(), MemoryMaintenanceError> {
121 let durable = memory.durable_memory().ok_or_else(|| {
122 invalid(
123 "semanticRefresh",
124 "requires an exact durable-memory binding",
125 )
126 })?;
127 let semantic = durable.semantic_recall().ok_or_else(|| {
128 invalid(
129 "semanticRefresh",
130 "requires an attached semantic recall generation",
131 )
132 })?;
133 let actual = semantic.mutation_consistency();
134 if actual != self.required_consistency() {
135 return Err(invalid(
136 "semanticRefresh.mutationConsistency",
137 format!(
138 "requires {:?}, but the backend provides {actual:?}",
139 self.required_consistency()
140 ),
141 ));
142 }
143 Ok(())
144 }
145
146 pub(super) fn try_claim(
147 &self,
148 ) -> Result<ScheduledSemanticRefreshClaim, MemoryMaintenanceError> {
149 let claim = self
150 .claimed
151 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
152 .map(|_| ScheduledSemanticRefreshClaim {
153 _lease: Arc::new(ScheduledSemanticRefreshClaimLease {
154 claimed: Arc::clone(&self.claimed),
155 state: Arc::clone(&self.state),
156 }),
157 })
158 .map_err(|_| MemoryMaintenanceError::SemanticRefreshAlreadyOwned)?;
159 let mut state = write_unpoisoned(&self.state);
160 let ownership_epoch = state.metrics.ownership_epoch().saturating_add(1);
161 let recovery_receipt = state.recovery_receipt.take();
162 *state = ScheduledSemanticRefreshState {
163 recovery_receipt,
164 metrics: SemanticRefreshMetrics::for_epoch(ownership_epoch),
165 ..ScheduledSemanticRefreshState::default()
166 };
167 Ok(claim)
168 }
169
170 pub(super) fn as_maintenance(
171 &self,
172 ) -> Result<ScheduledMemoryMaintenance, MemoryMaintenanceError> {
173 ScheduledMemoryMaintenance::try_new(
174 SEMANTIC_REFRESH_JOB_NAME,
175 self.interval,
176 Arc::new(SemanticRefreshJob {
177 state: Arc::clone(&self.state),
178 }),
179 )
180 }
181}
182
183#[derive(Clone)]
184pub(super) struct ScheduledSemanticRefreshClaim {
185 _lease: Arc<ScheduledSemanticRefreshClaimLease>,
186}
187
188struct ScheduledSemanticRefreshClaimLease {
189 claimed: Arc<AtomicBool>,
190 state: Arc<RwLock<ScheduledSemanticRefreshState>>,
191}
192
193impl Drop for ScheduledSemanticRefreshClaimLease {
194 fn drop(&mut self) {
195 write_unpoisoned(&self.state).embedding_cache = None;
199 self.claimed.store(false, Ordering::Release);
200 }
201}
202
203impl std::fmt::Debug for ScheduledSemanticRefresh {
204 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 let state = read_unpoisoned(&self.state);
206 formatter
207 .debug_struct("ScheduledSemanticRefresh")
208 .field("interval", &self.interval)
209 .field("required_consistency", &self.required_consistency())
210 .field("has_receipt", &state.last_receipt.is_some())
211 .field("has_recovery_checkpoint", &state.recovery_receipt.is_some())
212 .field("ownership_epoch", &state.metrics.ownership_epoch())
213 .field("attempted_runs", &state.metrics.attempted_runs())
214 .finish()
215 }
216}
217
218struct SemanticRefreshJob {
219 state: Arc<RwLock<ScheduledSemanticRefreshState>>,
220}
221
222#[async_trait]
223impl MemoryMaintenanceJob for SemanticRefreshJob {
224 async fn run(
225 &self,
226 context: &MemoryMaintenanceContext,
227 cancellation: CancellationToken,
228 ) -> anyhow::Result<MemoryMaintenanceOutcome> {
229 let durable = context
230 .durable_memory()
231 .ok_or_else(|| anyhow::anyhow!("scheduled semantic refresh binding is unavailable"))?;
232 let (previous, previous_cache, previous_requires_index_continuity) = {
233 let state = read_unpoisoned(&self.state);
234 let (previous, requires_continuity) = match state.last_receipt.as_ref() {
235 Some(receipt) => (Some(receipt.clone()), false),
236 None => (
237 state.recovery_receipt.clone(),
238 state.recovery_receipt.is_some(),
239 ),
240 };
241 (previous, state.embedding_cache.clone(), requires_continuity)
242 };
243 let attempt = durable
244 .refresh_semantic_recall_scheduled(
245 previous.as_ref(),
246 previous_cache.as_deref(),
247 previous_requires_index_continuity,
248 cancellation,
249 )
250 .await;
251 let outcome = match &attempt.result {
252 Ok(DurableMemorySemanticRefreshRun::Published { .. }) => {
253 SemanticRefreshRunOutcome::Published
254 }
255 Ok(DurableMemorySemanticRefreshRun::Unchanged(_)) => {
256 SemanticRefreshRunOutcome::Unchanged
257 }
258 Err(_) => SemanticRefreshRunOutcome::Failed,
259 };
260 let observation = SemanticRefreshRunObservation {
261 outcome,
262 elapsed: attempt.elapsed,
263 source_change_token_requests: attempt.work.source_change_token_requests,
264 source_change_token_observations: attempt.work.source_change_token_observations,
265 source_snapshot_requests: attempt.work.source_snapshot_requests,
266 source_snapshot_node_reads: attempt.work.source_snapshot_node_reads,
267 source_snapshot_bytes: attempt.work.source_snapshot_bytes,
268 embedding_cache_hits: attempt.work.embedding_cache_hits,
269 embedding_inputs: attempt.work.embedding_inputs,
270 embedding_input_bytes: attempt.work.embedding_input_bytes,
271 provider_requests: attempt.work.provider_requests,
272 provider_inputs: attempt.work.provider_inputs,
273 provider_input_bytes: attempt.work.provider_input_bytes,
274 publication_attempts: attempt.work.publication_attempts,
275 publication_records: attempt.work.publication_records,
276 };
277 let mut state = write_unpoisoned(&self.state);
278 state.metrics.record(observation);
279 let affected_items = match attempt.result {
280 Ok(DurableMemorySemanticRefreshRun::Published {
281 receipt,
282 embedding_cache,
283 }) => {
284 let affected_items = receipt.active_node_count();
285 state.last_receipt = Some(receipt);
286 state.recovery_receipt = None;
287 state.embedding_cache = embedding_cache;
288 affected_items
289 }
290 Ok(DurableMemorySemanticRefreshRun::Unchanged(receipt)) => {
291 state.last_receipt = Some(receipt);
292 state.recovery_receipt = None;
293 0
294 }
295 Err(error) => {
296 return Err(anyhow::anyhow!(error.redacted_message()));
297 }
298 };
299 Ok(MemoryMaintenanceOutcome::new(affected_items))
300 }
301}
302
303fn invalid(field: &'static str, reason: impl Into<String>) -> MemoryMaintenanceError {
304 MemoryMaintenanceError::InvalidConfiguration {
305 field,
306 reason: reason.into(),
307 }
308}
309
310fn read_unpoisoned<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
311 lock.read()
312 .unwrap_or_else(std::sync::PoisonError::into_inner)
313}
314
315fn write_unpoisoned<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
316 lock.write()
317 .unwrap_or_else(std::sync::PoisonError::into_inner)
318}