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}