axond 0.3.20

Axond — a stateless, single-binary, self-hosted AI gateway: one place for provider keys, model routing, usage, and telemetry.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Buffered, batched, back-pressured delivery for sinks that talk to a
//! datastore.
//!
//! The durability-vs-latency contract (ADR 0009): the request path enqueues
//! with a non-blocking `try_send` and never waits for a sink. A full buffer
//! therefore *drops* the record and counts it on
//! `axond.usage.records_dropped{reason="buffer_full"}` — usage is valuable, but
//! not more valuable than the request it describes. Every drop is a signal that
//! the destination is too slow for the offered load, or that the buffer is too
//! small; both are visible in the metric before they are visible in a bill.

use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;

use async_trait::async_trait;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Instant, timeout_at};

use crate::telemetry::metrics;

use super::{DropReason, FlushOutcome, ObservedRecord, SinkFailure, UsageRecord, UsageSink};

/// Batching policy for one sink.
#[derive(Debug, Clone, Copy)]
pub struct BatchSettings {
    /// Records the buffer holds before the fan-out starts dropping.
    pub capacity: usize,
    /// Rows accumulated before a write. The Postgres sink splits larger
    /// batches across statements while preserving one flush outcome.
    pub max_batch: usize,
    /// How long a partial batch waits for company before it is written anyway.
    pub flush_interval: Duration,
}

/// How often a sustained overflow is logged. A drop per record would turn a
/// sink outage into a log flood; the counter stays exact regardless.
const DROP_LOG_INTERVAL: u64 = 1_000;

/// What the flush task accepts: records from the request path, and the one
/// out-of-band request the shutdown path makes.
// The size difference is deliberate: boxing the record would add an allocation
// per request to spare the one flush message per process lifetime.
#[allow(clippy::large_enum_variant)]
enum Message {
    Record(ObservedRecord),
    /// Write everything held and everything already queued, then answer. Sent
    /// through the same channel as the records so it cannot overtake them.
    Flush(oneshot::Sender<FlushOutcome>),
}

/// Wraps a sink in a bounded queue and a flush task.
pub struct BatchedSink {
    name: &'static str,
    tx: mpsc::Sender<Message>,
    dropped: Arc<AtomicU64>,
}

impl BatchedSink {
    /// Spawn the flush task. Must be called on the Tokio runtime that will
    /// serve requests.
    pub fn spawn(sink: Arc<dyn UsageSink>, settings: BatchSettings) -> Self {
        let (tx, rx) = mpsc::channel(settings.capacity);
        let name = sink.name();
        let dropped = Arc::new(AtomicU64::new(0));
        tokio::spawn(flush_loop(sink, rx, settings, Arc::clone(&dropped)));
        Self { name, tx, dropped }
    }

    /// Records discarded so far — the observable cost of the contract. The
    /// operator-facing view of this is the `axond.usage.records_dropped` metric.
    #[allow(dead_code)]
    pub fn dropped(&self) -> u64 {
        self.dropped.load(Ordering::Relaxed)
    }

    fn drop_record(&self, reason: DropReason) {
        let total = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
        metrics::record_usage_dropped(self.name, reason.as_str(), 1);
        if total == 1 || total.is_multiple_of(DROP_LOG_INTERVAL) {
            tracing::warn!(
                sink = self.name,
                reason = reason.as_str(),
                dropped = total,
                "usage record dropped rather than delaying the request path"
            );
        }
    }
}

#[async_trait]
impl UsageSink for BatchedSink {
    fn name(&self) -> &'static str {
        self.name
    }

    async fn record(&self, record: &UsageRecord) {
        match self
            .tx
            .try_send(Message::Record(ObservedRecord::now(record.clone())))
        {
            Ok(()) => {}
            Err(mpsc::error::TrySendError::Full(_)) => self.drop_record(DropReason::BufferFull),
            Err(mpsc::error::TrySendError::Closed(_)) => self.drop_record(DropReason::Shutdown),
        }
    }

    async fn record_batch(&self, batch: &[ObservedRecord]) -> Result<(), SinkFailure> {
        for observed in batch {
            self.record(&observed.record).await;
        }
        Ok(())
    }

    /// Ask the flush task to write everything it holds. Unbounded here on
    /// purpose: the caller owns the bound and abandons the buffer through
    /// [`UsageSink::abandon`] when it expires.
    async fn flush(&self) -> FlushOutcome {
        let (ack, answer) = oneshot::channel();
        if self.tx.send(Message::Flush(ack)).await.is_err() {
            // The flush task is gone, so nothing is buffered to lose.
            return FlushOutcome::Flushed { records: 0 };
        }
        answer.await.unwrap_or(FlushOutcome::Flushed { records: 0 })
    }

    fn abandon(&self, reason: DropReason) -> u64 {
        let queued = (self.tx.max_capacity() - self.tx.capacity()) as u64;
        if queued == 0 {
            return 0;
        }
        self.dropped.fetch_add(queued, Ordering::Relaxed);
        metrics::record_usage_dropped(self.name, reason.as_str(), queued);
        queued
    }
}

/// Accumulate up to `max_batch` records, or whatever arrived within
/// `flush_interval` of the first one, then hand the batch to the sink. The loop
/// ends when every sender is gone, flushing what it holds.
async fn flush_loop(
    sink: Arc<dyn UsageSink>,
    mut rx: mpsc::Receiver<Message>,
    settings: BatchSettings,
    dropped: Arc<AtomicU64>,
) {
    let mut batch: Vec<ObservedRecord> = Vec::with_capacity(settings.max_batch.min(1024));
    loop {
        let Some(message) = rx.recv().await else {
            return;
        };
        match message {
            Message::Record(record) => batch.push(record),
            Message::Flush(ack) => {
                let _ = ack.send(drain(sink.as_ref(), &mut rx, &mut batch, &dropped).await);
                continue;
            }
        }
        let deadline = Instant::now() + settings.flush_interval;
        while batch.len() < settings.max_batch {
            match timeout_at(deadline, rx.recv()).await {
                Ok(Some(Message::Record(record))) => batch.push(record),
                Ok(Some(Message::Flush(ack))) => {
                    let _ = ack.send(drain(sink.as_ref(), &mut rx, &mut batch, &dropped).await);
                    break;
                }
                // Senders gone: write what is held, then stop. A failure here
                // is already counted and logged by `flush`.
                Ok(None) => {
                    let _ = flush(sink.as_ref(), &mut batch, &dropped).await;
                    return;
                }
                Err(_) => break,
            }
        }
        let _ = flush(sink.as_ref(), &mut batch, &dropped).await;
    }
}

/// Write the held batch plus everything already queued, in arrival order, and
/// report it as one outcome. Only records that are *already* enqueued are
/// drained, so a request path still producing cannot keep the flush running past
/// the caller's bound.
async fn drain(
    sink: &dyn UsageSink,
    rx: &mut mpsc::Receiver<Message>,
    batch: &mut Vec<ObservedRecord>,
    dropped: &AtomicU64,
) -> FlushOutcome {
    while let Ok(message) = rx.try_recv() {
        match message {
            Message::Record(record) => batch.push(record),
            // A second flush request during a drain is answered by this one.
            Message::Flush(ack) => {
                let _ = ack.send(FlushOutcome::Flushed { records: 0 });
            }
        }
    }
    let records = batch.len() as u64;
    match flush(sink, batch, dropped).await {
        Ok(()) => FlushOutcome::Flushed { records },
        Err(error) => FlushOutcome::Failed {
            records,
            error: error.to_string(),
        },
    }
}

