Skip to main content

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(&current, Arc::clone(&next));
264            if Arc::ptr_eq(&current, &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}