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}