hyper-util legacy client: each dropped pool `Checkout` scans every parked waiter under the pool lock, O(n²) at cold start
Les mainteneurs répondent en général sous 1 jour
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 48/100
- Type d'issue
- Bug
- Clarté
- Clairement spécifiée
- Activité
- Active
- Stack technique
- rust
- Domaine
- backend, performance
Piste de recherche
Read hyper-util's legacy client pool code, especially PoolInner.waiters, clean_waiters, and Checkout::drop; the issue identifies these as the path that scans parked waiters under the pool lock. Start by tracing how Client::one_connection_for drops a parked checkout and how canceled requests reach the same cleanup. The issue’s reproduction and timing tables define done: dropped checkouts should have amortized O(1) cleanup under the lock, with cold-start work scaling linearly.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Version
hyper-util 0.1.20, 0.1.21 and master at 64855f6; hyper 1.x; tokio 1.x. HTTP/1 only. In production, through reqwest 0.13.5.
Platform
Production: Linux x86_64, 192 cores. Repro: macOS on Apple M5 Pro (Darwin 25.6.0 arm64), 15 cores.
Summary
When many requests to one host start while the pool has no idle connections, each request parks a oneshot::Sender in PoolInner.waiters[key]. When a parked Checkout is dropped, Drop takes the pool mutex and calls clean_waiters. That function runs VecDeque::retain over the whole queue. A Checkout is dropped this way when the request's own new connection wins the race in Client::one_connection_for, and also when the request is canceled. With n parked waiters, the total work under the single pool mutex is O(n²).
A standalone repro (in-memory connector, no sockets) shows this on hyper-util 0.1.20, 0.1.21 and master at 64855f6. At 131,072 concurrent requests, the run takes 10.5 s on master and 1.8 s with the fix below. The fixed latency in the run is 1.5 s.
Versions: hyper-util 0.1.20 / 0.1.21 / master 64855f6, hyper 1.x, tokio 1.x, HTTP/1 only.
Code Sample
This is a single-file binary. The connector returns one end of tokio::io::duplex and a hyper::server::conn::http1 task serves the other end. The connector sleeps connect_ms to model connect latency, and the server sleeps resp_ms before it responds. The binary fires n concurrent GETs at one authority from a cold pool and prints the wall time. The client is configured like reqwest's defaults: pool timer on, 90 s idle timeout, unbounded idle per host.
Cargo.toml and src/main.rs
[package]
name = "pool-waiters-repro"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
hyper-util = { version = "=0.1.20", features = ["client-legacy", "http1", "tokio"] }
hyper = { version = "1", features = ["client", "server", "http1"] }
tokio = { version = "1", features = ["full"] }
http-body-util = "0.1"
tower-service = "0.3"
//! Fire N concurrent GETs at one authority through hyper-util's legacy client,
//! starting from a cold pool. The connector hands out in-memory duplex pipes
//! served by hyper's HTTP/1 server, so there are no sockets or fd limits.
//!
//! Usage: cargo run --release -- n=65536 connect_ms=500 resp_ms=1000 cancel_pct=0 cancel_ms=10 threads=0 deadline_s=120
use std::convert::Infallible;
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::{Bytes, Incoming};
use hyper::service::service_fn;
use hyper::{Request, Response, Uri};
use hyper_util::client::legacy::connect::{Connected, Connection};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use tokio::io::DuplexStream;
// Implements hyper's IO traits directly, since newer hyper-util gates
// `Connection for TokioIo<T>` behind the HTTP connector feature.
struct Pipe(TokioIo<DuplexStream>);
impl Connection for Pipe {
fn connected(&self) -> Connected {
Connected::new()
}
}
impl hyper::rt::Read for Pipe {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: hyper::rt::ReadBufCursor<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.0).poll_read(cx, buf)
}
}
impl hyper::rt::Write for Pipe {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
b: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.0).poll_write(cx, b)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.0).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.0).poll_shutdown(cx)
}
}
#[derive(Clone)]
struct DuplexConnector {
connect_delay: Duration,
resp_delay: Duration,
connects: Arc<AtomicU64>,
}
impl tower_service::Service<Uri> for DuplexConnector {
type Response = Pipe;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _: Uri) -> Self::Future {
let this = self.clone();
Box::pin(async move {
if !this.connect_delay.is_zero() {
tokio::time::sleep(this.connect_delay).await;
}
this.connects.fetch_add(1, Relaxed);
let (client, server) = tokio::io::duplex(16 * 1024);
tokio::spawn(serve(server, this.resp_delay));
Ok(Pipe(TokioIo::new(client)))
})
}
}
async fn serve(io: DuplexStream, resp_delay: Duration) {
let svc = service_fn(move |_req: Request<Incoming>| async move {
if !resp_delay.is_zero() {
tokio::time::sleep(resp_delay).await;
}
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(io), svc)
.await;
}
struct Args {
n: usize,
connect_ms: u64,
resp_ms: u64,
cancel_pct: u64,
cancel_ms: u64,
threads: usize,
deadline_s: u64,
}
fn parse_args() -> Args {
let mut a = Args {
n: 65536,
connect_ms: 500,
resp_ms: 1000,
cancel_pct: 0,
cancel_ms: 10,
threads: 0,
deadline_s: 120,
};
for kv in std::env::args().skip(1) {
let (k, v) = kv.split_once('=').expect("args are key=value");
let v: u64 = v.parse().expect("numeric value");
match k {
"n" => a.n = v as usize,
"connect_ms" => a.connect_ms = v,
"resp_ms" => a.resp_ms = v,
"cancel_pct" => a.cancel_pct = v,
"cancel_ms" => a.cancel_ms = v,
"threads" => a.threads = v as usize,
"deadline_s" => a.deadline_s = v,
_ => panic!("unknown arg {k}"),
}
}
a
}
fn main() {
let args = parse_args();
let mut rt = tokio::runtime::Builder::new_multi_thread();
if args.threads > 0 {
rt.worker_threads(args.threads);
}
let rt = rt.enable_all().build().unwrap();
let threads = rt.metrics().num_workers();
rt.block_on(run(args, threads));
// Skip dropping tens of thousands of pooled connections.
std::process::exit(0);
}
async fn run(args: Args, threads: usize) {
let connects = Arc::new(AtomicU64::new(0));
let connector = DuplexConnector {
connect_delay: Duration::from_millis(args.connect_ms),
resp_delay: Duration::from_millis(args.resp_ms),
connects: connects.clone(),
};
// Mirrors reqwest's defaults: pool timer on, 90 s idle timeout, unbounded idle per host.
let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new())
.pool_timer(TokioTimer::new())
.pool_idle_timeout(Duration::from_secs(90))
.build(connector);
let uri: Uri = "http://bench.invalid/obj".parse().unwrap();
let ok = Arc::new(AtomicU64::new(0));
let canceled = Arc::new(AtomicU64::new(0));
let first_ns = Arc::new(AtomicU64::new(u64::MAX));
let t0 = Instant::now();
let mut set = tokio::task::JoinSet::new();
for i in 0..args.n {
let client = client.clone();
let uri = uri.clone();
let (ok, canceled, first_ns) = (ok.clone(), canceled.clone(), first_ns.clone());
let cancel = (i as u64 % 100) < args.cancel_pct;
let cancel_after = Duration::from_millis(args.cancel_ms);
set.spawn(async move {
let req = async {
let resp = client.get(uri).await.expect("request");
resp.into_body().collect().await.expect("body");
};
let done = if cancel {
tokio::time::timeout(cancel_after, req).await.is_ok()
} else {
req.await;
true
};
if done {
first_ns.fetch_min(t0.elapsed().as_nanos() as u64, Relaxed);
ok.fetch_add(1, Relaxed);
} else {
canceled.fetch_add(1, Relaxed);
}
});
}
let spawn_ms = t0.elapsed().as_secs_f64() * 1e3;
let deadline = Duration::from_secs(args.deadline_s);
let all = async { while set.join_next().await.is_some() {} };
let finished = tokio::time::timeout(deadline, all).await.is_ok();
let wall = t0.elapsed().as_secs_f64();
let first = first_ns.load(Relaxed);
let first_ms = if first == u64::MAX {
f64::NAN
} else {
first as f64 / 1e6
};
print!(
"crate={} threads={} n={} connect_ms={} resp_ms={} cancel_pct={} finished={} wall_s={:.3} spawn_ms={:.0} first_ms={:.1} ok={} canceled={} connects={}",
env!("CARGO_PKG_NAME"),
threads,
args.n,
args.connect_ms,
args.resp_ms,
args.cancel_pct,
finished,
wall,
spawn_ms,
first_ms,
ok.load(Relaxed),
canceled.load(Relaxed),
connects.load(Relaxed),
);
println!();
}
Run: cargo run --release -- n=65536 (defaults: connect_ms=500 resp_ms=1000). To test a patched checkout, add [patch.crates-io] hyper-util = { path = "..." } and set the version to match.
Expected Behavior
A dropped Checkout costs O(1) amortized under the pool lock, so a cold start with many concurrent requests to one host scales linearly.
Actual Behavior
Each dropped parked Checkout scans the whole waiter queue under the pool mutex, so a cold start costs O(n²) under one lock. On a 192-core host this became a livelock.
Repro results
Machine: Apple M5 Pro, 15 cores (5 performance + 10 efficiency), 24 GiB, macOS, rustc 1.99.0, release builds, default tokio multi-thread runtime (15 workers). connect_ms=500 resp_ms=1000, so 1.5 s of every run is fixed latency. The values are the median wall time of 3 runs.
| concurrent requests | master 64855f6 |
master + proposed fix |
|---|---|---|
| 16,384 | 1.77 s | 1.57 s |
| 65,536 | 3.82 s | 1.66 s |
| 131,072 | 10.48 s | 1.83 s |
To count the work, I made a copy of 0.1.20 with counters added. One counter totals the is_canceled() checks in clean_waiters. The others time how long Drop waits for the pool lock and how long it holds it. The counters are summed over all threads, and each value is the median of 3 runs.
| N | 0.1.20: senders scanned | 0.1.20: lock held in Drop |
0.1.20: waiting for lock in Drop (thread-s) |
fix: senders scanned | fix: lock held | fix: waiting (thread-s) |
|---|---|---|---|---|---|---|
| 1,024 | 5.2e5 | 0.001 s | 0.03 | 2.0e3 | <0.001 s | 0.01 |
| 4,096 | 8.4e6 | 0.011 s | 0.25 | 8.2e3 | 0.001 s | 0.02 |
| 16,384 | 1.3e8 | 0.16 s | 3.1 | 3.3e4 | 0.003 s | 0.07 |
| 65,536 | 1.7e9 | 1.97 s | 27 | 1.3e5 | 0.011 s | 0.24 |
| 131,072 | 4.7e9 | 8.07 s | 83 | 2.6e5 | 0.024 s | 0.83 |
From 4k to 16k requests, the senders scanned grow 16×, which is quadratic. Above 64k the growth slows. The reason is that the convoy delays the drops past t = 1.5 s. Responses then start to return connections, put gives them to the parked waiters, and fewer checkouts get to Drop while still parked. At 131k, only 67k of the checkouts got there.
Other scenarios, at N = 65,536 (median wall time of 3 runs, 1.5 s of fixed latency):
- 25% of requests canceled with
tokio::time::timeout(250ms), which fires before any connect completes: 0.1.20 took 4.07 s and the fix took 1.63 s. - 192 worker threads on the same 15 cores: 0.1.20 took 4.42 s and the fix took 1.71 s. Time spent waiting for the lock in
Dropwas 477 thread-s with 0.1.20 and 3.3 thread-s with the fix. This is oversubscribed, so it does not model a real 192-core host. It does show that the time threads spend waiting for the lock grows with the number of workers.
This 15-core machine shows a superlinear slowdown but not a full stall. The production stall below was on a 192-core host. I have not run this repro on a many-core Linux host.
Production evidence
- Stack: lance → object_store 0.14 → reqwest 0.13.5 → hyper-util 0.1.20 legacy client. HTTP/1 only, one
reqwest::Client, one host, tens of thousands of concurrent GETs from a cold pool, on a 192-core Linux node. - 22 of 30 cold starts stalled. There were zero responses for 10 minutes while
curlfrom the same node to the same endpoint succeeded. - gdb showed about 192 tokio workers in
std::sys::sync::mutex::futex::Mutex::lock_contendedunderhyper_util::client::legacy::pool::Checkout, mostly indrop_glue::<Checkout>. - In an A/B test at the same load,
Drop for Checkoutwas patched to skipclean_waiters(keepingself.waiter.take()). Stalls went from 8/10 to 0/10 (Fisher exact p ≈ 7e-4). The amortized fix below has not been tested in production.
Additional Information
Mechanism
Permalinks are to master at 64855f6.
- With no idle connection,
Checkout::checkoutpushes a sender ontowaiters[key]under the pool lock: pool.rs#L684-L696. Client::one_connection_forraces that checkout against a new connect. If the connect wins, the checkout is dropped while it is still parked: client.rs#L419-L454.impl Drop for Checkoutlocks the pool and callsclean_waiters: pool.rs#L725-L734.clean_waitersdoeswaiters.retain(|tx| !tx.is_canceled()), which is O(queue length): pool.rs#L465-L479.PoolInner::putalready pops from the front and skips canceled senders: pool.rs#L356-L388. So the scan inDropis only needed to bound memory. It was added for that reason in hyperium/hyper#1325 (fixing hyperium/hyper#1315).
At cold start, nearly every request parks and then drops its checkout. That gives about n²/2 is_canceled() checks, all under the one mutex that every checkout, drop and put needs.
Proposed fix
Keep a count of the parked checkouts dropped since the last purge, beside each key's queue. In clean_waiters, increment the count and run retain only when canceled * 2 >= queue.len(). Then reset the count. The patch is below, against master, with a test modeled on test_pool_checkout_drop_cleans_up_waiters. I can open it as a PR to hyper-util.
- Amortized O(1) per drop. A purge scans
lensenders. Only drops increment the counter, and it is reset to zero at each purge. A purge runs only whenlen <= 2 * canceled, so it costs at most 2 checks for each drop since the previous purge.putpops canceled senders without decrementing the counter, so the counter can over-count. That only makes a purge happen earlier, and the bound stays true. - Memory stays bounded. A sender becomes canceled only when its
Checkoutis dropped, and each drop increments the counter after its receiver is gone. So after each drop, the canceled senders still queued are at mostcanceled, which is less thanlen / 2. The queue therefore holds fewer canceled senders than live ones, so it is at most twice the live waiters. Checkouts that are in the middle ofDropon other threads can add a few more. When the last waiter drops, the key is still removed. This keeps the guarantee from hyperium/hyper#1315. test_pool_checkout_drop_cleans_up_waiterspasses without changes. The newtest_pool_checkout_drop_cleans_up_waiters_amortizedchecks two things: one drop out of 64 does not purge, andqueued <= 2 * livestays true while the checkouts drop. The new test fails onmaster.cargo test --features fullpasses with the patch.
Drop still takes the pool lock once per parked checkout, but it now holds it only for O(1) work. In the repro, this fix and the diagnostic "no clean in Drop" patch (which takes no lock in Drop) gave the same wall times within noise. At 65,536 requests and 15 workers, the fix took 1.67 s and the diagnostic patch took 1.67 s. The lock acquisition itself could be removed with an atomic counter, but that seems unnecessary.
fix.patch
--- a/src/client/legacy/pool.rs
+++ b/src/client/legacy/pool.rs
@@ -92,7 +92,7 @@
// this list is checked for any parked Checkouts, and tries to notify
// them that the Conn could be used instead of waiting for a brand new
// connection.
- waiters: HashMap<K, VecDeque<oneshot::Sender<T>>>,
+ waiters: HashMap<K, Waiters<T>>,
// A oneshot channel is used to allow the interval to be notified when
// the Pool completely drops. That way, the interval can cancel immediately.
idle_interval_ref: Option<oneshot::Sender<Infallible>>,
@@ -101,6 +101,23 @@
timeout: Option<Duration>,
}
+/// The parked `Checkout`s for one key.
+struct Waiters<T> {
+ queue: VecDeque<oneshot::Sender<T>>,
+ // Checkouts dropped since the queue was last purged. Purging only once
+ // this reaches half the queue keeps each drop amortized O(1).
+ canceled: usize,
+}
+
+impl<T> Waiters<T> {
+ fn new() -> Self {
+ Waiters {
+ queue: VecDeque::new(),
+ canceled: 0,
+ }
+ }
+}
+
// This is because `Weak::new()` *allocates* space for `T`, even if it
// doesn't need it!
struct WeakOpt<T>(Option<Weak<T>>);
@@ -354,7 +371,7 @@
let mut remove_waiters = false;
let mut value = Some(value);
if let Some(waiters) = self.waiters.get_mut(&key) {
- while let Some(tx) = waiters.pop_front() {
+ while let Some(tx) = waiters.queue.pop_front() {
if !tx.is_canceled() {
let reserved = value.take().expect("value already sent");
let reserved = match reserved.reserve() {
@@ -381,7 +398,7 @@
trace!("put; removing canceled waiter for {:?}", key);
}
- remove_waiters = waiters.is_empty();
+ remove_waiters = waiters.queue.is_empty();
}
if remove_waiters {
self.waiters.remove(&key);
@@ -466,11 +483,20 @@
/// and possibly inserted into the pool that it is waiting for an idle
/// connection. If a user ever dropped that future, we need to clean out
/// those parked senders.
+ ///
+ /// Scanning the whole queue on every drop is O(n^2) under the pool lock
+ /// when many checkouts are parked, so the scan waits until at least half
+ /// the queue may be canceled. That bounds the queue to about twice the
+ /// live waiters.
fn clean_waiters(&mut self, key: &K) {
let mut remove_waiters = false;
if let Some(waiters) = self.waiters.get_mut(key) {
- waiters.retain(|tx| !tx.is_canceled());
- remove_waiters = waiters.is_empty();
+ waiters.canceled += 1;
+ if waiters.canceled * 2 >= waiters.queue.len() {
+ waiters.queue.retain(|tx| !tx.is_canceled());
+ waiters.canceled = 0;
+ remove_waiters = waiters.queue.is_empty();
+ }
}
if remove_waiters {
self.waiters.remove(key);
@@ -687,7 +713,8 @@
inner
.waiters
.entry(self.key.clone())
- .or_insert_with(VecDeque::new)
+ .or_insert_with(Waiters::new)
+ .queue
.push_back(tx);
// register the waker with this oneshot
@@ -1075,18 +1102,45 @@
// first poll needed to get into Pool's parked
poll_once1.await;
- assert_eq!(pool.locked().waiters.get(&key).unwrap().len(), 1);
+ assert_eq!(pool.locked().waiters.get(&key).unwrap().queue.len(), 1);
poll_once2.await;
- assert_eq!(pool.locked().waiters.get(&key).unwrap().len(), 2);
+ assert_eq!(pool.locked().waiters.get(&key).unwrap().queue.len(), 2);
// on drop, clean up Pool
drop(checkout1);
- assert_eq!(pool.locked().waiters.get(&key).unwrap().len(), 1);
+ assert_eq!(pool.locked().waiters.get(&key).unwrap().queue.len(), 1);
drop(checkout2);
assert!(!pool.locked().waiters.contains_key(&key));
}
+ #[tokio::test]
+ async fn test_pool_checkout_drop_cleans_up_waiters_amortized() {
+ let pool = pool_no_timer::<Uniq<i32>, KeyImpl>();
+ let key = host_key("foo");
+
+ let mut checkouts: Vec<_> = (0..64).map(|_| pool.checkout(key.clone())).collect();
+ for checkout in &mut checkouts {
+ PollOnce(checkout).await;
+ }
+ assert_eq!(pool.locked().waiters.get(&key).unwrap().queue.len(), 64);
+
+ // A single drop does not scan the queue.
+ checkouts.pop();
+ assert_eq!(pool.locked().waiters.get(&key).unwrap().queue.len(), 64);
+
+ // Canceled senders never outnumber the live ones.
+ while checkouts.pop().is_some() {
+ let live = checkouts.len();
+ let queued = pool.locked().waiters.get(&key).map_or(0, |w| w.queue.len());
+ assert!(
+ queued <= 2 * live,
+ "queued {queued} for {live} live waiters"
+ );
+ }
+ assert!(!pool.locked().waiters.contains_key(&key));
+ }
+
#[derive(Debug)]
struct CanClose {
#[allow(unused)]
- Langage dominant
- Rust
- Étoiles
- 16.4k
- Forks
- 1.8k
- Merge moyen
- 1 j 13 h
- PR mergées (30 j)
- 12
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Aucun modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de hyperium/hyper
-
Publicly reexport the http crateOuverteC-feature
Difficulté 1/5 Moins d'une heure Accessibilité débutants 65/100
hyperium/hyper#2652 · 4 réactions ·
Les mainteneurs répondent en général sous 1 jour
-
C-feature
Difficulté 5/5 Plus d'une semaine Accessibilité débutants 35/100
Les mainteneurs répondent en général sous 1 jour
-
False `SendRequest` readiness for HTTP/1 connectionsPeut-être pris @akasakariko l’a pris il y a 1 jour. OuverteC-bug
Difficulté 4/5 1-2 jours Accessibilité débutants 55/100
hyperium/hyper#4246 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
can't upgrade request that came in with `Connection: close, Upgrade`Peut-être pris @akasakariko l’a pris il y a 1 jour. OuverteC-bug
Difficulté 3/5 1-2 jours Accessibilité débutants 62/100
hyperium/hyper#4244 · 4 commentaires ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 4/5 3-5 jours Accessibilité débutants 52/100
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de hyperium/hyper
Issues similaires
-
desktop documentation
Difficulté 1/5 Moins d'une heure Accessibilité débutants 90/100
vfarcic/dot-agent-deck#2008 ·
Les mainteneurs répondent en général sous 1 jour
-
needs triage
Difficulté 1/5 Moins d'une heure Accessibilité débutants 85/100
pbakaus/impeccable#1210 ·
Les mainteneurs répondent en général sous 1 jour
-
area: ci priority: P4 ready-for-agent
Difficulté 2/5 1-3 heures Accessibilité débutants 74/100
shep-pm/shep#1249 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
L: javascript
Difficulté 2/5 1-3 heures Accessibilité débutants 72/100
dependabot/dependabot-core#16744 ·
Les mainteneurs répondent en général sous 1 jour
-
bug
Difficulté 2/5 1-3 heures Accessibilité débutants 68/100
nautechsystems/nautilus_trader#5486 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour