1use std::{
4 collections::{HashMap, VecDeque},
5 fs::File,
6 io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
7 path::{Path, PathBuf},
8 sync::{
9 atomic::{AtomicBool, AtomicU64, Ordering},
10 Arc, Condvar, Mutex,
11 },
12 time::Instant,
13};
14
15use subc_control::{
16 ActivityActor, ActivityCursor, ActivityEntry, ActivityQueryRequest, ActivityQueryResponse,
17 ActivityRecordRequest, ACTIVITY_CURSOR_INCARNATION_MISMATCH, ACTIVITY_CURSOR_TOO_OLD,
18 ACTIVITY_KIND_FORBIDDEN, ACTIVITY_MALFORMED, ACTIVITY_RATE_LIMITED, ACTIVITY_SUBSCRIBER_LAGGED,
19 ACTIVITY_TOO_LARGE, ACTIVITY_UNAVAILABLE,
20};
21use tokio::sync::{mpsc, oneshot};
22
23const QUEUE_CAPACITY: usize = 64;
24const RING_CAPACITY: usize = 1024;
25const SUBSCRIBER_CAPACITY: usize = 256;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(default)]
30pub struct ActivityConfig {
31 pub max_age_days: u32,
32 pub max_total_mb: u32,
33}
34
35impl Default for ActivityConfig {
36 fn default() -> Self {
37 Self {
38 max_age_days: 30,
39 max_total_mb: 64,
40 }
41 }
42}
43
44impl ActivityConfig {
45 fn retention(self) -> cortexkit_log::Retention {
46 let total = self.max_total_mb.max(1);
47 let file_mb = total.div_ceil(256);
50 cortexkit_log::Retention {
51 max_file_mb: file_mb,
52 keep: (total / file_mb - 1) as u8,
53 max_age_days: self.max_age_days,
54 }
55 }
56}
57
58#[derive(Default)]
59struct SharedWriter {
60 writer: Mutex<Option<Writer>>,
61 ready: Condvar,
62 failed: AtomicBool,
63}
64
65impl SharedWriter {
66 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Option<Writer>>, &'static str> {
67 let mut writer = self.writer.lock().unwrap_or_else(|p| p.into_inner());
68 while writer.is_none() {
69 if self.failed.load(Ordering::Relaxed) {
70 return Err(ACTIVITY_UNAVAILABLE);
71 }
72 writer = self.ready.wait(writer).unwrap_or_else(|p| p.into_inner());
73 }
74 Ok(writer)
75 }
76}
77
78struct Subscriber {
79 sender: mpsc::Sender<ActivityEntry>,
80 terminal: Arc<Mutex<Option<&'static str>>>,
81}
82
83pub(crate) struct ActivitySubscription {
84 pub(crate) replay: VecDeque<ActivityEntry>,
85 pub(crate) receiver: mpsc::Receiver<ActivityEntry>,
86 terminal: Arc<Mutex<Option<&'static str>>>,
87}
88
89impl ActivitySubscription {
90 pub(crate) fn terminal_code(&self) -> Option<&'static str> {
91 *self.terminal.lock().unwrap_or_else(|p| p.into_inner())
92 }
93}
94
95pub(crate) fn validate(module: &str, request: &ActivityRecordRequest) -> Result<(), &'static str> {
96 let name = request
97 .kind
98 .strip_prefix(module)
99 .and_then(|s| s.strip_prefix('.'));
100 if !name.is_some_and(|name| identifier(name, 64)) {
101 return Err(ACTIVITY_KIND_FORBIDDEN);
102 }
103 let summary_len = request.summary.chars().count();
104 if summary_len == 0 {
105 return Err(ACTIVITY_MALFORMED);
106 }
107 if summary_len > 200 {
108 return Err(ACTIVITY_TOO_LARGE);
109 }
110 if let Some(subject) = &request.subject {
111 if subject.subject_type.len() > 32 || subject.id.chars().count() > 256 {
112 return Err(ACTIVITY_TOO_LARGE);
113 }
114 if !identifier(&subject.subject_type, 32) || subject.id.is_empty() {
115 return Err(ACTIVITY_MALFORMED);
116 }
117 }
118 if let Some(details) = &request.details {
119 let bytes = serde_json::to_vec(details).map_err(|_| ACTIVITY_MALFORMED)?;
120 if bytes.len() > 4096 {
121 return Err(ACTIVITY_TOO_LARGE);
122 }
123 }
124 Ok(())
125}
126
127fn identifier(value: &str, max: usize) -> bool {
128 !value.is_empty()
129 && value.len() <= max
130 && value
131 .bytes()
132 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
133}
134
135struct Append {
136 module: String,
137 request: ActivityRecordRequest,
138 actor: ActivityActor,
139 at_ms: u64,
140 reply: oneshot::Sender<Result<ActivityEntry, &'static str>>,
141}
142
143#[derive(Clone)]
145pub(crate) struct ActivityLedger {
146 sender: mpsc::Sender<Append>,
147 read_errors: Arc<AtomicU64>,
148 shared: Arc<SharedWriter>,
149}
150
151impl std::fmt::Debug for ActivityLedger {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("ActivityLedger").finish_non_exhaustive()
154 }
155}
156
157impl ActivityLedger {
158 pub(crate) fn read_errors(&self) -> u64 {
159 self.read_errors.load(Ordering::Relaxed)
160 }
161 #[cfg(test)]
162 pub(crate) fn remaining_capacity(&self) -> usize {
163 self.sender.capacity()
164 }
165 #[cfg(test)]
166 pub(crate) fn with_writer_lock(&self, work: impl FnOnce()) {
167 let _guard = self.shared.lock().unwrap();
168 work();
169 }
170 pub(crate) fn open(path: PathBuf, incarnation: String) -> Self {
171 Self::start(path, incarnation, QUEUE_CAPACITY, WriterHooks::default())
172 }
173
174 pub(crate) fn open_config(path: PathBuf, incarnation: String, config: ActivityConfig) -> Self {
175 Self::start_config(
176 path,
177 incarnation,
178 QUEUE_CAPACITY,
179 WriterHooks::default(),
180 config,
181 )
182 }
183
184 pub(crate) fn start(
185 path: PathBuf,
186 incarnation: String,
187 capacity: usize,
188 hooks: WriterHooks,
189 ) -> Self {
190 Self::start_config(
191 path,
192 incarnation,
193 capacity,
194 hooks,
195 ActivityConfig::default(),
196 )
197 }
198
199 fn start_config(
200 path: PathBuf,
201 incarnation: String,
202 capacity: usize,
203 hooks: WriterHooks,
204 config: ActivityConfig,
205 ) -> Self {
206 let (sender, mut receiver) = mpsc::channel::<Append>(capacity);
207 let read_errors = Arc::new(AtomicU64::new(0));
208 let writer_errors = read_errors.clone();
209 let shared = Arc::new(SharedWriter::default());
210 let worker = shared.clone();
211 let started = std::thread::Builder::new()
214 .name("activity-writer".into())
215 .spawn(move || {
216 let writer = Writer::open(path, incarnation, hooks, &writer_errors, config);
217 let pause = writer.hooks.pause_hook();
218 *worker.writer.lock().unwrap_or_else(|p| p.into_inner()) = Some(writer);
219 worker.ready.notify_all();
220 pause.wait();
221 while let Some(append) = receiver.blocking_recv() {
222 let mut guard = worker.lock().unwrap();
223 let result = guard.as_mut().unwrap().append(&append);
224 let _ = append.reply.send(result);
225 }
226 });
227 if let Err(error) = started {
228 tracing::warn!(%error, "activity writer unavailable");
229 let _guard = shared.writer.lock().unwrap_or_else(|p| p.into_inner());
230 shared.failed.store(true, Ordering::Relaxed);
231 shared.ready.notify_all();
232 }
233 Self {
234 sender,
235 read_errors,
236 shared,
237 }
238 }
239
240 pub(crate) async fn query(
241 &self,
242 request: ActivityQueryRequest,
243 ) -> Result<ActivityQueryResponse, &'static str> {
244 let shared = self.shared.clone();
245 let errors = self.read_errors.clone();
246 tokio::task::spawn_blocking(move || {
247 let pinned = {
248 let guard = shared.lock()?;
249 guard.as_ref().unwrap().capture_read()?
250 };
251 pinned.read(request, &errors)
252 })
253 .await
254 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
255 }
256
257 pub(crate) async fn subscribe(
258 &self,
259 since: Option<ActivityCursor>,
260 ) -> Result<ActivitySubscription, &'static str> {
261 let shared = self.shared.clone();
262 tokio::task::spawn_blocking(move || shared.lock()?.as_mut().unwrap().subscribe(since))
263 .await
264 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
265 }
266
267 pub(crate) async fn configure(&self, config: ActivityConfig) -> Result<(), &'static str> {
268 let shared = self.shared.clone();
269 tokio::task::spawn_blocking(move || shared.lock()?.as_mut().unwrap().configure(config))
270 .await
271 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
272 }
273
274 pub(crate) async fn record(
275 &self,
276 module: String,
277 request: ActivityRecordRequest,
278 actor: ActivityActor,
279 at_ms: u64,
280 ) -> Result<ActivityEntry, &'static str> {
281 let (reply, received) = oneshot::channel();
282 self.sender
283 .try_send(Append {
284 module,
285 request,
286 actor,
287 at_ms,
288 reply,
289 })
290 .map_err(|_| ACTIVITY_UNAVAILABLE)?;
291 received.await.unwrap_or(Err(ACTIVITY_UNAVAILABLE))
292 }
293}
294
295#[derive(Default)]
296pub(crate) struct WriterHooks {
297 #[cfg(test)]
298 pub(crate) open_failure: bool,
299 #[cfg(test)]
300 pub(crate) write_failure: bool,
301 #[cfg(test)]
302 pub(crate) pause: Option<Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>>,
303 #[cfg(test)]
304 pub(crate) read_pause: Option<Arc<(std::sync::Barrier, std::sync::Barrier)>>,
305 pub(crate) clock: Option<Arc<dyn Fn() -> u64 + Send + Sync>>,
306}
307
308impl WriterHooks {
309 fn pause_hook(&self) -> Self {
310 Self {
311 #[cfg(test)]
312 pause: self.pause.clone(),
313 ..Default::default()
314 }
315 }
316 fn wait(&self) {
317 #[cfg(test)]
318 if let Some(pause) = &self.pause {
319 let (lock, ready) = &**pause;
320 let mut paused = lock.lock().unwrap();
321 while *paused {
322 paused = ready.wait(paused).unwrap();
323 }
324 }
325 }
326}
327
328struct Bucket {
329 credit_ms: u64,
331 updated_ms: u64,
332}
333
334impl Bucket {
335 fn available(&mut self, now: u64) -> bool {
336 self.credit_ms = self
337 .credit_ms
338 .saturating_add(now.saturating_sub(self.updated_ms))
339 .min(6000);
340 self.updated_ms = self.updated_ms.max(now);
341 self.credit_ms >= 100
342 }
343}
344
345struct Writer {
346 sink: Option<cortexkit_log::LineSink>,
347 path: PathBuf,
348 config: ActivityConfig,
349 next_seq: u64,
350 first_seq: u64,
351 incarnation: String,
352 ring: VecDeque<ActivityEntry>,
353 subscribers: Vec<Subscriber>,
354 buckets: HashMap<String, Bucket>,
355 started: Instant,
356 hooks: WriterHooks,
357}
358
359impl Writer {
360 fn open(
361 path: PathBuf,
362 incarnation: String,
363 hooks: WriterHooks,
364 read_errors: &AtomicU64,
365 config: ActivityConfig,
366 ) -> Self {
367 let opened = (|| -> io::Result<_> {
368 #[cfg(test)]
369 if hooks.open_failure {
370 return Err(io::Error::other("injected open failure"));
371 }
372 let sink = open_sink(&path, config)?;
373 let highest = retained_max(&path, config.retention().keep, read_errors)?;
374 Ok((
375 sink,
376 highest
377 .checked_add(1)
378 .ok_or_else(|| io::Error::other("activity seq exhausted"))?,
379 ))
380 })();
381 let (sink, next_seq) = match opened {
382 Ok((sink, seq)) => (Some(sink), seq),
383 Err(error) => {
384 tracing::warn!(path = %path.display(), %error, "activity ledger unavailable");
385 (None, 1)
386 }
387 };
388 Self {
389 sink,
390 path,
391 config,
392 next_seq,
393 first_seq: next_seq,
394 incarnation,
395 ring: VecDeque::new(),
396 subscribers: Vec::new(),
397 buckets: HashMap::new(),
398 started: Instant::now(),
399 hooks,
400 }
401 }
402
403 fn append(&mut self, append: &Append) -> Result<ActivityEntry, &'static str> {
404 validate(&append.module, &append.request)?;
405 if self.sink.is_none() || self.next_seq == u64::MAX {
406 return Err(ACTIVITY_UNAVAILABLE);
407 }
408 let now = self.hooks.clock.as_ref().map_or_else(
409 || self.started.elapsed().as_millis() as u64,
410 |clock| clock(),
411 );
412 let bucket = self.buckets.entry(append.module.clone()).or_insert(Bucket {
413 credit_ms: 6000,
414 updated_ms: now,
415 });
416 if !bucket.available(now) {
417 return Err(ACTIVITY_RATE_LIMITED);
418 }
419 let entry = ActivityEntry {
420 seq: self.next_seq,
421 at_ms: append.at_ms,
422 daemon_incarnation: self.incarnation.clone(),
423 module: append.module.clone(),
424 kind: append.request.kind.clone(),
425 summary: append.request.summary.clone(),
426 subject: append.request.subject.clone(),
427 details: append.request.details.clone().unwrap_or_default(),
428 actor: append.actor.clone(),
429 };
430 let written = (|| -> io::Result<()> {
431 #[cfg(test)]
432 if self.hooks.write_failure {
433 return Err(io::Error::other("injected write failure"));
434 }
435 let bytes = serde_json::to_vec(&entry).map_err(io::Error::other)?;
436 self.sink
437 .as_mut()
438 .ok_or_else(|| io::Error::other("activity sink unavailable"))?
439 .write_line(&bytes)
440 })();
441 if let Err(error) = written {
442 self.sink = None;
445 tracing::warn!(kind = %entry.kind, %error, "activity append failed");
446 return Err(ACTIVITY_UNAVAILABLE);
447 }
448 bucket.credit_ms -= 100;
449 self.next_seq += 1;
450 self.ring.push_back(entry.clone());
451 if self.ring.len() > RING_CAPACITY {
452 self.ring.pop_front();
453 }
454 self.subscribers.retain(
455 |subscriber| match subscriber.sender.try_send(entry.clone()) {
456 Ok(()) => true,
457 Err(mpsc::error::TrySendError::Full(_)) => {
458 *subscriber
459 .terminal
460 .lock()
461 .unwrap_or_else(|p| p.into_inner()) = Some(ACTIVITY_SUBSCRIBER_LAGGED);
462 false
463 }
464 Err(mpsc::error::TrySendError::Closed(_)) => false,
465 },
466 );
467 Ok(entry)
468 }
469
470 fn subscribe(
471 &mut self,
472 since: Option<ActivityCursor>,
473 ) -> Result<ActivitySubscription, &'static str> {
474 if let Some(cursor) = &since {
475 if cursor.daemon_incarnation != self.incarnation {
476 return Err(ACTIVITY_CURSOR_INCARNATION_MISMATCH);
477 }
478 if let Some(first) = self.ring.front() {
483 if first.seq > self.first_seq && cursor.seq.saturating_add(1) < first.seq {
484 return Err(ACTIVITY_CURSOR_TOO_OLD);
485 }
486 }
487 }
488 let replay = self
489 .ring
490 .iter()
491 .filter(|entry| since.as_ref().is_some_and(|cursor| entry.seq > cursor.seq))
492 .cloned()
493 .collect();
494 let (sender, receiver) = mpsc::channel(SUBSCRIBER_CAPACITY);
495 let terminal = Arc::new(Mutex::new(None));
496 self.subscribers
497 .retain(|subscriber| !subscriber.sender.is_closed());
498 self.subscribers.push(Subscriber {
499 sender,
500 terminal: terminal.clone(),
501 });
502 Ok(ActivitySubscription {
503 replay,
504 receiver,
505 terminal,
506 })
507 }
508
509 fn configure(&mut self, config: ActivityConfig) -> Result<(), &'static str> {
510 if config == self.config {
511 return Ok(());
512 }
513 let sink = open_sink(&self.path, config).map_err(|error| {
514 tracing::warn!(%error, "activity retention update failed");
515 ACTIVITY_UNAVAILABLE
516 })?;
517 self.sink = Some(sink);
518 self.config = config;
519 Ok(())
520 }
521
522 fn capture_read(&self) -> Result<ActivityRead, &'static str> {
523 if self.sink.is_none() {
524 return Err(ACTIVITY_UNAVAILABLE);
525 }
526 let mut files = Vec::new();
527 for generation in 0..=self.config.retention().keep {
528 match File::open(generation_path(&self.path, generation)) {
529 Ok(file) => {
530 let len = file.metadata().map_err(|_| ACTIVITY_UNAVAILABLE)?.len();
531 files.push((file, len));
532 }
533 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
534 Err(_) => return Err(ACTIVITY_UNAVAILABLE),
535 }
536 }
537 Ok(ActivityRead {
538 files,
539 #[cfg(test)]
540 read_pause: self.hooks.read_pause.clone(),
541 head: ActivityCursor {
542 daemon_incarnation: self.incarnation.clone(),
543 seq: self.next_seq - 1,
544 },
545 })
546 }
547}
548
549struct ActivityRead {
550 files: Vec<(File, u64)>,
551 head: ActivityCursor,
552 #[cfg(test)]
553 read_pause: Option<Arc<(std::sync::Barrier, std::sync::Barrier)>>,
554}
555
556impl ActivityRead {
557 fn read(
558 self,
559 request: ActivityQueryRequest,
560 read_errors: &AtomicU64,
561 ) -> Result<ActivityQueryResponse, &'static str> {
562 #[cfg(test)]
563 if let Some(pause) = &self.read_pause {
564 pause.0.wait();
565 pause.1.wait();
566 }
567 let mut entries = Vec::new();
568 for (file, len) in self.files {
569 for line in BufReader::new(file.take(len)).split(b'\n') {
570 let line = line.map_err(|_| ACTIVITY_UNAVAILABLE)?;
571 let entry: ActivityEntry = match serde_json::from_slice(&line) {
572 Ok(entry) => entry,
573 Err(error) => {
574 read_errors.fetch_add(1, Ordering::Relaxed);
575 tracing::warn!(%error, "skipping unparseable activity line");
576 continue;
577 }
578 };
579 if request
580 .agent
581 .as_ref()
582 .is_some_and(|v| entry.actor.agent_id.as_ref() != Some(v))
583 || request
584 .project
585 .as_ref()
586 .is_some_and(|v| entry.actor.project_root.as_ref() != Some(v))
587 || request.module.as_ref().is_some_and(|v| &entry.module != v)
588 || request.kind.as_ref().is_some_and(|v| &entry.kind != v)
589 || request.since_ms.is_some_and(|v| entry.at_ms < v)
590 || request.until_ms.is_some_and(|v| entry.at_ms > v)
591 || request.cursor.is_some_and(|v| entry.seq >= v)
592 {
593 continue;
594 }
595 entries.push(entry);
596 }
597 }
598 entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.seq));
599 entries.truncate(request.limit as usize);
600 Ok(ActivityQueryResponse {
601 cursor: entries.last().map(|entry| entry.seq),
602 head: self.head,
603 entries,
604 })
605 }
606}
607
608fn open_sink(path: &Path, config: ActivityConfig) -> io::Result<cortexkit_log::LineSink> {
609 let mut sink = cortexkit_log::LineSink::open(path, config.retention())?;
610 let mut file = File::open(path)?;
613 if file.metadata()?.len() > 0 {
614 file.seek(SeekFrom::End(-1))?;
615 let mut last = [0];
616 file.read_exact(&mut last)?;
617 if last[0] != b'\n' {
618 sink.write_line(b"")?;
619 }
620 }
621 Ok(sink)
622}
623
624fn generation_path(path: &Path, generation: u8) -> PathBuf {
625 let mut name = path.as_os_str().to_owned();
626 if generation != 0 {
627 name.push(format!(".{generation}"));
628 }
629 PathBuf::from(name)
630}
631
632fn retained_max(path: &Path, keep: u8, read_errors: &AtomicU64) -> io::Result<u64> {
633 let mut highest = 0;
634 for generation in 0..=keep {
635 let file = match File::open(generation_path(path, generation)) {
636 Ok(file) => file,
637 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
638 Err(error) => return Err(error),
639 };
640 for line in BufReader::new(file).split(b'\n') {
641 match serde_json::from_slice::<ActivityEntry>(&line?) {
642 Ok(entry) => highest = highest.max(entry.seq),
643 Err(error) => {
644 read_errors.fetch_add(1, Ordering::Relaxed);
645 tracing::warn!(%error, "skipping unparseable activity line");
646 }
647 }
648 }
649 }
650 Ok(highest)
651}
652
653#[cfg(test)]
654#[path = "activity_tests.rs"]
655mod tests;