Skip to main content

objectiveai_cli/db/logs/
writer.rs

1//! `LogWriter<C>` — postgres log writer fronted by an mpsc sender.
2//!
3//! Architecturally:
4//!
5//! - The constructor (`write_agent_completion` etc.) spawns a tokio
6//!   task that owns the [`LogWriterState`] and the
7//!   `mpsc::UnboundedReceiver<C>`. It returns the [`LogWriter`]
8//!   handle plus a [`tokio::sync::oneshot::Receiver<String>`] that
9//!   fires the first time the writer learns the stream's primary
10//!   `response_id`.
11//! - [`LogWriter::write`] is **synchronous** — it just hands the
12//!   chunk to the listener task via `UnboundedSender::send`. The
13//!   caller's chunk-yield hot path stays off the DB write critical
14//!   path.
15//! - The listener task `.push()`-folds every chunk into one
16//!   stream-wide accumulator and runs the persistence logic
17//!   ([`LogWriterState::apply_chunk`]) against that cumulative
18//!   aggregate — not a per-batch slice. Draining the queue per loop
19//!   iteration only collapses how often the pass runs. Folding is
20//!   correct because each tier's chunk is a cumulative roll-up of
21//!   state — `push` folds the later chunk's deltas into the earlier
22//!   one's accumulators (`AgentCompletionChunk::push` /
23//!   `VectorCompletionChunk::push` / `FunctionExecutionChunk::push`) —
24//!   so a row whose fields stream across several wire chunks (a tool
25//!   call's id/name/arguments, streamed content text) is always
26//!   persisted from its complete body, never a partial fragment.
27//! - [`LogWriter::finalize`] consumes the writer by value, drops the
28//!   sender, and `.await`s the JoinHandle. By the time it returns,
29//!   both invariants hold: the channel is empty (sender dropped →
30//!   `recv()` returned `None` only after every queued chunk was
31//!   consumed) and the task's future has fully completed (no
32//!   in-flight row-bucket joins or blob writes).
33//!
34//! Persistence pass (run per drain batch against the cumulative
35//! accumulator):
36//!
37//! 1. **First pass**: capture the accumulator's `response_id`, INSERT
38//!    the request blob (no `agent_instance_hierarchy` on the blob —
39//!    that linkage lives in `objectiveai.messages`).
40//! 2. **Every pass**: walk `chunk_rows(acc)` over the cumulative
41//!    aggregate, gate each yielded [`RowValue`] through the shadow
42//!    (Skip path is pure-memory — unchanged rows cost nothing), bucket
43//!    the survivors by `agent_instance_hierarchy`. For every agent the
44//!    writer hasn't seen yet in this stream's lifetime, prepend a
45//!    `objectiveai.messages` row that registers the request blob in
46//!    that agent's history.
47//! 3. **Per-bucket execution**: rows within one agent's bucket fire
48//!    sequentially (so the per-agent ORDER BY `"index"` matches the
49//!    iterator's order). All buckets fire concurrently via
50//!    `try_join_all`.
51//!
52//! The response blob is written separately, exactly once, by
53//! `listener_loop` after the last chunk — from the same cumulative
54//! accumulator, so blob and rows can never disagree.
55
56use std::collections::{HashMap, HashSet};
57use std::future::Future;
58use std::marker::PhantomData;
59use std::pin::Pin;
60
61use objectiveai_sdk::agent::completions::request::AgentCompletionCreateParams;
62use objectiveai_sdk::agent::completions::response::streaming::{
63    AgentCompletionChunk, AgentCompletionIds,
64};
65use objectiveai_sdk::functions::executions::request::FunctionExecutionCreateParams;
66use objectiveai_sdk::functions::executions::response::streaming::FunctionExecutionChunk;
67use objectiveai_sdk::vector::completions::request::VectorCompletionCreateParams;
68use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
69use serde::Serialize;
70use tokio::sync::{mpsc, oneshot, watch};
71use tokio::task::JoinHandle;
72
73use crate::db::Pool;
74
75use super::row::{RowValue, WriterItem, WriterItems};
76use super::rows::{
77    agent_completion_chunk_rows, function_execution_chunk_rows,
78    vector_completion_chunk_rows,
79};
80use super::shadow::{Shadow, WriteOp};
81use super::write::{
82    Tier, insert_request_blob, insert_request_messages_row, insert_response_blob,
83    update_agent_token_usage, write_value,
84};
85
86pub trait WriterChunk {
87    fn primary_id(&self) -> &str;
88}
89
90impl WriterChunk for AgentCompletionChunk {
91    fn primary_id(&self) -> &str {
92        self.id.as_str()
93    }
94}
95impl WriterChunk for VectorCompletionChunk {
96    fn primary_id(&self) -> &str {
97        self.id.as_str()
98    }
99}
100impl WriterChunk for FunctionExecutionChunk {
101    fn primary_id(&self) -> &str {
102        self.id.as_str()
103    }
104}
105
106/// CLI-side wrapper exposing the SDK's intrinsic `push(&mut self,
107/// other: &Self)` method via a uniform trait. Each impl simply
108/// delegates to the chunk type's inherent method — the SDK already
109/// guarantees `push` is a correct accumulator for the tier's
110/// cumulative-state semantics.
111pub trait ChunkPush {
112    fn push(&mut self, other: &Self);
113}
114
115impl ChunkPush for AgentCompletionChunk {
116    fn push(&mut self, other: &Self) {
117        AgentCompletionChunk::push(self, other);
118    }
119}
120impl ChunkPush for VectorCompletionChunk {
121    fn push(&mut self, other: &Self) {
122        VectorCompletionChunk::push(self, other);
123    }
124}
125impl ChunkPush for FunctionExecutionChunk {
126    fn push(&mut self, other: &Self) {
127        FunctionExecutionChunk::push(self, other);
128    }
129}
130
131/// Background-task-fronted log writer.
132///
133/// Construction (via `write_agent_completion` etc.) spawns a tokio
134/// task that owns the per-stream [`LogWriterState`]. The handle here
135/// is a thin sender + JoinHandle pair.
136pub struct LogWriter<C> {
137    tx: mpsc::UnboundedSender<C>,
138    handle: JoinHandle<Result<(), crate::error::Error>>,
139    /// Toggled `false` → `true` by the listener task once it has
140    /// completed a single successful `apply_chunk`. Powers
141    /// [`LogWriter::written_once`] (sync peek) and
142    /// [`LogWriter::wait_written_once`] (async wait).
143    written_rx: watch::Receiver<bool>,
144    _chunk: PhantomData<fn() -> C>,
145}
146
147impl<C> LogWriter<C> {
148    /// Hand off one chunk to the listener task. Returns
149    /// `Err(Error::Instance(_))` only when the listener has already
150    /// exited — typically because an earlier DB write failed. The
151    /// caller should treat that error the same way it would treat an
152    /// upstream stream error: stop reading, surface upward.
153    pub fn write(&self, chunk: C) -> Result<(), crate::error::Error> {
154        self.tx
155            .send(chunk)
156            .map_err(|_| crate::error::Error::Instance(
157                "log writer task has exited (earlier write failed)".to_string(),
158            ))
159    }
160
161    /// Sync peek: has the listener completed at least one successful
162    /// `apply_chunk` batch? Flips `false → true` exactly once,
163    /// immediately after the first batch's write completes and
164    /// before the listener parks on the next `recv`.
165    pub fn written_once(&self) -> bool {
166        *self.written_rx.borrow()
167    }
168
169    /// Async wait that resolves once the listener has completed its
170    /// first successful `apply_chunk` batch. Returns immediately if
171    /// that already happened. Errors only if the listener task
172    /// exited before its first successful write (DB error on the
173    /// very first batch).
174    pub async fn wait_written_once(&self) -> Result<(), crate::error::Error> {
175        let mut rx = self.written_rx.clone();
176        rx.wait_for(|b| *b)
177            .await
178            .map(|_| ())
179            .map_err(|_| crate::error::Error::Instance(
180                "log writer task exited before completing its first write".to_string(),
181            ))
182    }
183
184    /// Consume the writer. Drops the sender (signaling EOF to the
185    /// listener) and awaits the task. Returns only once:
186    ///
187    /// - the channel is empty: `recv()` returns `None` only after the
188    ///   listener has drained every queued chunk, AND
189    /// - no work is in flight: the task's future has fully completed,
190    ///   so no row-bucket joins or blob writes remain pending.
191    ///
192    /// Surfaces the first DB error the task encountered, if any.
193    pub async fn finalize(self) -> Result<(), crate::error::Error> {
194        let LogWriter { tx, handle, .. } = self;
195        drop(tx);
196        match handle.await {
197            Ok(inner) => inner,
198            Err(e) => Err(crate::error::Error::Instance(
199                format!("log writer task: {e}"),
200            )),
201        }
202    }
203}
204
205/// All the per-stream state the listener task owns. Was previously
206/// inlined onto `LogWriter`; now lives entirely inside the spawned
207/// task so the handle stays send-and-clone-cheap.
208struct LogWriterState<C> {
209    pool: Pool,
210    tier: Tier,
211    request_body: serde_json::Value,
212    /// AIH of the caller who issued the request that spawned this
213    /// writer (pulled from `ctx.config.agent_instance_hierarchy` at
214    /// `spawn_writer` time). Written into the request blob row at
215    /// `insert_request_blob` time. Constant for the writer's
216    /// lifetime — one request = one sender.
217    sender_agent_instance_hierarchy: String,
218    /// Walks a chunk into [`WriterItem`]s — content rows AND, at each
219    /// nested agent completion with usage, a per-AIH `total_tokens`
220    /// snapshot. One traversal covers both.
221    items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
222    /// Last `total_tokens` written per AIH — dedups redundant
223    /// overwrites across the writer's repeated passes over the
224    /// cumulative accumulator.
225    last_usage: HashMap<String, u64>,
226    primary_id: Option<String>,
227    /// Per-streaming-content-row shadow. Skip path is allocation-free.
228    shadow: Shadow,
229    /// Every `agent_instance_hierarchy` we've observed in this
230    /// stream's lifetime. The first time an agent appears in the row
231    /// iterator we insert a `objectiveai.messages` row registering the
232    /// request blob in that agent's history; subsequent ticks see
233    /// the agent already-marked and skip the registration.
234    seen_agents: HashSet<String>,
235    _chunk: PhantomData<fn() -> C>,
236}
237
238impl<C> LogWriterState<C> {
239    fn new(
240        pool: Pool,
241        tier: Tier,
242        request_body: serde_json::Value,
243        sender_agent_instance_hierarchy: String,
244        items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
245    ) -> Self {
246        Self {
247            pool,
248            tier,
249            request_body,
250            sender_agent_instance_hierarchy,
251            items_fn,
252            last_usage: HashMap::new(),
253            primary_id: None,
254            shadow: Shadow::new(),
255            seen_agents: HashSet::new(),
256            _chunk: PhantomData,
257        }
258    }
259
260    /// Persist the cumulative aggregate's streaming-content rows
261    /// (`listener_loop` hands in the stream-wide accumulator, not a
262    /// per-batch slice, so every row is seen with its complete body).
263    /// The response_id and request blob are established by
264    /// `listener_loop` before the first call; the response blob is
265    /// written by `listener_loop` after the last chunk. This method
266    /// only touches the per-row content tables (gated through the
267    /// shadow, so re-walking the unchanged majority is free) and the
268    /// per-agent `objectiveai.messages` bookkeeping.
269    async fn apply_chunk(&mut self, chunk: &C) -> Result<(), crate::error::Error>
270    where
271        C: Send + Sync,
272    {
273        let response_id = self
274            .primary_id
275            .clone()
276            .expect("primary_id set by listener_loop before apply_chunk");
277        let created_at_seed = now_secs() as i64;
278
279        // One traversal of the chunk. Content rows are gated via the
280        // shadow and bucketed by agent_instance_hierarchy (Vec inside
281        // the HashMap preserves iterator order so per-bucket sequential
282        // awaits match the walk's ordering). Usage items (per nested
283        // agent completion carrying a non-`None` usage) overwrite the
284        // per-AIH `total_tokens` snapshot inline — `last_usage` dedups
285        // so the repeated passes over the cumulative accumulator only
286        // write when the value changes.
287        let mut buckets: HashMap<&str, Vec<(WriteOp, RowValue<'_>)>> = HashMap::new();
288        for item in (self.items_fn)(chunk) {
289            match item {
290                WriterItem::Row(value) => {
291                    let key = value.agent_instance_hierarchy();
292                    match self.shadow.record(&value) {
293                        WriteOp::Skip => {}
294                        op => buckets.entry(key).or_default().push((op, value)),
295                    }
296                }
297                WriterItem::Usage { agent_instance_hierarchy, total_tokens } => {
298                    if self.last_usage.get(agent_instance_hierarchy) != Some(&total_tokens) {
299                        update_agent_token_usage(
300                            &self.pool,
301                            agent_instance_hierarchy,
302                            total_tokens as i64,
303                        )
304                        .await?;
305                        self.last_usage
306                            .insert(agent_instance_hierarchy.to_string(), total_tokens);
307                    }
308                }
309            }
310        }
311
312        // Build the per-agent bucket futures. Each future runs its
313        // rows sequentially (order matters within one agent's
314        // history); different agents run concurrently via
315        // `try_join_all`. The seen_agents mutation happens
316        // synchronously inside the map closure — by the time the
317        // futures actually run, every bucket already knows whether it
318        // owes a request-messages row.
319        let pool = &self.pool;
320        let tier = self.tier;
321        let resp_id = response_id.as_str();
322        let seen_agents = &mut self.seen_agents;
323        let bucket_futures: Vec<
324            Pin<Box<dyn Future<Output = Result<(), crate::error::Error>> + Send + '_>>,
325        > = buckets
326            .into_iter()
327            .map(|(hier, items)| {
328                let needs_request_row = !seen_agents.contains(hier);
329                if needs_request_row {
330                    seen_agents.insert(hier.to_string());
331                }
332                Box::pin(async move {
333                    if needs_request_row {
334                        insert_request_messages_row(
335                            pool,
336                            tier,
337                            resp_id,
338                            hier,
339                            created_at_seed,
340                        )
341                        .await?;
342                    }
343                    for (op, value) in &items {
344                        write_value(pool, *op, value, created_at_seed).await?;
345                    }
346                    Ok::<(), crate::error::Error>(())
347                })
348                    as Pin<
349                        Box<
350                            dyn Future<Output = Result<(), crate::error::Error>>
351                                + Send
352                                + '_,
353                        >,
354                    >
355            })
356            .collect();
357
358        futures::future::try_join_all(bucket_futures).await?;
359
360        Ok(())
361    }
362
363    /// Write the request blob exactly once, when `listener_loop` first
364    /// learns the response_id (before any content row references it).
365    /// The request blob carries no agent_instance_hierarchy — that
366    /// linkage lives in `objectiveai.messages` (written per-agent in
367    /// `apply_chunk`).
368    async fn write_request_blob(
369        &self,
370        response_id: &str,
371    ) -> Result<(), crate::error::Error> {
372        let created_at_seed = now_secs() as i64;
373        insert_request_blob(
374            &self.pool,
375            self.tier,
376            response_id,
377            &self.request_body,
378            &self.sender_agent_instance_hierarchy,
379            created_at_seed,
380        )
381        .await?;
382        Ok(())
383    }
384
385    /// Write the complete response blob exactly once, from the
386    /// cumulative aggregate of every chunk in the stream — built by
387    /// `listener_loop` and handed in after the last chunk (finalize).
388    /// A single INSERT: the blob is never a partial snapshot, so a
389    /// chunk's tool-calls can't be lost to a per-batch overwrite.
390    async fn write_response_blob(
391        &self,
392        chunk: &C,
393    ) -> Result<(), crate::error::Error>
394    where
395        C: Serialize,
396    {
397        let Some(response_id) = self.primary_id.as_deref() else {
398            return Ok(());
399        };
400        let created_at_seed = now_secs() as i64;
401        insert_response_blob(
402            &self.pool,
403            self.tier,
404            response_id,
405            chunk,
406            created_at_seed,
407        )
408        .await?;
409        Ok(())
410    }
411}
412
413/// Listener loop. One iteration:
414///
415/// 1. Block on `rx.recv()` for the first chunk of a batch.
416/// 2. Drain any other chunks queued behind it via `try_recv`,
417///    `.push()`-aggregating them into the first.
418/// 3. Apply the aggregated chunk to the state.
419/// 4. If this was the first successful batch, flip `written_tx` to
420///    `true` (powers `LogWriter::wait_written_once`).
421/// 5. If `primary_id` just became known, fire the ready oneshot.
422///
423/// On `recv() = None` (sender dropped via `finalize`), the loop
424/// exits cleanly. On any DB error from `apply_chunk`, the loop
425/// exits with `Err`; subsequent sender sends fail with `SendError`,
426/// which `LogWriter::write` maps to a stable `Error::Instance`.
427async fn listener_loop<C>(
428    mut rx: mpsc::UnboundedReceiver<C>,
429    mut state: LogWriterState<C>,
430    ready_tx: oneshot::Sender<String>,
431    written_tx: watch::Sender<bool>,
432) -> Result<(), crate::error::Error>
433where
434    C: WriterChunk + AgentCompletionIds + ChunkPush + Clone + Serialize + Send + Sync,
435{
436    let mut ready_tx = Some(ready_tx);
437    let mut written_fired = false;
438    // Cumulative aggregate of every chunk across the whole stream. Each
439    // iteration's `agg` is only a partial slice (the wire is per-message
440    // deltas); folding every batch in here builds the complete response
441    // that is written as the response blob exactly once, after the last
442    // chunk. Without this the blob would be overwritten with whatever
443    // partial batch arrived last, dropping earlier tool-calls.
444    let mut accumulated: Option<C> = None;
445    while let Some(first) = rx.recv().await {
446        // Fold `first` into the stream-wide aggregate, then drain any
447        // chunks queued behind it into the same aggregate. `accumulated`
448        // is the cumulative roll-up of every chunk seen so far — NOT a
449        // per-batch slice. Draining the queue only collapses how OFTEN
450        // the persistence pass runs; what it persists from is always
451        // the full accumulator.
452        if let Some(acc) = accumulated.as_mut() {
453            acc.push(&first);
454        } else {
455            accumulated = Some(first.clone());
456        }
457        while let Ok(next) = rx.try_recv() {
458            if let Some(acc) = accumulated.as_mut() {
459                acc.push(&next);
460            }
461        }
462        let acc = accumulated
463            .as_ref()
464            .expect("accumulated is Some: set or pushed above");
465
466        // On the very first chunk: learn the response_id and write the
467        // request blob once, before any content row references it.
468        if state.primary_id.is_none() {
469            let response_id = acc.primary_id().to_string();
470            state.write_request_blob(&response_id).await?;
471            state.primary_id = Some(response_id);
472        }
473
474        // Persist rows from the cumulative aggregate, never a per-batch
475        // slice. A tool call's id/name/arguments — and streamed content
476        // text — arrive as deltas spread across multiple wire chunks;
477        // under load those deltas land in different drain batches. Row
478        // generation (`rows.rs`) drops any tool call missing id/name/
479        // args, so a per-batch slice that lacked the id/name delta would
480        // omit the row entirely (and would overwrite content text with
481        // the latest fragment rather than the full run). Walking the
482        // full accumulator each pass emits every row from its COMPLETE
483        // body; the shadow makes the repeated walk cheap — unchanged
484        // rows Skip with zero writes, only genuinely-changed bodies hit
485        // the DB.
486        state.apply_chunk(acc).await?;
487
488        // First successful apply: flip the watch true exactly once.
489        // Subsequent batches don't touch it (the value is already
490        // true; no point waking waiters again).
491        if !written_fired {
492            let _ = written_tx.send(true);
493            written_fired = true;
494        }
495        // Fire the oneshot the first time primary_id becomes known
496        // (set above on the first chunk).
497        if let Some(tx) = ready_tx.take() {
498            match state.primary_id.as_deref() {
499                Some(id) => {
500                    let _ = tx.send(id.to_string());
501                }
502                None => {
503                    ready_tx = Some(tx);
504                }
505            }
506        }
507    }
508    // EOF (sender dropped via finalize): write the complete response
509    // blob exactly once from the cumulative aggregate. Skipped when no
510    // chunk ever arrived (primary_id still unset).
511    if let Some(acc) = accumulated {
512        state.write_response_blob(&acc).await?;
513    }
514    Ok(())
515}
516
517fn now_secs() -> u64 {
518    std::time::SystemTime::now()
519        .duration_since(std::time::UNIX_EPOCH)
520        .map(|d| d.as_secs())
521        .unwrap_or(0)
522}
523
524fn spawn_writer<C>(
525    pool: Pool,
526    tier: Tier,
527    request_body: serde_json::Value,
528    sender_agent_instance_hierarchy: String,
529    items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
530) -> (LogWriter<C>, oneshot::Receiver<String>)
531where
532    C: WriterChunk + AgentCompletionIds + ChunkPush + Clone + Serialize + Send + Sync + 'static,
533{
534    let (tx, rx) = mpsc::unbounded_channel();
535    let (ready_tx, ready_rx) = oneshot::channel();
536    let (written_tx, written_rx) = watch::channel(false);
537    let state = LogWriterState::new(
538        pool,
539        tier,
540        request_body,
541        sender_agent_instance_hierarchy,
542        items_fn,
543    );
544    let handle = tokio::spawn(listener_loop(rx, state, ready_tx, written_tx));
545    (
546        LogWriter {
547            tx,
548            handle,
549            written_rx,
550            _chunk: PhantomData,
551        },
552        ready_rx,
553    )
554}
555
556pub fn write_agent_completion(
557    pool: &Pool,
558    params: &AgentCompletionCreateParams,
559    sender_agent_instance_hierarchy: String,
560) -> Result<
561    (LogWriter<AgentCompletionChunk>, oneshot::Receiver<String>),
562    crate::error::Error,
563> {
564    let body = serde_json::to_value(params)?;
565    Ok(spawn_writer(
566        pool.clone(),
567        Tier::Agent,
568        body,
569        sender_agent_instance_hierarchy,
570        agent_completion_chunk_rows,
571    ))
572}
573
574pub fn write_vector_completion(
575    pool: &Pool,
576    params: &VectorCompletionCreateParams,
577    sender_agent_instance_hierarchy: String,
578) -> Result<
579    (LogWriter<VectorCompletionChunk>, oneshot::Receiver<String>),
580    crate::error::Error,
581> {
582    let body = serde_json::to_value(params)?;
583    Ok(spawn_writer(
584        pool.clone(),
585        Tier::Vector,
586        body,
587        sender_agent_instance_hierarchy,
588        vector_completion_chunk_rows,
589    ))
590}
591
592pub fn write_function_execution(
593    pool: &Pool,
594    params: &FunctionExecutionCreateParams,
595    sender_agent_instance_hierarchy: String,
596) -> Result<
597    (LogWriter<FunctionExecutionChunk>, oneshot::Receiver<String>),
598    crate::error::Error,
599> {
600    let body = serde_json::to_value(params)?;
601    Ok(spawn_writer(
602        pool.clone(),
603        Tier::Function,
604        body,
605        sender_agent_instance_hierarchy,
606        function_execution_chunk_rows,
607    ))
608}