async fn flush(
    sink: &dyn UsageSink,
    batch: &mut Vec<ObservedRecord>,
    dropped: &AtomicU64,
) -> Result<(), SinkFailure> {
    if batch.is_empty() {
        return Ok(());
    }
    let count = batch.len() as u64;
    let result = match sink.record_batch(batch).await {
        Ok(()) => {
            metrics::record_usage_written(sink.name(), count);
            Ok(())
        }
        Err(e) => {
            dropped.fetch_add(count, Ordering::Relaxed);
            metrics::record_usage_dropped(sink.name(), DropReason::SinkError.as_str(), count);
            tracing::warn!(
                sink = sink.name(),
                reason = DropReason::SinkError.as_str(),
                records = count,
                error = %e,
                "usage batch dropped: sink rejected it"
            );
            Err(e)
        }
    };
    batch.clear();
    result
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use tokio::sync::Notify;

    use super::super::tests::sample_record;
    use super::*;

    /// Records batch sizes, and optionally blocks the flush task so the buffer
    /// can be driven to overflow deterministically.
    #[derive(Default)]
    struct RecordingSink {
        batches: Mutex<Vec<usize>>,
        release: Option<Arc<Notify>>,
        fail: bool,
    }

    #[async_trait]
    impl UsageSink for RecordingSink {
        fn name(&self) -> &'static str {
            "recording"
        }

        async fn record(&self, _record: &UsageRecord) {}

        async fn record_batch(&self, batch: &[ObservedRecord]) -> Result<(), SinkFailure> {
            if let Some(release) = &self.release {
                release.notified().await;
            }
            self.batches.lock().unwrap().push(batch.len());
            if self.fail {
                return Err(SinkFailure::new("destination unavailable"));
            }
            Ok(())
        }
    }

    fn settings(capacity: usize, max_batch: usize, flush_ms: u64) -> BatchSettings {
        BatchSettings {
            capacity,
            max_batch,
            flush_interval: Duration::from_millis(flush_ms),
        }
    }

    #[tokio::test]
    async fn records_are_written_in_batches_not_one_by_one() {
        let sink = Arc::new(RecordingSink::default());
        let batched =
            BatchedSink::spawn(Arc::clone(&sink) as Arc<dyn UsageSink>, settings(64, 8, 5));
        for _ in 0..8 {
            batched.record(&sample_record()).await;
        }
        // The batch closes on `max_batch`, so a single flush covers all eight.
        for _ in 0..50 {
            if !sink.batches.lock().unwrap().is_empty() {
                break;
            }
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
        assert_eq!(*sink.batches.lock().unwrap(), vec![8]);
        assert_eq!(batched.dropped(), 0);
    }

    #[tokio::test]
    async fn a_partial_batch_still_flushes_on_the_interval() {
        let sink = Arc::new(RecordingSink::default());
        let batched = BatchedSink::spawn(
            Arc::clone(&sink) as Arc<dyn UsageSink>,
            settings(64, 500, 20),
        );
        batched.record(&sample_record()).await;
        for _ in 0..50 {
            if !sink.batches.lock().unwrap().is_empty() {
                break;
            }
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
        assert_eq!(*sink.batches.lock().unwrap(), vec![1]);
    }

    #[tokio::test]
    async fn a_stalled_sink_drops_instead_of_blocking_the_caller() {
        let release = Arc::new(Notify::new());
        let sink = Arc::new(RecordingSink {
            release: Some(Arc::clone(&release)),
            ..RecordingSink::default()
        });
        let batched =
            BatchedSink::spawn(Arc::clone(&sink) as Arc<dyn UsageSink>, settings(4, 1, 5));
        // The flush task holds one record and blocks; the buffer takes four more
        // and everything after that is dropped rather than awaited.
        for _ in 0..32 {
            batched.record(&sample_record()).await;
        }
        assert!(batched.dropped() > 0, "a full buffer must drop");
        assert!(
            sink.batches.lock().unwrap().is_empty(),
            "sink still stalled"
        );
        release.notify_waiters();
    }

    #[tokio::test]
    async fn a_flush_writes_a_partial_batch_before_the_interval_elapses() {
        let sink = Arc::new(RecordingSink::default());
        let batched = BatchedSink::spawn(
            Arc::clone(&sink) as Arc<dyn UsageSink>,
            // A flush interval far longer than the test: only the explicit
            // flush can get these records written.
            settings(64, 500, 60_000),
        );
        batched.record(&sample_record()).await;
        batched.record(&sample_record()).await;
        assert_eq!(batched.flush().await, FlushOutcome::Flushed { records: 2 });
        assert_eq!(*sink.batches.lock().unwrap(), vec![2]);
        assert_eq!(batched.dropped(), 0);
    }

    #[tokio::test]
    async fn a_flush_a_failing_sink_rejects_is_reported_and_counted() {
        let sink = Arc::new(RecordingSink {
            fail: true,
            ..RecordingSink::default()
        });
        let batched = BatchedSink::spawn(
            Arc::clone(&sink) as Arc<dyn UsageSink>,
            settings(64, 500, 60_000),
        );
        batched.record(&sample_record()).await;
        let outcome = batched.flush().await;
        assert!(
            matches!(outcome, FlushOutcome::Failed { records: 1, .. }),
            "{outcome:?}"
        );
        assert!(!outcome.is_complete());
        assert_eq!(batched.dropped(), 1, "a rejected flush is still accounted");
    }

    #[tokio::test]
    async fn an_abandoned_buffer_counts_every_queued_record_as_a_shutdown_drop() {
        let release = Arc::new(Notify::new());
        let sink = Arc::new(RecordingSink {
            release: Some(Arc::clone(&release)),
            ..RecordingSink::default()
        });
        let batched =
            BatchedSink::spawn(Arc::clone(&sink) as Arc<dyn UsageSink>, settings(8, 1, 5));
        // One record is held by the stalled flush task; the rest sit in the queue.
        for _ in 0..5 {
            batched.record(&sample_record()).await;
        }
        let abandoned = batched.abandon(DropReason::Shutdown);
        assert!(abandoned > 0, "queued records must be accounted for");
        assert_eq!(batched.dropped(), abandoned);
        release.notify_waiters();
    }

    #[tokio::test]
    async fn a_failing_sink_counts_the_batch_as_dropped() {
        let sink = Arc::new(RecordingSink {
            fail: true,
            ..RecordingSink::default()
        });
        let batched =
            BatchedSink::spawn(Arc::clone(&sink) as Arc<dyn UsageSink>, settings(64, 2, 5));
        batched.record(&sample_record()).await;
        batched.record(&sample_record()).await;
        for _ in 0..50 {
            if batched.dropped() > 0 {
                break;
            }
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
        assert_eq!(batched.dropped(), 2);
    }
}