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: Option<oneshot::Sender<Result<ActivityEntry, &'static str>>>,
141 lifecycle: Option<lifecycle::PendingLifecycle>,
142}
143
144#[derive(Clone)]
146pub(crate) struct ActivityLedger {
147 sender: mpsc::Sender<Append>,
148 read_errors: Arc<AtomicU64>,
149 shared: Arc<SharedWriter>,
150 lifecycle_counters: Arc<lifecycle::LifecycleCounters>,
151}
152
153impl std::fmt::Debug for ActivityLedger {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.debug_struct("ActivityLedger").finish_non_exhaustive()
156 }
157}
158
159impl ActivityLedger {
160 pub(crate) fn read_errors(&self) -> u64 {
161 self.read_errors.load(Ordering::Relaxed)
162 }
163 #[cfg(test)]
164 pub(crate) fn remaining_capacity(&self) -> usize {
165 self.sender.capacity()
166 }
167 #[cfg(test)]
168 pub(crate) fn with_writer_lock(&self, work: impl FnOnce()) {
169 let _guard = self.shared.lock().unwrap();
170 work();
171 }
172 pub(crate) fn open(path: PathBuf, incarnation: String) -> Self {
173 Self::start(path, incarnation, QUEUE_CAPACITY, WriterHooks::default())
174 }
175
176 pub(crate) fn open_config(path: PathBuf, incarnation: String, config: ActivityConfig) -> Self {
177 Self::start_config(
178 path,
179 incarnation,
180 QUEUE_CAPACITY,
181 WriterHooks::default(),
182 config,
183 )
184 }
185
186 pub(crate) fn start(
187 path: PathBuf,
188 incarnation: String,
189 capacity: usize,
190 hooks: WriterHooks,
191 ) -> Self {
192 Self::start_config(
193 path,
194 incarnation,
195 capacity,
196 hooks,
197 ActivityConfig::default(),
198 )
199 }
200
201 fn start_config(
202 path: PathBuf,
203 incarnation: String,
204 capacity: usize,
205 hooks: WriterHooks,
206 config: ActivityConfig,
207 ) -> Self {
208 let (sender, mut receiver) = mpsc::channel::<Append>(capacity);
209 let read_errors = Arc::new(AtomicU64::new(0));
210 let writer_errors = read_errors.clone();
211 let shared = Arc::new(SharedWriter::default());
212 let worker = shared.clone();
213 let started = std::thread::Builder::new()
216 .name("activity-writer".into())
217 .spawn(move || {
218 let writer = Writer::open(path, incarnation, hooks, &writer_errors, config);
219 let pause = writer.hooks.pause_hook();
220 *worker.writer.lock().unwrap_or_else(|p| p.into_inner()) = Some(writer);
221 worker.ready.notify_all();
222 pause.wait();
223 while let Some(append) = receiver.blocking_recv() {
224 let mut append = append;
225 let mut guard = worker.lock().unwrap();
226 let writer = guard.as_mut().unwrap();
227 match writer.prepare_lifecycle(&mut append, &writer_errors) {
228 Ok(false) => {
229 append.lifecycle.as_mut().unwrap().completed = true;
230 continue;
231 }
232 Err(_) => continue,
233 Ok(true) => {}
234 }
235 let result = writer.append(&append);
236 if result.is_ok() {
237 if let Some(pending) = &mut append.lifecycle {
238 pending.completed = true;
239 }
240 }
241 if let Some(reply) = append.reply.take() {
242 let _ = reply.send(result);
243 }
244 }
245 });
246 if let Err(error) = started {
247 tracing::warn!(%error, "activity writer unavailable");
248 let _guard = shared.writer.lock().unwrap_or_else(|p| p.into_inner());
249 shared.failed.store(true, Ordering::Relaxed);
250 shared.ready.notify_all();
251 }
252 Self {
253 sender,
254 read_errors,
255 shared,
256 lifecycle_counters: Arc::default(),
257 }
258 }
259
260 pub(crate) async fn query(
261 &self,
262 request: ActivityQueryRequest,
263 ) -> Result<ActivityQueryResponse, &'static str> {
264 let shared = self.shared.clone();
265 let errors = self.read_errors.clone();
266 tokio::task::spawn_blocking(move || {
267 let pinned = {
268 let guard = shared.lock()?;
269 guard.as_ref().unwrap().capture_read()?
270 };
271 pinned.read(request, &errors)
272 })
273 .await
274 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
275 }
276
277 pub(crate) async fn subscribe(
278 &self,
279 since: Option<ActivityCursor>,
280 ) -> Result<ActivitySubscription, &'static str> {
281 let shared = self.shared.clone();
282 tokio::task::spawn_blocking(move || shared.lock()?.as_mut().unwrap().subscribe(since))
283 .await
284 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
285 }
286
287 pub(crate) async fn configure(&self, config: ActivityConfig) -> Result<(), &'static str> {
288 let shared = self.shared.clone();
289 tokio::task::spawn_blocking(move || shared.lock()?.as_mut().unwrap().configure(config))
290 .await
291 .unwrap_or(Err(ACTIVITY_UNAVAILABLE))
292 }
293
294 pub(crate) async fn record(
295 &self,
296 module: String,
297 request: ActivityRecordRequest,
298 actor: ActivityActor,
299 at_ms: u64,
300 ) -> Result<ActivityEntry, &'static str> {
301 let (reply, received) = oneshot::channel();
302 self.sender
303 .try_send(Append {
304 module,
305 request,
306 actor,
307 at_ms,
308 reply: Some(reply),
309 lifecycle: None,
310 })
311 .map_err(|_| ACTIVITY_UNAVAILABLE)?;
312 received.await.unwrap_or(Err(ACTIVITY_UNAVAILABLE))
313 }
314}
315
316#[derive(Default)]
317pub(crate) struct WriterHooks {
318 #[cfg(test)]
319 pub(crate) open_failure: bool,
320 #[cfg(test)]
321 pub(crate) write_failure: bool,
322 #[cfg(test)]
323 pub(crate) pause: Option<Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>>,
324 #[cfg(test)]
325 pub(crate) read_pause: Option<Arc<(std::sync::Barrier, std::sync::Barrier)>>,
326 pub(crate) clock: Option<Arc<dyn Fn() -> u64 + Send + Sync>>,
327}
328
329impl WriterHooks {
330 fn pause_hook(&self) -> Self {
331 Self {
332 #[cfg(test)]
333 pause: self.pause.clone(),
334 ..Default::default()
335 }
336 }
337 fn wait(&self) {
338 #[cfg(test)]
339 if let Some(pause) = &self.pause {
340 let (lock, ready) = &**pause;
341 let mut paused = lock.lock().unwrap();
342 while *paused {
343 paused = ready.wait(paused).unwrap();
344 }
345 }
346 }
347}
348
349struct Bucket {
350 credit_ms: u64,
352 updated_ms: u64,
353}
354
355impl Bucket {
356 fn available(&mut self, now: u64) -> bool {
357 self.credit_ms = self
358 .credit_ms
359 .saturating_add(now.saturating_sub(self.updated_ms))
360 .min(6000);
361 self.updated_ms = self.updated_ms.max(now);
362 self.credit_ms >= 100
363 }
364}
365
366struct Writer {
367 sink: Option<cortexkit_log::LineSink>,
368 path: PathBuf,
369 config: ActivityConfig,
370 next_seq: u64,
371 first_seq: u64,
372 incarnation: String,
373 ring: VecDeque<ActivityEntry>,
374 subscribers: Vec<Subscriber>,
375 buckets: HashMap<String, Bucket>,
376 started: Instant,
377 hooks: WriterHooks,
378}
379
380impl Writer {
381 fn open(
382 path: PathBuf,
383 incarnation: String,
384 hooks: WriterHooks,
385 read_errors: &AtomicU64,
386 config: ActivityConfig,
387 ) -> Self {
388 let opened = (|| -> io::Result<_> {
389 #[cfg(test)]
390 if hooks.open_failure {
391 return Err(io::Error::other("injected open failure"));
392 }
393 let sink = open_sink(&path, config)?;
394 let highest = retained_max(&path, config.retention().keep, read_errors)?;
395 Ok((
396 sink,
397 highest
398 .checked_add(1)
399 .ok_or_else(|| io::Error::other("activity seq exhausted"))?,
400 ))
401 })();
402 let (sink, next_seq) = match opened {
403 Ok((sink, seq)) => (Some(sink), seq),
404 Err(error) => {
405 tracing::warn!(path = %path.display(), %error, "activity ledger unavailable");
406 (None, 1)
407 }
408 };
409 Self {
410 sink,
411 path,
412 config,
413 next_seq,
414 first_seq: next_seq,
415 incarnation,
416 ring: VecDeque::new(),
417 subscribers: Vec::new(),
418 buckets: HashMap::new(),
419 started: Instant::now(),
420 hooks,
421 }
422 }
423
424 fn append(&mut self, append: &Append) -> Result<ActivityEntry, &'static str> {
425 if append.lifecycle.is_none() {
426 validate(&append.module, &append.request)?;
427 }
428 if self.sink.is_none() || self.next_seq == u64::MAX {
429 return Err(ACTIVITY_UNAVAILABLE);
430 }
431 let now = self.hooks.clock.as_ref().map_or_else(
432 || self.started.elapsed().as_millis() as u64,
433 |clock| clock(),
434 );
435 let bucket = self.buckets.entry(append.module.clone()).or_insert(Bucket {
436 credit_ms: 6000,
437 updated_ms: now,
438 });
439 if append.lifecycle.is_none() && !bucket.available(now) {
440 return Err(ACTIVITY_RATE_LIMITED);
441 }
442 let entry = ActivityEntry {
443 seq: self.next_seq,
444 at_ms: append.at_ms,
445 daemon_incarnation: self.incarnation.clone(),
446 module: append.module.clone(),
447 kind: append.request.kind.clone(),
448 summary: append.request.summary.clone(),
449 subject: append.request.subject.clone(),
450 details: append.request.details.clone().unwrap_or_default(),
451 actor: append.actor.clone(),
452 };
453 let written = (|| -> io::Result<()> {
454 #[cfg(test)]
455 if self.hooks.write_failure {
456 return Err(io::Error::other("injected write failure"));
457 }
458 let bytes = serde_json::to_vec(&entry).map_err(io::Error::other)?;
459 self.sink
460 .as_mut()
461 .ok_or_else(|| io::Error::other("activity sink unavailable"))?
462 .write_line(&bytes)
463 })();
464 if let Err(error) = written {
465 self.sink = None;
468 tracing::warn!(kind = %entry.kind, %error, "activity append failed");
469 return Err(ACTIVITY_UNAVAILABLE);
470 }
471 if append.lifecycle.is_none() {
472 bucket.credit_ms -= 100;
473 }
474 self.next_seq += 1;
475 self.ring.push_back(entry.clone());
476 if self.ring.len() > RING_CAPACITY {
477 self.ring.pop_front();
478 }
479 self.subscribers.retain(
480 |subscriber| match subscriber.sender.try_send(entry.clone()) {
481 Ok(()) => true,
482 Err(mpsc::error::TrySendError::Full(_)) => {
483 *subscriber
484 .terminal
485 .lock()
486 .unwrap_or_else(|p| p.into_inner()) = Some(ACTIVITY_SUBSCRIBER_LAGGED);
487 false
488 }
489 Err(mpsc::error::TrySendError::Closed(_)) => false,
490 },
491 );
492 Ok(entry)
493 }
494
495 fn subscribe(
496 &mut self,
497 since: Option<ActivityCursor>,
498 ) -> Result<ActivitySubscription, &'static str> {
499 if let Some(cursor) = &since {
500 if cursor.daemon_incarnation != self.incarnation {
501 return Err(ACTIVITY_CURSOR_INCARNATION_MISMATCH);
502 }
503 if let Some(first) = self.ring.front() {
508 if first.seq > self.first_seq && cursor.seq.saturating_add(1) < first.seq {
509 return Err(ACTIVITY_CURSOR_TOO_OLD);
510 }
511 }
512 }
513 let replay = self
514 .ring
515 .iter()
516 .filter(|entry| since.as_ref().is_some_and(|cursor| entry.seq > cursor.seq))
517 .cloned()
518 .collect();
519 let (sender, receiver) = mpsc::channel(SUBSCRIBER_CAPACITY);
520 let terminal = Arc::new(Mutex::new(None));
521 self.subscribers
522 .retain(|subscriber| !subscriber.sender.is_closed());
523 self.subscribers.push(Subscriber {
524 sender,
525 terminal: terminal.clone(),
526 });
527 Ok(ActivitySubscription {
528 replay,
529 receiver,
530 terminal,
531 })
532 }
533
534 fn configure(&mut self, config: ActivityConfig) -> Result<(), &'static str> {
535 if config == self.config {
536 return Ok(());
537 }
538 let sink = open_sink(&self.path, config).map_err(|error| {
539 tracing::warn!(%error, "activity retention update failed");
540 ACTIVITY_UNAVAILABLE
541 })?;
542 self.sink = Some(sink);
543 self.config = config;
544 Ok(())
545 }
546
547 fn capture_read(&self) -> Result<ActivityRead, &'static str> {
548 if self.sink.is_none() {
549 return Err(ACTIVITY_UNAVAILABLE);
550 }
551 let mut files = Vec::new();
552 for generation in 0..=self.config.retention().keep {
553 match File::open(generation_path(&self.path, generation)) {
554 Ok(file) => {
555 let len = file.metadata().map_err(|_| ACTIVITY_UNAVAILABLE)?.len();
556 files.push((file, len));
557 }
558 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
559 Err(_) => return Err(ACTIVITY_UNAVAILABLE),
560 }
561 }
562 Ok(ActivityRead {
563 files,
564 #[cfg(test)]
565 read_pause: self.hooks.read_pause.clone(),
566 head: ActivityCursor {
567 daemon_incarnation: self.incarnation.clone(),
568 seq: self.next_seq - 1,
569 },
570 })
571 }
572}
573
574struct ActivityRead {
575 files: Vec<(File, u64)>,
576 head: ActivityCursor,
577 #[cfg(test)]
578 read_pause: Option<Arc<(std::sync::Barrier, std::sync::Barrier)>>,
579}
580
581impl ActivityRead {
582 fn read(
583 self,
584 request: ActivityQueryRequest,
585 read_errors: &AtomicU64,
586 ) -> Result<ActivityQueryResponse, &'static str> {
587 #[cfg(test)]
588 if let Some(pause) = &self.read_pause {
589 pause.0.wait();
590 pause.1.wait();
591 }
592 let mut entries = Vec::new();
593 for (file, len) in self.files {
594 for line in BufReader::new(file.take(len)).split(b'\n') {
595 let line = line.map_err(|_| ACTIVITY_UNAVAILABLE)?;
596 let entry: ActivityEntry = match serde_json::from_slice(&line) {
597 Ok(entry) => entry,
598 Err(error) => {
599 read_errors.fetch_add(1, Ordering::Relaxed);
600 tracing::warn!(%error, "skipping unparseable activity line");
601 continue;
602 }
603 };
604 if request
605 .agent
606 .as_ref()
607 .is_some_and(|v| entry.actor.agent_id.as_ref() != Some(v))
608 || request
609 .project
610 .as_ref()
611 .is_some_and(|v| entry.actor.project_root.as_ref() != Some(v))
612 || request.module.as_ref().is_some_and(|v| &entry.module != v)
613 || request.kind.as_ref().is_some_and(|v| &entry.kind != v)
614 || request.since_ms.is_some_and(|v| entry.at_ms < v)
615 || request.until_ms.is_some_and(|v| entry.at_ms > v)
616 || request.cursor.is_some_and(|v| entry.seq >= v)
617 {
618 continue;
619 }
620 entries.push(entry);
621 }
622 }
623 entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.seq));
624 entries.truncate(request.limit as usize);
625 Ok(ActivityQueryResponse {
626 cursor: entries.last().map(|entry| entry.seq),
627 head: self.head,
628 entries,
629 })
630 }
631}
632
633fn open_sink(path: &Path, config: ActivityConfig) -> io::Result<cortexkit_log::LineSink> {
634 let mut sink = cortexkit_log::LineSink::open(path, config.retention())?;
635 let mut file = File::open(path)?;
638 if file.metadata()?.len() > 0 {
639 file.seek(SeekFrom::End(-1))?;
640 let mut last = [0];
641 file.read_exact(&mut last)?;
642 if last[0] != b'\n' {
643 sink.write_line(b"")?;
644 }
645 }
646 Ok(sink)
647}
648
649fn generation_path(path: &Path, generation: u8) -> PathBuf {
650 let mut name = path.as_os_str().to_owned();
651 if generation != 0 {
652 name.push(format!(".{generation}"));
653 }
654 PathBuf::from(name)
655}
656
657fn retained_max(path: &Path, keep: u8, read_errors: &AtomicU64) -> io::Result<u64> {
658 let mut highest = 0;
659 for generation in 0..=keep {
660 let file = match File::open(generation_path(path, generation)) {
661 Ok(file) => file,
662 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
663 Err(error) => return Err(error),
664 };
665 for line in BufReader::new(file).split(b'\n') {
666 match serde_json::from_slice::<ActivityEntry>(&line?) {
667 Ok(entry) => highest = highest.max(entry.seq),
668 Err(error) => {
669 read_errors.fetch_add(1, Ordering::Relaxed);
670 tracing::warn!(%error, "skipping unparseable activity line");
671 }
672 }
673 }
674 }
675 Ok(highest)
676}
677
678#[cfg(test)]
679#[path = "activity_tests.rs"]
680mod tests;
681
682#[path = "activity_lifecycle.rs"]
683mod lifecycle;
684
685pub(crate) use lifecycle::{ImageFacts, RegistrationImage};
686
687#[cfg(test)]
688#[path = "activity_lifecycle_tests.rs"]
689mod lifecycle_tests;