frink_api/request_id.rs
1//! Server-assigned request ids.
2//!
3//! The id is stated by the server in the response (and in the *first*
4//! streamed chunk, before any content), so a client never has to invent
5//! one or correlate by heuristic. The alternative -- matching a live
6//! request against a metrics snapshot by "the newest one that looks like
7//! mine" -- is what a UI is forced into when the server stays silent,
8//! and it mis-attributes as soon as two chats run at once.
9//!
10//! Format: `chatcmpl-` + a per-process random-ish stamp + a monotonic
11//! counter. The stamp makes ids from two runs of the same binary
12//! distinguishable (so a log spanning a restart does not collide); the
13//! counter makes them unique within a run without a lock or an RNG
14//! dependency. The prefix matches OpenAI's, which some clients display.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// OpenAI uses this prefix for chat completion ids; clients occasionally
19/// display or match on it.
20pub const PREFIX: &str = "chatcmpl-";
21
22static COUNTER: AtomicU64 = AtomicU64::new(0);
23
24/// Per-process stamp: the low bits of the wall clock at first use. Not
25/// a security token -- it only needs to differ between restarts.
26///
27/// Public because `frink-server`'s conversation ids need the same
28/// stamp for the same reason, and had a byte-identical copy of this
29/// function whose doc comment said it was mirroring this one. A copy
30/// that says it is a copy is still a copy.
31pub fn process_stamp() -> u64 {
32 use std::sync::OnceLock;
33 static STAMP: OnceLock<u64> = OnceLock::new();
34 *STAMP.get_or_init(|| {
35 std::time::SystemTime::now()
36 .duration_since(std::time::UNIX_EPOCH)
37 .map(|d| d.as_nanos() as u64)
38 .unwrap_or(0)
39 })
40}
41
42/// A fresh request id. Unique within this process, and in practice
43/// across restarts.
44pub fn next_request_id() -> String {
45 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
46 format!(
47 "{PREFIX}{:012x}{:06x}",
48 process_stamp() & 0xffff_ffff_ffff,
49 n
50 )
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 #[test]
58 fn ids_are_unique_and_prefixed() {
59 let a = next_request_id();
60 let b = next_request_id();
61 assert_ne!(a, b);
62 assert!(a.starts_with(PREFIX), "{a}");
63 assert!(b.starts_with(PREFIX), "{b}");
64 }
65
66 #[test]
67 fn ids_are_stable_width_for_the_first_million_requests() {
68 // A UI that column-aligns a request log should not have the
69 // column jump on request 16.
70 let first = next_request_id();
71 for _ in 0..64 {
72 assert_eq!(next_request_id().len(), first.len());
73 }
74 }
75
76 #[test]
77 fn ids_are_unique_across_threads() {
78 let handles: Vec<_> = (0..8)
79 .map(|_| std::thread::spawn(|| (0..64).map(|_| next_request_id()).collect::<Vec<_>>()))
80 .collect();
81 let mut seen = std::collections::BTreeSet::new();
82 for h in handles {
83 for id in h.join().unwrap() {
84 assert!(seen.insert(id.clone()), "duplicate id {id}");
85 }
86 }
87 }
88}