Skip to main content

subc_daemon/
activity.rs

1//! A bounded, single-writer activity ledger. File work never runs on a reader.
2
3use 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/// Bounds include the active file, not just rotated generations.
28#[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        // Retention allows at most 255 rotated files. Scale the file bound for
48        // larger budgets while keeping active + rotated capacity within total.
49        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/// Clones share the bounded queue and per-module rate credit until this daemon stops.
145#[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        // A dedicated thread also keeps filesystem stalls off Tokio's workers.
214        // Dropping the last sender ends it; shutdown never joins a stalled sink.
215        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    // One token is 100 ms of credit, avoiding floating-point boundary drift.
351    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            // A partial write must not be followed by another append until reopen
466            // repairs its boundary. Failed attempts also leave rate credit intact.
467            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            // After restart, a query's head cursor uses the new daemon's ID
504            // with the highest retained seq from the prior daemon. That cursor
505            // remains replayable until ring eviction would leave a gap; then
506            // query recovery is required rather than an incomplete replay.
507            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    // A killed append without a trailing newline must not join the next valid
636    // record into one unparseable line. Add the missing separator on reopen.
637    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;