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}