lean_ctx/core/context_store/
task_signals.rs1use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19const MAX_FILE_BYTES: u64 = 4 * 1024;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Signal {
23 Bounce,
24 EditFailure,
25 Expand,
26 Unattributed,
27 Observed,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub(crate) struct TaskSignals {
35 pub(crate) schema_version: u32,
36 pub bounce: u32,
37 pub edit_failure: u32,
38 pub expand: u32,
39 pub unattributed: u32,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub(crate) observer: Option<String>,
43}
44
45fn process_nonce() -> &'static str {
47 static NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
48 NONCE.get_or_init(|| {
49 let started = std::time::SystemTime::now()
50 .duration_since(std::time::UNIX_EPOCH)
51 .map_or(0, |elapsed| elapsed.as_nanos());
52 format!("{}-{started}", std::process::id())
53 })
54}
55
56pub(crate) type Origin = (String, String);
58
59pub(crate) fn current_origin() -> Option<Origin> {
61 let envelope = crate::core::task_spine::TaskSpine::current()?;
62 Some((
63 super::task_scope(envelope.tenant_id.as_ref(), &envelope.project_id),
64 envelope.task_id.as_str().to_owned(),
65 ))
66}
67
68fn gateway_dir() -> Option<PathBuf> {
69 crate::core::data_dir::lean_ctx_data_dir()
70 .ok()
71 .map(|dir| dir.join("gateway"))
72}
73
74fn signals_path(dir: &Path, (scope, task): &(String, String)) -> PathBuf {
75 let mut hasher = blake3::Hasher::new();
76 hasher.update(scope.as_bytes());
77 hasher.update(&[0]);
78 hasher.update(task.as_bytes());
79 let digest = hasher.finalize().to_hex();
80 dir.join("tasks")
81 .join(format!("{}.signals.json", &digest[..32]))
82}
83
84fn incomplete_marker(path: &Path) -> PathBuf {
86 path.with_extension("incomplete")
87}
88
89fn read(path: &Path) -> Option<TaskSignals> {
90 if std::fs::metadata(path).ok()?.len() > MAX_FILE_BYTES {
91 return None;
92 }
93 serde_json::from_slice::<TaskSignals>(&std::fs::read(path).ok()?)
94 .ok()
95 .filter(|signals| signals.schema_version == 1)
96}
97
98pub(crate) fn record_in(dir: &Path, origin: &Origin, signal: Signal) -> Result<(), String> {
99 record_as(dir, origin, signal, process_nonce())
100}
101
102fn record_as(dir: &Path, origin: &Origin, signal: Signal, process: &str) -> Result<(), String> {
103 let path = signals_path(dir, origin);
104 let _lock = crate::core::context_admission::receipt_store::lock_task_files(dir)
105 .ok_or("task index lock unavailable")?;
106 let mut signals = match std::fs::metadata(&path) {
108 Ok(_) => read(&path).unwrap_or(TaskSignals {
109 unattributed: 1,
110 ..TaskSignals::default()
111 }),
112 Err(_) => TaskSignals::default(),
113 };
114 signals.schema_version = 1;
115 let counter = match signal {
116 Signal::Bounce => Some(&mut signals.bounce),
117 Signal::EditFailure => Some(&mut signals.edit_failure),
118 Signal::Expand => Some(&mut signals.expand),
119 Signal::Unattributed => Some(&mut signals.unattributed),
120 Signal::Observed => match signals.observer.as_deref() {
121 Some(observer) if observer == process => return Ok(()),
122 Some(_) => Some(&mut signals.unattributed),
124 None => {
125 signals.observer = Some(process.to_owned());
126 None
127 }
128 },
129 };
130 if let Some(counter) = counter {
131 *counter = counter.saturating_add(1);
132 }
133 let bytes = serde_json::to_vec(&signals).map_err(|e| e.to_string())?;
134 crate::core::context_admission::receipt_store::write_atomic(dir, &path, &bytes)
138}
139
140pub fn record(origin: &Origin, signal: Signal) {
143 let Some(dir) = gateway_dir() else {
144 return;
145 };
146 if let Err(error) = record_in(&dir, origin, signal) {
147 tracing::debug!("task signal not recorded: {error}");
148 let marker = incomplete_marker(&signals_path(&dir, origin));
151 let _ = crate::core::context_admission::receipt_store::capacity::mark(&dir, &marker);
152 }
153}
154
155pub(crate) fn observe(origin: &Origin) {
157 static OBSERVED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<Origin>>> =
158 std::sync::OnceLock::new();
159 let first = OBSERVED
160 .get_or_init(Default::default)
161 .lock()
162 .map_or(true, |mut observed| observed.insert(origin.clone()));
163 if first {
164 let origin = origin.clone();
165 std::thread::spawn(move || record(&origin, Signal::Observed));
166 }
167}
168
169pub fn record_unattributed() {
172 if let Some(origin) = current_origin() {
173 record(&origin, Signal::Unattributed);
174 }
175}
176
177pub(crate) fn load_in(dir: &Path, origin: &Origin) -> Option<TaskSignals> {
178 let path = signals_path(dir, origin);
179 let unknown = TaskSignals {
180 unattributed: 1,
181 ..TaskSignals::default()
182 };
183 if incomplete_marker(&path).exists() {
184 return Some(read(&path).map_or(unknown, |signals| TaskSignals {
185 unattributed: signals.unattributed.max(1),
186 ..signals
187 }));
188 }
189 if !path.exists() {
190 return Some(TaskSignals::default());
191 }
192 read(&path).or(Some(unknown))
193}
194
195pub(crate) fn load(origin: &Origin) -> Option<TaskSignals> {
197 load_in(&gateway_dir()?, origin)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn events_count_per_task_and_scope_and_damage_means_unknown() {
206 let dir = tempfile::tempdir().expect("tempdir");
207 let task_a = ("[null,\"a\"]".to_owned(), "task-1".to_owned());
208 let task_b = ("[null,\"b\"]".to_owned(), "task-1".to_owned());
209 assert_eq!(load_in(dir.path(), &task_a), Some(TaskSignals::default()));
210 record_in(dir.path(), &task_a, Signal::Bounce).expect("record");
211 record_in(dir.path(), &task_a, Signal::Bounce).expect("record");
212 record_in(dir.path(), &task_a, Signal::EditFailure).expect("record");
213 let a = load_in(dir.path(), &task_a).expect("signals");
214 assert_eq!(
215 (a.bounce, a.edit_failure, a.expand, a.unattributed),
216 (2, 1, 0, 0)
217 );
218 assert_eq!(
219 load_in(dir.path(), &task_b),
220 Some(TaskSignals::default()),
221 "equal task ids in another project never share signals"
222 );
223
224 let path = signals_path(dir.path(), &task_b);
225 std::fs::create_dir_all(path.parent().expect("parent")).expect("dir");
226 std::fs::write(&path, "{broken").expect("damage");
227 assert_eq!(
228 load_in(dir.path(), &task_b).map(|s| s.unattributed),
229 Some(1)
230 );
231 }
232
233 #[test]
234 fn a_task_served_by_two_processes_or_with_a_lost_event_is_unknown() {
235 let dir = tempfile::tempdir().expect("tempdir");
236 let task = ("[null,\"p\"]".to_owned(), "task-1".to_owned());
237 record_as(dir.path(), &task, Signal::Observed, "proc-a").expect("observe");
238 record_as(dir.path(), &task, Signal::Observed, "proc-a").expect("observe again");
239 assert_eq!(
240 load_in(dir.path(), &task).map(|s| s.unattributed),
241 Some(0),
242 "one process sees every bounce of its task"
243 );
244 record_as(dir.path(), &task, Signal::Observed, "proc-b").expect("observe");
245 assert_eq!(
246 load_in(dir.path(), &task).map(|s| s.unattributed),
247 Some(1),
248 "a second process: bounces across them are invisible"
249 );
250
251 let lost = ("[null,\"p\"]".to_owned(), "task-2".to_owned());
252 std::fs::create_dir_all(dir.path().join("tasks")).expect("dir");
253 std::fs::write(incomplete_marker(&signals_path(dir.path(), &lost)), b"").expect("marker");
254 assert_eq!(
255 load_in(dir.path(), &lost).map(|s| s.unattributed),
256 Some(1),
257 "an event that could not be recorded is not a clean task"
258 );
259 }
260}