osproxy_core/ids.rs
1//! Strongly-typed identifier newtypes.
2//!
3//! Bare `String`/`u64` identifiers must never cross an API boundary: they are
4//! trivially mixed up and they make traces ambiguous. Every identifier in the
5//! system is a distinct type so the compiler catches misuse and so telemetry
6//! can label each value precisely (`docs/08` §7, `docs/05`).
7
8use std::fmt;
9
10/// Defines a string-backed identifier newtype with `Display`, `From<String>`,
11/// `From<&str>`, and an `as_str` accessor. Keeps each id a distinct type while
12/// avoiding repetitive boilerplate.
13macro_rules! string_id {
14 ($(#[$meta:meta])* $name:ident) => {
15 $(#[$meta])*
16 #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17 pub struct $name(String);
18
19 impl $name {
20 /// Borrows the underlying string.
21 #[must_use]
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25
26 /// Consumes the id, returning the owned string.
27 #[must_use]
28 pub fn into_string(self) -> String {
29 self.0
30 }
31 }
32
33 impl From<String> for $name {
34 fn from(value: String) -> Self {
35 Self(value)
36 }
37 }
38
39 impl From<&str> for $name {
40 fn from(value: &str) -> Self {
41 Self(value.to_owned())
42 }
43 }
44
45 impl fmt::Display for $name {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str(&self.0)
48 }
49 }
50
51 // Identifiers are safe to render in telemetry (they are ids, not
52 // tenant *values*); a precise Debug aids the `/debug/explain` story.
53 impl fmt::Debug for $name {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(f, concat!(stringify!($name), "({:?})"), self.0)
56 }
57 }
58 };
59}
60
61string_id! {
62 /// The tenancy/placement unit. Everything routes by this (`docs/03` §1).
63 PartitionId
64}
65
66string_id! {
67 /// Identifies a physical OpenSearch cluster.
68 ClusterId
69}
70
71string_id! {
72 /// A concrete (physical) OpenSearch index name.
73 IndexName
74}
75
76string_id! {
77 /// The authenticated principal's stable id. Never the raw token (`docs/05`).
78 PrincipalId
79}
80
81string_id! {
82 /// Correlates all telemetry for a single request (`docs/05` §6).
83 RequestId
84}
85
86string_id! {
87 /// The name of a document field. Used both for fields the proxy injects on
88 /// ingest and the fields it strips on read, so the two stay symmetric
89 /// (`docs/02` §2, `docs/03`). A name, never a value, safe in telemetry.
90 FieldName
91}
92
93/// The placement-table generation a routing decision was resolved against.
94///
95/// Monotonically increasing. Stamped on every routed write so the sink can
96/// reject a stale-epoch write for a migrating partition (`docs/06` §2).
97#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
98pub struct Epoch(u64);
99
100impl Epoch {
101 /// The initial epoch.
102 pub const ZERO: Self = Self(0);
103
104 /// Constructs an epoch from a raw generation number.
105 #[must_use]
106 pub fn new(generation: u64) -> Self {
107 Self(generation)
108 }
109
110 /// Returns the raw generation number.
111 #[must_use]
112 pub fn get(self) -> u64 {
113 self.0
114 }
115
116 /// Returns the next epoch. Saturates at `u64::MAX` rather than wrapping, so
117 /// monotonicity (relied on by migration cutover, `docs/06` INV-M2) can
118 /// never be violated by overflow.
119 #[must_use]
120 pub fn next(self) -> Self {
121 Self(self.0.saturating_add(1))
122 }
123}
124
125impl fmt::Display for Epoch {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 write!(f, "{}", self.0)
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn string_id_roundtrips_through_str_and_string() {
137 let from_str = PartitionId::from("tenant-7");
138 let from_string = PartitionId::from("tenant-7".to_owned());
139 assert_eq!(from_str, from_string);
140 assert_eq!(from_str.as_str(), "tenant-7");
141 assert_eq!(from_str.clone().into_string(), "tenant-7");
142 }
143
144 #[test]
145 fn distinct_id_types_do_not_compare_but_display_plainly() {
146 let cluster = ClusterId::from("eu-1");
147 assert_eq!(cluster.to_string(), "eu-1");
148 // Debug is labelled so traces are unambiguous.
149 assert_eq!(format!("{cluster:?}"), r#"ClusterId("eu-1")"#);
150 }
151
152 #[test]
153 fn epoch_is_monotonic_and_saturates() {
154 assert_eq!(Epoch::ZERO.get(), 0);
155 assert_eq!(Epoch::ZERO.next(), Epoch::new(1));
156 assert!(Epoch::new(1) > Epoch::ZERO);
157 assert_eq!(Epoch::new(u64::MAX).next(), Epoch::new(u64::MAX));
158 // Display renders the bare generation number (used in trace attributes).
159 assert_eq!(Epoch::new(42).to_string(), "42");
160 }
161}