protocol_droid/application/registry/store.rs
1//! The publish side: the one place a snapshot can change.
2//!
3//! # What this file is for
4//!
5//! [`crate::domain::registry::Snapshot`] knows how to describe a set of
6//! primitives and refuses to be edited. This module is what a server calls when
7//! a tool is registered, and its whole job is the sequence *copy, change,
8//! swap*. The copy is shallow — every primitive is behind an `Arc` — so a
9//! thousand unchanged tools cost a thousand pointer copies rather than a
10//! thousand clones of the tools.
11//!
12//! # Why the swap is the transaction boundary
13//!
14//! A reader that held `&Snapshot` could not survive a publish at all, and a
15//! reader that held a `Guard` would pin the old snapshot against reclamation
16//! for as long as it lives. A `tools/call` that resolves a tool and then runs
17//! it — which may block on a network call, a sandbox, or a human — would hold
18//! the registry's old state against a publish for that whole duration. So the
19//! reader takes an owned `Arc<Snapshot>`, the writer replaces the `Arc`, and
20//! the two never share a borrow.
21//!
22//! The consequence worth stating: **an in-flight call runs against the
23//! snapshot it started with.** A tool removed a microsecond after a call
24//! resolved still completes. That is not a best-effort approximation, it is
25//! the only behaviour that does not make a publish block on a running tool.
26//!
27//! # The generation
28//!
29//! Every snapshot carries the number of the publish that produced it, and
30//! [`RegistryStore::generation`] reads it off the current value rather than
31//! keeping a counter beside it — a counter that could drift from what it
32//! describes. A session that was told about generation `N` can compare one
33//! `u64` to learn whether anything changed, which is what makes the fan-out in
34//! [`super::subscriber`] able to skip a notification it has already sent.
35
36use std::cell::Cell;
37use std::sync::Arc;
38
39use arc_swap::ArcSwap;
40
41use crate::domain::registry::{PrimitiveId, PrimitiveKind, Registered, Snapshot, SnapshotBuilder};
42
43/// What a publish did, as the caller needs to hear it.
44///
45/// The returned snapshot is the one now published. Handing it back means the
46/// caller can act on exactly the value readers will see, rather than
47/// re-loading and racing a second publish.
48#[derive(Debug)]
49pub struct Published {
50 /// The snapshot now live, owned so the caller can read it without a load.
51 snapshot: Arc<Snapshot>,
52 /// The kinds this publish touched, in declaration order.
53 changed: Vec<PrimitiveKind>,
54}
55
56impl Published {
57 /// The snapshot that is now published.
58 #[must_use]
59 pub const fn snapshot(&self) -> &Arc<Snapshot> {
60 &self.snapshot
61 }
62
63 /// The number of this publish.
64 #[must_use]
65 pub fn generation(&self) -> u64 {
66 self.snapshot.generation()
67 }
68
69 /// The kinds this publish touched.
70 ///
71 /// A publish that removes a tool that was not there touches nothing and
72 /// reports nothing, so a caller that drives notifications off this list
73 /// does not announce a change nobody made.
74 #[must_use]
75 pub fn changed(&self) -> &[PrimitiveKind] {
76 &self.changed
77 }
78
79 /// Whether anything changed at all.
80 #[must_use]
81 pub const fn is_noop(&self) -> bool {
82 self.changed.is_empty()
83 }
84}
85
86/// The live set, and the only way to change it.
87///
88/// # Examples
89///
90/// ```
91/// use protocol_droid::domain::registry::{Primitive, PrimitiveKind, Tool};
92/// use protocol_droid::application::registry::RegistryStore;
93///
94/// let store = RegistryStore::new();
95/// assert!(store.snapshot().is_blank());
96///
97/// let published = store.register(Primitive::from(
98/// Tool::new("echo", r#"{"type":"object"}"#)?,
99/// ));
100///
101/// assert_eq!(published.changed(), [PrimitiveKind::Tool]);
102/// assert_eq!(store.snapshot().len(PrimitiveKind::Tool), 1);
103/// # Ok::<(), Box<dyn std::error::Error>>(())
104/// ```
105#[derive(Debug)]
106pub struct RegistryStore {
107 live: ArcSwap<Snapshot>,
108}
109
110impl RegistryStore {
111 /// An empty store at generation zero.
112 #[must_use]
113 pub fn new() -> Self {
114 Self {
115 live: ArcSwap::from_pointee(Snapshot::empty()),
116 }
117 }
118
119 /// A store seeded with a snapshot, at that snapshot's generation.
120 #[must_use]
121 pub fn with(snapshot: Snapshot) -> Self {
122 Self {
123 live: ArcSwap::from_pointee(snapshot),
124 }
125 }
126
127 /// The current snapshot, owned.
128 ///
129 /// An `Arc` clone, not a borrow. The caller may hold it for as long as it
130 /// likes — across a publish, across a long-running tool — and no publish
131 /// waits on it.
132 #[must_use]
133 pub fn snapshot(&self) -> Arc<Snapshot> {
134 self.live.load_full()
135 }
136
137 /// The current publish number.
138 #[must_use]
139 pub fn generation(&self) -> u64 {
140 self.live.load().generation()
141 }
142
143 /// Adds a primitive, replacing any primitive already filed under its id.
144 ///
145 /// A replacement is one publish and one generation, not a removal followed
146 /// by an add: a subscriber that hears about it twice has been told the tool
147 /// went away and came back, which is a lie that costs a re-list.
148 ///
149 /// The primitive is captured behind an `Arc` because `Self::publish` may
150 /// run its closure more than once, and a retry must be able to install the
151 /// *same* primitive without taking the first attempt's copy of it. The
152 /// alternative is a clone per attempt, which on a contended registry means
153 /// deep-copying a tool's schema on every retry.
154 pub fn register(&self, primitive: impl Into<Registered>) -> Published {
155 let primitive = Arc::new(primitive.into());
156 let kind = primitive.kind();
157 let snapshot = self.publish(move |mut next| {
158 next.insert_arc(Arc::clone(&primitive));
159 next
160 });
161 Published {
162 snapshot,
163 changed: vec![kind],
164 }
165 }
166
167 /// Takes a primitive out of the set.
168 ///
169 /// Removing something that is not there is not an error: the observable
170 /// state afterwards is the same either way, and a caller tearing down a
171 /// tool it may never have registered should not have to check first. The
172 /// returned [`Published`] reports `is_noop`, so a notification is still
173 /// suppressed.
174 pub fn unregister(&self, kind: PrimitiveKind, id: &PrimitiveId) -> Published {
175 // `rcu` may run the closure more than once, so "was anything removed"
176 // has to be read from the attempt that actually won rather than
177 // captured by the first one to run.
178 let removed = Cell::new(false);
179 let id = id.clone();
180 let snapshot = self.publish({
181 let removed = &removed;
182 move |mut next| {
183 removed.set(next.remove(kind, &id).is_some());
184 next
185 }
186 });
187 let changed = if removed.get() {
188 vec![kind]
189 } else {
190 Vec::new()
191 };
192 Published { snapshot, changed }
193 }
194
195 /// Publishes several changes as one generation.
196 ///
197 /// A batch is one publish rather than a sequence of them so that a
198 /// subscriber told about the result never observes a half-applied set.
199 /// That is the only reason this takes a slice: the alternative is a caller
200 /// looping over [`Self::register`], and a reader racing the loop sees
201 /// generation `N` with the first change and `N+2` with the last.
202 pub fn apply(&self, primitives: impl IntoIterator<Item = Registered>) -> Published {
203 let batch: Vec<Arc<Registered>> = primitives.into_iter().map(Arc::new).collect();
204 let mut changed: Vec<PrimitiveKind> = Vec::new();
205 for primitive in &batch {
206 let kind = primitive.kind();
207 if !changed.contains(&kind) {
208 changed.push(kind);
209 }
210 }
211 changed.sort_by_key(|kind| kind.as_str());
212 let snapshot = self.publish(move |mut next| {
213 for primitive in &batch {
214 next.insert_arc(Arc::clone(primitive));
215 }
216 next
217 });
218 Published { snapshot, changed }
219 }
220
221 /// The one place a publish happens.
222 ///
223 /// # Why a compare-and-swap loop and not load-then-store
224 ///
225 /// An `ArcSwap` makes a read atomic and a write atomic. It does **not**
226 /// make a read followed by a write atomic, and a registry built on
227 /// load → modify → store loses a registration for every publish that
228 /// overlaps another. Four threads registering a thousand tools between them
229 /// kept a few hundred, and every lost write is a tool the server had
230 /// already reported as registered and then could not serve.
231 /// `a_reader_never_sees_a_torn_snapshot` is the test that found it.
232 ///
233 /// The loop builds a candidate from the snapshot it actually read, attempts
234 /// a compare-and-swap, and on failure **retries against the value that
235 /// won** — so a losing publish is not lost, it is re-derived from the newer
236 /// snapshot and applied on top of it.
237 ///
238 /// That is why the closure is handed a `SnapshotBuilder` rather than being
239 /// handed one made before the call: the builder has to come from whichever
240 /// snapshot this attempt actually saw, and one built earlier cannot be
241 /// re-derived.
242 ///
243 /// The generation is read from the snapshot inside the loop, not from the
244 /// store, so a retried publish numbers itself off the value it is being
245 /// applied to and two concurrent publishes cannot claim the same number.
246 ///
247 /// # Why this is not `ArcSwap::rcu`
248 ///
249 /// `rcu` is this same loop, and it returns the value that was *replaced* —
250 /// the **previous** snapshot, not the one now live. That is the right
251 /// return for a cache, where the caller wants the stale value back to
252 /// recompute from. Here the caller needs the snapshot readers will
253 /// actually see, to hand to the fan-out, so returning the old one would
254 /// announce a generation that is not live. The two tests that pinned this
255 /// down — `the_generation_advances_with_every_publish` and
256 /// `a_batch_is_one_generation` — were reading 0 where 1 was published.
257 fn publish(&self, apply: impl Fn(SnapshotBuilder) -> SnapshotBuilder) -> Arc<Snapshot> {
258 let mut current = self.live.load();
259 loop {
260 let next = Arc::new(
261 apply(current.to_builder()).build_at(current.generation().saturating_add(1)),
262 );
263 let previous = self.live.compare_and_swap(¤t, Arc::clone(&next));
264 if Arc::ptr_eq(¤t, &previous) {
265 return next;
266 }
267 current = previous;
268 }
269 }
270}
271
272impl Default for RegistryStore {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::RegistryStore;
281 use crate::domain::registry::TestError;
282 use crate::domain::registry::{Primitive, PrimitiveId, PrimitiveKind, Registered, Tool};
283
284 fn tool(name: &str) -> Result<Registered, TestError> {
285 Ok(Registered::from(Primitive::from(Tool::new(
286 name,
287 r#"{"type":"object"}"#,
288 )?)))
289 }
290
291 /// An empty store is a real state, not a null: a listing of it has to
292 /// answer zero of four without the caller special-casing it.
293 #[test]
294 fn an_empty_store_reports_itself_empty() {
295 let store = RegistryStore::new();
296 assert!(store.snapshot().is_blank());
297 assert_eq!(store.generation(), 0);
298 assert!(store.snapshot().is_empty(PrimitiveKind::Tool));
299 }
300
301 /// A replace is one publish and one generation. A store that removed and
302 /// re-added would cost a subscriber two notifications to say one thing.
303 #[test]
304 fn a_replacement_is_one_publish() -> Result<(), TestError> {
305 let store = RegistryStore::new();
306 store.register(tool("a")?);
307 let replaced = store.register(Registered::from(Primitive::from(
308 Tool::new("a", r#"{"type":"object"}"#)?.with_output_schema(r#"{"type":"object"}"#)?,
309 )));
310
311 assert_eq!(replaced.changed(), [PrimitiveKind::Tool]);
312 assert_eq!(store.snapshot().len(PrimitiveKind::Tool), 1);
313 assert_eq!(store.generation(), 2);
314 Ok(())
315 }
316
317 /// Removing something absent reports no change, and still advances the
318 /// generation — a waiter has to be able to learn "nothing changed".
319 #[test]
320 fn removing_an_absent_primitive_is_a_reported_no_op() -> Result<(), TestError> {
321 let store = RegistryStore::new();
322 store.register(tool("a")?);
323 let before = store.generation();
324
325 let published = store.unregister(PrimitiveKind::Tool, &PrimitiveId::from("absent"));
326 assert!(published.is_noop());
327 assert!(published.changed().is_empty());
328 assert_eq!(
329 published.generation(),
330 before + 1,
331 "a no-op still publishes, so a waiter is not left hanging"
332 );
333 assert_eq!(store.snapshot().len(PrimitiveKind::Tool), 1);
334 Ok(())
335 }
336
337 /// A real removal reports the kind, because that is what drives the
338 /// notification.
339 #[test]
340 fn removing_a_present_primitive_reports_its_kind() -> Result<(), TestError> {
341 let store = RegistryStore::new();
342 store.register(tool("a")?);
343 let published = store.unregister(PrimitiveKind::Tool, &PrimitiveId::from("a"));
344
345 assert_eq!(published.changed(), [PrimitiveKind::Tool]);
346 assert!(store.snapshot().is_empty(PrimitiveKind::Tool));
347 Ok(())
348 }
349
350 /// An empty batch is a publish that touched nothing.
351 #[test]
352 fn an_empty_batch_changes_nothing() {
353 let store = RegistryStore::new();
354 let published = store.apply([]);
355 assert!(published.is_noop());
356 }
357
358 /// The snapshot a publish returns is the one readers see, not the one that
359 /// was replaced. The generation is what proves it.
360 #[test]
361 fn a_publish_returns_the_snapshot_that_is_live() -> Result<(), TestError> {
362 let store = RegistryStore::new();
363 let first = store.register(tool("a")?);
364 assert_eq!(
365 first.snapshot().generation(),
366 store.generation(),
367 "the returned snapshot must be the live one"
368 );
369 assert_eq!(first.snapshot().len(PrimitiveKind::Tool), 1);
370 Ok(())
371 }
372}