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: oneshot::Sender<Result<ActivityEntry, &'static str>>,
141}
142
143/// Clones share the bounded queue and per-module rate credit until this daemon stops.
144#[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        // A dedicated thread also keeps filesystem stalls off Tokio's workers.
212        // Dropping the last sender ends it; shutdown never joins a stalled sink.
213        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    // One token is 100 ms of credit, avoiding floating-point boundary drift.
330    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            // A partial write must not be followed by another append until reopen
443            // repairs its boundary. Failed attempts also leave rate credit intact.
444            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            // After restart, a query's head cursor uses the new daemon's ID
479            // with the highest retained seq from the prior daemon. That cursor
480            // remains replayable until ring eviction would leave a gap; then
481            // query recovery is required rather than an incomplete replay.
482            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    // A killed append without a trailing newline must not join the next valid
611    // record into one unparseable line. Add the missing separator on reopen.
612    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;