Skip to main content

structfs_handles/
handle_store.rs

1//! `HandleStore`: generic `outstanding/{id}` handle scaffolding.
2//!
3//! The deferred-operation pattern — write a request to a store's root, get
4//! back an `outstanding/{id}` handle path, then read the handle for results
5//! — recurs in every broker-shaped store. This module owns the mechanics
6//! (id minting, handle routing, the no-overwrite rule, Null-write release,
7//! cancellation, listing) so a store author only implements the protocol:
8//! what a handle *is* and how its sub-paths respond.
9//!
10//! # Protocol rules (enforced here)
11//!
12//! - A write to the store root **mints** a handle and returns
13//!   `outstanding/{id}`.
14//! - A non-Null write directly to `outstanding/{id}` is a **conflict** —
15//!   handles cannot be overwritten.
16//! - A Null write to `outstanding/{id}` **releases** the handle: its
17//!   cancel token fires (failing parked reads), `close` runs, and the
18//!   entry is removed. Releasing an unknown handle is a no-op (idempotent).
19//! - Reads and writes below a released or unknown handle see `None` /
20//!   `NotFound`.
21//! - Reading the root (or `outstanding`) lists live handle paths.
22
23use std::collections::BTreeMap;
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::{Arc, Mutex};
26
27use structfs_core_store::{
28    DetachedFuture, DetachedReader, DetachedWriter, Error, NoCodec, Path, Record, Value,
29};
30
31use crate::gate::CancelToken;
32
33/// Context handed to a protocol when a handle is opened.
34pub struct HandleCx {
35    /// The minted handle id.
36    pub id: u64,
37    /// Cancelled when the handle is released. Protocol reads that park
38    /// should park cancellably on this token; writes should not, so
39    /// teardown writes can still land.
40    pub cancel: CancelToken,
41}
42
43/// The store-specific half of a handle store.
44///
45/// Implementations define per-handle state and the meaning of sub-paths
46/// under `outstanding/{id}`. All routing and lifecycle is handled by
47/// [`HandleStore`].
48pub trait HandleProtocol: Send + Sync + 'static {
49    /// Per-handle state. Stored behind an `Arc` so detached futures can
50    /// hold it without borrowing the store.
51    type Handle: Send + Sync + 'static;
52
53    /// Open a handle for a request written to the store root.
54    ///
55    /// Spawn any background work here; keep `cx.cancel` if parked reads
56    /// need to fail on release.
57    fn open(&self, cx: HandleCx, request: Value) -> Result<Self::Handle, Error>;
58
59    /// Serve a read below the handle. `sub` is relative to the handle
60    /// (empty for a read of `outstanding/{id}` itself).
61    fn read(&self, handle: Arc<Self::Handle>, sub: Path) -> DetachedFuture<Option<Record>>;
62
63    /// Serve a write below the handle. The returned path is relative to
64    /// the handle; [`HandleStore`] prefixes `outstanding/{id}` so callers
65    /// always see paths in their own namespace.
66    fn write(&self, handle: Arc<Self::Handle>, sub: Path, data: Record) -> DetachedFuture<Path>;
67
68    /// Called once when the handle is released (after cancellation).
69    fn close(&self, handle: Arc<Self::Handle>) {
70        let _ = handle;
71    }
72
73    /// Optional documentation served at `docs`.
74    fn docs(&self) -> Option<Value> {
75        None
76    }
77}
78
79struct Entry<H> {
80    handle: Arc<H>,
81    cancel: CancelToken,
82}
83
84struct Inner<P: HandleProtocol> {
85    protocol: Arc<P>,
86    next_id: AtomicU64,
87    entries: Mutex<BTreeMap<u64, Entry<P::Handle>>>,
88}
89
90impl<P: HandleProtocol> Drop for Inner<P> {
91    /// Dropping the last clone of a handle store releases every live
92    /// handle: parked reads cancel and the protocol's `close` runs. A
93    /// handle must never outlive its store — the RAII rule that keeps
94    /// abandoned owners (a dropped response future, a dead spawner
95    /// block) from leaking their handles forever.
96    fn drop(&mut self) {
97        let entries = std::mem::take(self.entries.get_mut().unwrap_or_else(|e| e.into_inner()));
98        for (_, entry) in entries {
99            entry.cancel.cancel();
100            self.protocol.close(entry.handle);
101        }
102    }
103}
104
105/// Generic handle store over a [`HandleProtocol`].
106///
107/// Cloneable; clones share the handle table. Implements the detached async
108/// store traits — use [`crate::SyncBridge`] for the sync traits.
109pub struct HandleStore<P: HandleProtocol> {
110    inner: Arc<Inner<P>>,
111}
112
113impl<P: HandleProtocol> Clone for HandleStore<P> {
114    fn clone(&self) -> Self {
115        Self {
116            inner: self.inner.clone(),
117        }
118    }
119}
120
121const OUTSTANDING: &str = "outstanding";
122
123impl<P: HandleProtocol> HandleStore<P> {
124    /// Create a handle store over a protocol.
125    pub fn new(protocol: P) -> Self {
126        Self {
127            inner: Arc::new(Inner {
128                protocol: Arc::new(protocol),
129                next_id: AtomicU64::new(0),
130                entries: Mutex::new(BTreeMap::new()),
131            }),
132        }
133    }
134
135    fn lock_entries(&self) -> std::sync::MutexGuard<'_, BTreeMap<u64, Entry<P::Handle>>> {
136        self.inner.entries.lock().unwrap_or_else(|e| e.into_inner())
137    }
138
139    /// The handle path for an id: `outstanding/{id}`.
140    pub fn handle_path(id: u64) -> Path {
141        Path::from_components(vec![OUTSTANDING.to_string(), id.to_string()])
142    }
143
144    /// Number of live handles.
145    pub fn live_handles(&self) -> usize {
146        self.lock_entries().len()
147    }
148
149    /// The ids of all live handles, ascending.
150    ///
151    /// For meta/introspection lenses layered over a handle store.
152    pub fn handle_ids(&self) -> Vec<u64> {
153        self.lock_entries().keys().copied().collect()
154    }
155
156    /// The protocol state of a live handle, if present.
157    ///
158    /// For meta/introspection lenses; regular operations should go
159    /// through the store interface.
160    pub fn get_handle(&self, id: u64) -> Option<Arc<P::Handle>> {
161        self.get(id)
162    }
163
164    fn mint(&self, request: Value) -> Result<Path, Error> {
165        let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
166        let cancel = CancelToken::new();
167        let handle = self.inner.protocol.open(
168            HandleCx {
169                id,
170                cancel: cancel.clone(),
171            },
172            request,
173        )?;
174        self.lock_entries().insert(
175            id,
176            Entry {
177                handle: Arc::new(handle),
178                cancel,
179            },
180        );
181        Ok(Self::handle_path(id))
182    }
183
184    fn release(&self, id: u64) {
185        let entry = self.lock_entries().remove(&id);
186        if let Some(entry) = entry {
187            // Cancel first so parked reads fail, then let the protocol
188            // tear down. Writes are unaffected by cancellation.
189            entry.cancel.cancel();
190            self.inner.protocol.close(entry.handle);
191        }
192    }
193
194    fn get(&self, id: u64) -> Option<Arc<P::Handle>> {
195        self.lock_entries().get(&id).map(|e| e.handle.clone())
196    }
197
198    fn listing(&self) -> Value {
199        let items: Vec<Value> = self
200            .lock_entries()
201            .keys()
202            .map(|id| Value::String(Self::handle_path(*id).to_string()))
203            .collect();
204        let mut map = BTreeMap::new();
205        map.insert("items".to_string(), Value::Array(items));
206        Value::Map(map)
207    }
208
209    /// Parse `outstanding/{id}[/sub...]`; `None` if the path has another shape.
210    fn parse_handle(path: &Path) -> Option<(u64, Path)> {
211        if path.len() < 2 || &path[0] != OUTSTANDING {
212            return None;
213        }
214        let id: u64 = path[1].parse().ok()?;
215        Some((id, path.slice(2, path.len())))
216    }
217}
218
219impl<P: HandleProtocol> DetachedReader for HandleStore<P> {
220    fn read_detached(&mut self, from: &Path) -> DetachedFuture<Option<Record>> {
221        // Root and bare `outstanding` list live handles.
222        if from.is_empty() || (from.len() == 1 && &from[0] == OUTSTANDING) {
223            let listing = self.listing();
224            return Box::pin(async move { Ok(Some(Record::parsed(listing))) });
225        }
226        if from.len() == 1 && &from[0] == "docs" {
227            let docs = self.inner.protocol.docs();
228            return Box::pin(async move { Ok(docs.map(Record::parsed)) });
229        }
230        let Some((id, sub)) = Self::parse_handle(from) else {
231            return Box::pin(async move { Ok(None) });
232        };
233        let Some(handle) = self.get(id) else {
234            // Unknown or released handle: absent, not an error.
235            return Box::pin(async move { Ok(None) });
236        };
237        self.inner.protocol.read(handle, sub)
238    }
239}
240
241impl<P: HandleProtocol> DetachedWriter for HandleStore<P> {
242    fn write_detached(&mut self, to: &Path, data: Record) -> DetachedFuture<Path> {
243        // Root write mints a handle.
244        if to.is_empty() {
245            let result = data.into_value(&NoCodec).and_then(|value| self.mint(value));
246            return Box::pin(async move { result });
247        }
248
249        let Some((id, sub)) = Self::parse_handle(to) else {
250            let path = to.clone();
251            return Box::pin(async move {
252                Err(Error::store(
253                    "handle_store",
254                    "write",
255                    format!("no such path: {}", path),
256                ))
257            });
258        };
259
260        if sub.is_empty() {
261            // Direct handle write: Null releases, anything else conflicts.
262            let result = match data.into_value(&NoCodec) {
263                Err(e) => Err(e),
264                Ok(value) if value.is_null() => {
265                    self.release(id);
266                    Ok(to.clone())
267                }
268                Ok(_) => Err(Error::conflict(format!(
269                    "cannot overwrite outstanding handle {}; write Null to release it",
270                    Self::handle_path(id)
271                ))),
272            };
273            return Box::pin(async move { result });
274        }
275
276        let Some(handle) = self.get(id) else {
277            let path = to.clone();
278            return Box::pin(async move { Err(Error::not_found(path)) });
279        };
280        let fut = self.inner.protocol.write(handle, sub, data);
281        // Protocol write results are handle-relative; express them in the
282        // caller's namespace.
283        Box::pin(async move {
284            let rel = fut.await?;
285            Ok(Self::handle_path(id).join(&rel))
286        })
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::tail::TailLog;
294
295    /// Test protocol: each handle is an event log. Writes to `push` append,
296    /// reads of `events/from/{n}` are atomic tail reads, reads of `status`
297    /// return open/done, writes to `done` finish the log.
298    struct StreamProtocol;
299
300    struct StreamHandle {
301        log: TailLog<Value>,
302        cancel: CancelToken,
303    }
304
305    impl HandleProtocol for StreamProtocol {
306        type Handle = StreamHandle;
307
308        fn open(&self, cx: HandleCx, _request: Value) -> Result<Self::Handle, Error> {
309            Ok(StreamHandle {
310                log: TailLog::new(),
311                cancel: cx.cancel,
312            })
313        }
314
315        fn read(&self, handle: Arc<Self::Handle>, sub: Path) -> DetachedFuture<Option<Record>> {
316            Box::pin(async move {
317                if sub.len() == 3 && &sub[0] == "events" && &sub[1] == "from" {
318                    let seq: u64 = sub[2]
319                        .parse()
320                        .map_err(|_| Error::store("stream", "read", "bad cursor"))?;
321                    let page = handle
322                        .log
323                        .read_from_cancellable(seq, &handle.cancel)
324                        .await
325                        .map_err(|c| c.into_error("stream handle released"))?;
326                    return Ok(Some(Record::parsed(page.into_value())));
327                }
328                if sub.len() == 1 && &sub[0] == "status" {
329                    let status = if handle.log.is_done() { "done" } else { "open" };
330                    return Ok(Some(Record::parsed(Value::from(status))));
331                }
332                Ok(None)
333            })
334        }
335
336        fn write(
337            &self,
338            handle: Arc<Self::Handle>,
339            sub: Path,
340            data: Record,
341        ) -> DetachedFuture<Path> {
342            Box::pin(async move {
343                if sub.len() == 1 && &sub[0] == "push" {
344                    let value = data.into_value(&NoCodec)?;
345                    handle.log.push(value);
346                    return Ok(sub);
347                }
348                if sub.len() == 1 && &sub[0] == "done" {
349                    handle.log.finish();
350                    return Ok(sub);
351                }
352                Err(Error::store("stream", "write", "unknown sub-path"))
353            })
354        }
355
356        fn close(&self, handle: Arc<Self::Handle>) {
357            handle.log.finish();
358        }
359    }
360
361    fn store() -> HandleStore<StreamProtocol> {
362        HandleStore::new(StreamProtocol)
363    }
364
365    fn parsed(v: Value) -> Record {
366        Record::parsed(v)
367    }
368
369    #[tokio::test]
370    async fn mint_returns_handle_path() {
371        let mut s = store();
372        let path = s
373            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("req")))
374            .await
375            .unwrap();
376        assert_eq!(path.to_string(), "outstanding/0");
377
378        let second = s
379            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("req")))
380            .await
381            .unwrap();
382        assert_eq!(second.to_string(), "outstanding/1");
383    }
384
385    #[tokio::test]
386    async fn overwrite_is_conflict() {
387        let mut s = store();
388        let path = s
389            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
390            .await
391            .unwrap();
392        let err = s
393            .write_detached(&path, parsed(Value::from("clobber")))
394            .await
395            .unwrap_err();
396        assert!(matches!(err, Error::Conflict { .. }));
397    }
398
399    #[tokio::test]
400    async fn write_result_is_in_caller_namespace() {
401        let mut s = store();
402        let path = s
403            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
404            .await
405            .unwrap();
406        let result = s
407            .write_detached(
408                &path.join(&Path::parse("push").unwrap()),
409                parsed(Value::from(1i64)),
410            )
411            .await
412            .unwrap();
413        assert_eq!(result.to_string(), format!("{}/push", path));
414    }
415
416    #[tokio::test]
417    async fn tail_read_through_store() {
418        let mut s = store();
419        let handle = s
420            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
421            .await
422            .unwrap();
423        s.write_detached(
424            &handle.join(&Path::parse("push").unwrap()),
425            parsed(Value::from(1i64)),
426        )
427        .await
428        .unwrap();
429        s.write_detached(
430            &handle.join(&Path::parse("done").unwrap()),
431            parsed(Value::Null),
432        )
433        .await
434        .unwrap();
435
436        let record = s
437            .read_detached(&handle.join(&Path::parse("events/from/0").unwrap()))
438            .await
439            .unwrap()
440            .unwrap();
441        let map = match record.as_value().unwrap() {
442            Value::Map(m) => m.clone(),
443            _ => panic!("expected envelope"),
444        };
445        assert_eq!(map.get("status"), Some(&Value::from("done")));
446        assert!(matches!(map.get("items"), Some(Value::Array(a)) if a.len() == 1));
447    }
448
449    #[tokio::test]
450    async fn release_cancels_parked_reads() {
451        let mut s = store();
452        let handle = s
453            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
454            .await
455            .unwrap();
456
457        // Park a tail read with no events.
458        let mut reader = s.clone();
459        let tail_path = handle.join(&Path::parse("events/from/0").unwrap());
460        let parked = tokio::spawn(async move { reader.read_detached(&tail_path).await });
461        tokio::task::yield_now().await;
462
463        // Release the handle: the parked read must fail with Cancelled.
464        s.write_detached(&handle, parsed(Value::Null))
465            .await
466            .unwrap();
467        let err = parked.await.unwrap().unwrap_err();
468        assert!(err.is_cancelled());
469
470        // Post-release reads see absence.
471        assert!(s.read_detached(&handle).await.unwrap().is_none());
472    }
473
474    #[tokio::test]
475    async fn dropping_the_store_releases_live_handles() {
476        let s = store();
477        let handle_path = {
478            let mut s = s.clone();
479            s.write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
480                .await
481                .unwrap()
482        };
483
484        // Detached futures don't hold the store: create the read future,
485        // drop every store clone, and the parked read must cancel.
486        let tail = handle_path.join(&Path::parse("events/from/0").unwrap());
487        let fut = {
488            let mut reader = s.clone();
489            reader.read_detached(&tail)
490        };
491        let parked = tokio::spawn(fut);
492        tokio::task::yield_now().await;
493        drop(s);
494
495        let err = tokio::time::timeout(std::time::Duration::from_secs(5), parked)
496            .await
497            .expect("parked read never resolved after store drop")
498            .unwrap()
499            .unwrap_err();
500        assert!(err.is_cancelled());
501    }
502
503    #[tokio::test]
504    async fn release_is_idempotent() {
505        let mut s = store();
506        let handle = s
507            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("r")))
508            .await
509            .unwrap();
510        s.write_detached(&handle, parsed(Value::Null))
511            .await
512            .unwrap();
513        // Second release of the same handle is a no-op, not an error.
514        s.write_detached(&handle, parsed(Value::Null))
515            .await
516            .unwrap();
517        // Releasing a handle that never existed is also fine.
518        s.write_detached(
519            &Path::parse("outstanding/999").unwrap(),
520            parsed(Value::Null),
521        )
522        .await
523        .unwrap();
524    }
525
526    #[tokio::test]
527    async fn listing_tracks_live_handles() {
528        let mut s = store();
529        let a = s
530            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("a")))
531            .await
532            .unwrap();
533        let _b = s
534            .write_detached(&Path::parse("").unwrap(), parsed(Value::from("b")))
535            .await
536            .unwrap();
537
538        let listing = s
539            .read_detached(&Path::parse("outstanding").unwrap())
540            .await
541            .unwrap()
542            .unwrap();
543        let items = match listing.as_value().unwrap() {
544            Value::Map(m) => match m.get("items").unwrap() {
545                Value::Array(a) => a.len(),
546                _ => panic!(),
547            },
548            _ => panic!(),
549        };
550        assert_eq!(items, 2);
551
552        s.write_detached(&a, parsed(Value::Null)).await.unwrap();
553        assert_eq!(s.live_handles(), 1);
554    }
555
556    #[tokio::test]
557    async fn unknown_paths_absent() {
558        let mut s = store();
559        assert!(s
560            .read_detached(&Path::parse("outstanding/42").unwrap())
561            .await
562            .unwrap()
563            .is_none());
564        assert!(s
565            .read_detached(&Path::parse("something/else").unwrap())
566            .await
567            .unwrap()
568            .is_none());
569        let err = s
570            .write_detached(
571                &Path::parse("outstanding/42/push").unwrap(),
572                parsed(Value::from(1i64)),
573            )
574            .await
575            .unwrap_err();
576        assert!(err.is_not_found());
577    }
578
579    #[test]
580    fn handle_path_component_is_valid() {
581        let p = HandleStore::<StreamProtocol>::handle_path(7);
582        assert_eq!(p.to_string(), "outstanding/7");
583        let _ = structfs_core_store::PathComponent::try_new("outstanding").unwrap();
584    }
585}