OXYGENHUB

Strata: one client per cloud store, its requests side by side, and counts 2ddd471651cd

A hub opens a repository for every request, and with it a new cloud store:
a new runtime, new connections and signing in to the provider again. On
oxygenhub.io a negotiate on a repository kept in GCS took 7-8 s, the hub
mostly waiting (a profile showed a quarter of one core busy). Opening a
store now shares one client per URL and credentials for the life of the
process; its runtime has two workers and no lock, so a shared store's
requests run side by side as separate stores' did. stats() counts clients
opened, requests made and the time spent in them, for a server to show. A
test checks a store opened again shares its client and other credentials
get another.

Refs andymccall/oxygenhub#234, andymccall/oxygenhub#228
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-17 00:18 UTC
Signature
✔ signed by Claude Code
Hash
2ddd471651cd8ba71992cdcc29c5bc42b3094d0f567322143900cf218fd66466
Depends on
2cd40ea1c04c 7026bd7dc5b8 8d80356371c5 c9369ad8b1b1
Depended on by
7e0e8e5ad882 1e0fbb47ab90
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
11 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-strata/Cargo.toml +1 −1
…
18 unchanged lines
19 19
http = "1"
20 20
http-body-util = "0.1"
21 21
bytes = "1"
22 -
tokio = { version = "1", default-features = false, features = ["rt"] }
22 +
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "rt"] }
23 23
futures = "0.3"
24 24
serde_json = "1"
25 25
…
2 unchanged lines
modified crates/oxy-strata/src/lib.rs +122 −13
…
47 unchanged lines
48 48
    }
49 49
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
50 50
    let given = lookup.and_then(|f| f(url));
51 -
    Some(CloudStore::open_with(url, given.as_ref()).map(|s| Box::new(s) as Box<dyn Store>))
51 +
    Some(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>))
52 52
}
53 53
54 +
/// One client per store and credentials, kept for the life of the process
55 +
/// (oxygenhub#234): a server opens a repository for every request, and a
56 +
/// new client each time meant a new runtime, new connections and signing
57 +
/// in to the provider again before the first byte.
58 +
type OpenStores = std::collections::HashMap<(String, Option<Credentials>), Arc<CloudStore>>;
59 +
60 +
fn shared(url: &str, given: Option<Credentials>) -> Result<Arc<CloudStore>> {
61 +
    static OPEN: Mutex<Option<OpenStores>> = Mutex::new(None);
62 +
    let key = (url.to_string(), given);
63 +
    let mut open = OPEN.lock().unwrap_or_else(|p| p.into_inner());
64 +
    let map = open.get_or_insert_with(Default::default);
65 +
    if let Some(s) = map.get(&key) {
66 +
        return Ok(s.clone());
67 +
    }
68 +
    let store = Arc::new(CloudStore::open_with(url, key.1.as_ref())?);
69 +
    map.insert(key, store.clone());
70 +
    Ok(store)
71 +
}
72 +
73 +
/// A shared [`CloudStore`] as a [`Store`].
74 +
struct Shared(Arc<CloudStore>);
75 +
76 +
impl Store for Shared {
77 +
    fn describe(&self) -> String {
78 +
        self.0.describe()
79 +
    }
80 +
    fn has(&self, key: &str) -> Result<bool> {
81 +
        self.0.has(key)
82 +
    }
83 +
    fn get(&self, key: &str) -> Result<Vec<u8>> {
84 +
        self.0.get(key)
85 +
    }
86 +
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
87 +
        self.0.get_range(key, range)
88 +
    }
89 +
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
90 +
        self.0.put_new(key, bytes)
91 +
    }
92 +
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
93 +
        self.0.list(prefix)
94 +
    }
95 +
    fn delete(&self, key: &str) -> Result<()> {
96 +
        self.0.delete(key)
97 +
    }
98 +
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
99 +
        self.0.read_ref(key)
100 +
    }
101 +
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
102 +
        self.0.write_ref(key, bytes, expected)
103 +
    }
