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