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