104 +
}
105 +
106 +
static OPENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
107 +
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
108 +
static MICROS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
109 +
110 +
/// What the cloud stores have done since the process started: clients
111 +
/// opened, requests made and the time spent in them, for a server to show
112 +
/// (a hub's /health).
113 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114 +
pub struct Stats {
115 +
    pub opens: u64,
116 +
    pub calls: u64,
117 +
    pub millis: u64,
118 +
}
119 +
120 +
pub fn stats() -> Stats {
121 +
    use std::sync::atomic::Ordering::Relaxed;
122 +
    Stats {
123 +
        opens: OPENS.load(Relaxed),
124 +
        calls: CALLS.load(Relaxed),
125 +
        millis: MICROS.load(Relaxed) / 1000,
126 +
    }
127 +
}
128 +
54 129
/// Credentials a program keeps itself and hands over, instead of the
55 130
/// provider's usual places: a hub keeps each repository's sealed. When a
56 131
/// store is opened with them, nothing from the environment or the
57 132
/// provider's files is used.
58 -
#[derive(Clone, PartialEq, Eq)]
133 +
#[derive(Clone, PartialEq, Eq, Hash)]
59 134
pub enum Credentials {
60 135
    /// An S3 access key.
61 136
    Aws {
…
194 unchanged lines
256 331
    prefix: String,
257 332
    name: String,
258 333
    hint: &'static str,
259 -
    /// Always there until the store is dropped.
334 +
    /// Always there until the store is dropped: two workers, so the
335 +
    /// requests of a shared store run side by side.
260 336
    runtime: Option<tokio::runtime::Runtime>,
261 -
    /// One call at a time: the runtime runs on the calling thread.
262 -
    turn: Mutex<()>,
263 337
}
264 338
265 339
impl Drop for CloudStore {
…
53 unchanged lines
319 393
            )),
320 394
            _ => None,
321 395
        };
322 -
        let runtime = tokio::runtime::Builder::new_current_thread()
396 +
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
397 +
        let runtime = tokio::runtime::Builder::new_multi_thread()
398 +
            .worker_threads(2)
399 +
            .thread_name("oxy-strata")
323 400
            .enable_all()
324 401
            .build()
325 402
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
…
25 unchanged lines
351 428
            name: parsed.plain(),
352 429
            hint,
353 430
            runtime: Some(runtime),
354 -
            turn: Mutex::new(()),
355 431
        })
356 432
    }
357 433
…
5 unchanged lines
363 439
        F: Future + Send,
364 440
        F::Output: Send,
365 441
    {
366 -
        let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner());
367 442
        let runtime = self
368 443
            .runtime
369 444
            .as_ref()
370 445
            .expect("the runtime lives as long as the store");
371 -
        if tokio::runtime::Handle::try_current().is_ok() {
372 -
            return std::thread::scope(|s| {
446 +
        let started = std::time::Instant::now();
447 +
        let out = if tokio::runtime::Handle::try_current().is_ok() {
448 +
            std::thread::scope(|s| {
373 449
                s.spawn(|| runtime.block_on(f))
374 450
                    .join()
375 451
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
376 -
            });
377 -
        }
378 -
        runtime.block_on(f)
452 +
            })
453 +
        } else {
454 +
            runtime.block_on(f)
455 +
        };
456 +
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
457 +
        MICROS.fetch_add(
458 +
            started.elapsed().as_micros() as u64,
459 +
            std::sync::atomic::Ordering::Relaxed,
460 +
        );
461 +
        out
379 462
    }
380 463
381 464
    fn path(&self, key: &str) -> Result<ObjectPath> {
…
220 unchanged lines
602 685
603 686
#[cfg(test)]
604 687
mod tests {
688 +
689 +
    /// One client per store and credentials (oxygenhub#234): opening the
690 +
    /// same again shares it, and only a new one counts as opened.
691 +
    #[test]
692 +
    fn a_store_opened_again_shares_its_client() {
693 +
        let creds = |k: &str| Credentials::Aws {
694 +
            access_key_id: k.into(),
695 +
            secret_access_key: "s".into(),
696 +
            session_token: None,
697 +
        };
698 +
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
699 +
        let before = super::stats().opens;
700 +
        let a = super::shared(url, Some(creds("one"))).unwrap();
701 +
        let b = super::shared(url, Some(creds("one"))).unwrap();
702 +
        assert!(std::sync::Arc::ptr_eq(&a, &b));
703 +
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
704 +
        assert!(
705 +
            !std::sync::Arc::ptr_eq(&a, &other_keys),
706 +
            "other credentials, another client"
707 +
        );
708 +
        assert!(
709 +
            super::stats().opens - before >= 2,
710 +
            "two clients opened (other tests may open more)"
711 +
        );
712 +
    }
713 +
605 714
    #[tokio::test]
606 715
    async fn a_store_can_be_used_from_inside_a_runtime() {
607 716
        // As a server's handler would: blocking on the store's own runtime
…
83 unchanged lines