kino 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/Cargo.lock +115 -24
- data/README.md +113 -41
- data/doc/architecture.md +61 -7
- data/doc/benchmarks.md +120 -26
- data/ext/kino/Cargo.toml +8 -3
- data/ext/kino/src/control.rs +267 -60
- data/ext/kino/src/env_strings.rs +172 -12
- data/ext/kino/src/io_shards.rs +437 -0
- data/ext/kino/src/lib.rs +12 -7
- data/ext/kino/src/listen.rs +4 -1
- data/ext/kino/src/log.rs +10 -2
- data/ext/kino/src/queue.rs +61 -44
- data/ext/kino/src/registry.rs +103 -10
- data/ext/kino/src/request.rs +150 -69
- data/ext/kino/src/response.rs +40 -9
- data/ext/kino/src/server.rs +759 -97
- data/ext/kino/src/tls.rs +129 -12
- data/lib/kino/configuration.rb +16 -0
- data/lib/kino/server.rb +10 -0
- data/lib/kino/templates/kino.rb.tt +13 -2
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/sig/kino.rbs +3 -0
- metadata +16 -1
data/ext/kino/src/control.rs
CHANGED
|
@@ -120,7 +120,11 @@ pub fn stats_json(s: &StatsSnapshot) -> String {
|
|
|
120
120
|
)
|
|
121
121
|
.expect("writing to a String cannot fail");
|
|
122
122
|
if let Some(depths) = &s.lane_depths {
|
|
123
|
-
let list = depths
|
|
123
|
+
let list = depths
|
|
124
|
+
.iter()
|
|
125
|
+
.map(|d| d.to_string())
|
|
126
|
+
.collect::<Vec<_>>()
|
|
127
|
+
.join(",");
|
|
124
128
|
write!(out, r#","lane_depths":[{list}]"#).expect("writing to a String cannot fail");
|
|
125
129
|
}
|
|
126
130
|
out.push_str(r#","worker_status":["#);
|
|
@@ -146,7 +150,8 @@ pub fn stats_json(s: &StatsSnapshot) -> String {
|
|
|
146
150
|
write!(
|
|
147
151
|
out,
|
|
148
152
|
r#","quarantined":{},"state":"{}","version":"{}"}}"#,
|
|
149
|
-
s.quarantined_count,
|
|
153
|
+
s.quarantined_count,
|
|
154
|
+
s.state_name(),
|
|
150
155
|
env!("CARGO_PKG_VERSION")
|
|
151
156
|
)
|
|
152
157
|
.expect("writing to a String cannot fail");
|
|
@@ -156,8 +161,11 @@ pub fn stats_json(s: &StatsSnapshot) -> String {
|
|
|
156
161
|
/// One HELP/TYPE/sample triple for a single-value metric.
|
|
157
162
|
fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
|
|
158
163
|
use std::fmt::Write;
|
|
159
|
-
writeln!(
|
|
160
|
-
|
|
164
|
+
writeln!(
|
|
165
|
+
out,
|
|
166
|
+
"# HELP {name} {help}\n# TYPE {name} {kind}\n{name} {value}"
|
|
167
|
+
)
|
|
168
|
+
.expect("writing to a String cannot fail");
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
/// One HELP/TYPE header followed by one `name{label="key"} value` line per
|
|
@@ -171,9 +179,11 @@ fn series<K: std::fmt::Display, V: std::fmt::Display>(
|
|
|
171
179
|
rows: impl Iterator<Item = (K, V)>,
|
|
172
180
|
) {
|
|
173
181
|
use std::fmt::Write;
|
|
174
|
-
writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}")
|
|
182
|
+
writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}")
|
|
183
|
+
.expect("writing to a String cannot fail");
|
|
175
184
|
for (key, value) in rows {
|
|
176
|
-
writeln!(out, "{name}{{{label}=\"{key}\"}} {value}")
|
|
185
|
+
writeln!(out, "{name}{{{label}=\"{key}\"}} {value}")
|
|
186
|
+
.expect("writing to a String cannot fail");
|
|
177
187
|
}
|
|
178
188
|
}
|
|
179
189
|
|
|
@@ -181,40 +191,138 @@ fn series<K: std::fmt::Display, V: std::fmt::Display>(
|
|
|
181
191
|
pub fn metrics_text(s: &StatsSnapshot) -> String {
|
|
182
192
|
use std::fmt::Write;
|
|
183
193
|
let mut out = String::with_capacity(1024);
|
|
184
|
-
metric(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
metric(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
metric(
|
|
195
|
+
&mut out,
|
|
196
|
+
"kino_requests_served_total",
|
|
197
|
+
"counter",
|
|
198
|
+
"Requests handed to Ruby workers.",
|
|
199
|
+
s.served,
|
|
200
|
+
);
|
|
201
|
+
metric(
|
|
202
|
+
&mut out,
|
|
203
|
+
"kino_requests_rejected_total",
|
|
204
|
+
"counter",
|
|
205
|
+
"Requests rejected with a 503.",
|
|
206
|
+
s.rejected,
|
|
207
|
+
);
|
|
208
|
+
metric(
|
|
209
|
+
&mut out,
|
|
210
|
+
"kino_request_timeouts_total",
|
|
211
|
+
"counter",
|
|
212
|
+
"Responses past the request timeout (client got a 504).",
|
|
213
|
+
s.timeouts,
|
|
214
|
+
);
|
|
215
|
+
metric(
|
|
216
|
+
&mut out,
|
|
217
|
+
"kino_worker_respawns_total",
|
|
218
|
+
"counter",
|
|
219
|
+
"Crashed workers respawned by the supervisor.",
|
|
220
|
+
s.respawns,
|
|
221
|
+
);
|
|
222
|
+
metric(
|
|
223
|
+
&mut out,
|
|
224
|
+
"kino_queue_depth",
|
|
225
|
+
"gauge",
|
|
226
|
+
"Requests waiting for a worker.",
|
|
227
|
+
s.queued,
|
|
228
|
+
);
|
|
229
|
+
metric(
|
|
230
|
+
&mut out,
|
|
231
|
+
"kino_requests_in_flight",
|
|
232
|
+
"gauge",
|
|
233
|
+
"Requests currently inside Ruby workers.",
|
|
234
|
+
s.in_flight,
|
|
235
|
+
);
|
|
236
|
+
metric(
|
|
237
|
+
&mut out,
|
|
238
|
+
"kino_workers",
|
|
239
|
+
"gauge",
|
|
240
|
+
"Configured worker count.",
|
|
241
|
+
s.workers,
|
|
242
|
+
);
|
|
243
|
+
metric(
|
|
244
|
+
&mut out,
|
|
245
|
+
"kino_threads_per_worker",
|
|
246
|
+
"gauge",
|
|
247
|
+
"Configured threads per worker.",
|
|
248
|
+
s.threads,
|
|
249
|
+
);
|
|
250
|
+
metric(
|
|
251
|
+
&mut out,
|
|
252
|
+
"kino_ready",
|
|
253
|
+
"gauge",
|
|
254
|
+
"1 when serving, 0 while booting or draining.",
|
|
255
|
+
if s.state == STATE_READY { "1" } else { "0" },
|
|
256
|
+
);
|
|
194
257
|
if let Some(depths) = &s.lane_depths {
|
|
195
|
-
series(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
258
|
+
series(
|
|
259
|
+
&mut out,
|
|
260
|
+
"kino_lane_depth",
|
|
261
|
+
"gauge",
|
|
262
|
+
"Queued requests in each worker lane.",
|
|
263
|
+
"lane",
|
|
264
|
+
depths
|
|
265
|
+
.iter()
|
|
266
|
+
.enumerate()
|
|
267
|
+
.map(|(lane, depth)| (lane, *depth)),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
series(
|
|
271
|
+
&mut out,
|
|
272
|
+
"kino_worker_requests_served_total",
|
|
273
|
+
"counter",
|
|
274
|
+
"Requests handed to each dispatch slot.",
|
|
275
|
+
"worker",
|
|
276
|
+
s.worker_status.iter().map(|w| (w.index, w.served)),
|
|
277
|
+
);
|
|
278
|
+
series(
|
|
279
|
+
&mut out,
|
|
280
|
+
"kino_worker_in_flight",
|
|
281
|
+
"gauge",
|
|
282
|
+
"Requests executing in each dispatch slot.",
|
|
283
|
+
"worker",
|
|
284
|
+
s.worker_status.iter().map(|w| (w.index, w.in_flight)),
|
|
285
|
+
);
|
|
286
|
+
series(
|
|
287
|
+
&mut out,
|
|
288
|
+
"kino_worker_busy_ms",
|
|
289
|
+
"gauge",
|
|
290
|
+
"Age in ms of the current in-flight request per slot (0 when idle).",
|
|
291
|
+
"worker",
|
|
292
|
+
s.worker_status.iter().map(|w| (w.index, w.busy_ms)),
|
|
293
|
+
);
|
|
294
|
+
metric(
|
|
295
|
+
&mut out,
|
|
296
|
+
"kino_quarantined_workers",
|
|
297
|
+
"gauge",
|
|
298
|
+
"Dispatch slots abandoned as wedged.",
|
|
299
|
+
s.quarantined_count,
|
|
300
|
+
);
|
|
301
|
+
metric(
|
|
302
|
+
&mut out,
|
|
303
|
+
"kino_quarantine_replacements_total",
|
|
304
|
+
"counter",
|
|
305
|
+
"Replacement workers spawned after a wedge.",
|
|
306
|
+
s.quarantine_replacements,
|
|
307
|
+
);
|
|
206
308
|
let h = &s.queue_histogram;
|
|
207
309
|
out.push_str("# HELP kino_request_queue_seconds Seconds requests waited in the queue before a worker admitted them.\n# TYPE kino_request_queue_seconds histogram\n");
|
|
208
310
|
let mut cumulative = 0u64;
|
|
209
311
|
for (i, bound_us) in crate::registry::QUEUE_BOUNDS_US.iter().enumerate() {
|
|
210
312
|
cumulative += h.buckets[i];
|
|
211
313
|
let le = *bound_us as f64 / 1_000_000.0;
|
|
212
|
-
writeln!(
|
|
213
|
-
|
|
314
|
+
writeln!(
|
|
315
|
+
out,
|
|
316
|
+
"kino_request_queue_seconds_bucket{{le=\"{le}\"}} {cumulative}"
|
|
317
|
+
)
|
|
318
|
+
.expect("writing to a String cannot fail");
|
|
214
319
|
}
|
|
215
320
|
let total = cumulative + h.overflow;
|
|
216
|
-
writeln!(
|
|
217
|
-
|
|
321
|
+
writeln!(
|
|
322
|
+
out,
|
|
323
|
+
"kino_request_queue_seconds_bucket{{le=\"+Inf\"}} {total}"
|
|
324
|
+
)
|
|
325
|
+
.expect("writing to a String cannot fail");
|
|
218
326
|
writeln!(out, "kino_request_queue_seconds_sum {}", h.sum_seconds())
|
|
219
327
|
.expect("writing to a String cannot fail");
|
|
220
328
|
writeln!(out, "kino_request_queue_seconds_count {total}")
|
|
@@ -354,7 +462,11 @@ pub fn start(
|
|
|
354
462
|
};
|
|
355
463
|
control_registry().lock().insert(
|
|
356
464
|
id,
|
|
357
|
-
ControlHandle {
|
|
465
|
+
ControlHandle {
|
|
466
|
+
stop_tx,
|
|
467
|
+
join,
|
|
468
|
+
unix_path,
|
|
469
|
+
},
|
|
358
470
|
);
|
|
359
471
|
Ok(port)
|
|
360
472
|
}
|
|
@@ -380,7 +492,10 @@ fn run(
|
|
|
380
492
|
token: Option<String>,
|
|
381
493
|
stop_rx: tokio::sync::watch::Receiver<bool>,
|
|
382
494
|
) {
|
|
383
|
-
let runtime = match tokio::runtime::Builder::new_current_thread()
|
|
495
|
+
let runtime = match tokio::runtime::Builder::new_current_thread()
|
|
496
|
+
.enable_all()
|
|
497
|
+
.build()
|
|
498
|
+
{
|
|
384
499
|
Ok(runtime) => runtime,
|
|
385
500
|
Err(e) => return native_error(format!("control runtime failed: {e}")),
|
|
386
501
|
};
|
|
@@ -423,7 +538,9 @@ async fn serve(
|
|
|
423
538
|
($accepted:expr) => {
|
|
424
539
|
match $accepted {
|
|
425
540
|
Ok((stream, _)) => {
|
|
426
|
-
let Ok(permit) = permits.clone().try_acquire_owned() else {
|
|
541
|
+
let Ok(permit) = permits.clone().try_acquire_owned() else {
|
|
542
|
+
continue;
|
|
543
|
+
};
|
|
427
544
|
spawn_connection(stream, permit, server.clone(), token.clone());
|
|
428
545
|
}
|
|
429
546
|
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
|
@@ -479,8 +596,13 @@ fn handle(
|
|
|
479
596
|
.get(hyper::header::AUTHORIZATION)
|
|
480
597
|
.and_then(|v| v.to_str().ok());
|
|
481
598
|
let snapshot = StatsSnapshot::take(server);
|
|
482
|
-
let (status, content_type, body) =
|
|
483
|
-
|
|
599
|
+
let (status, content_type, body) = route(
|
|
600
|
+
req.method().as_str(),
|
|
601
|
+
req.uri().path(),
|
|
602
|
+
authorization,
|
|
603
|
+
token,
|
|
604
|
+
&snapshot,
|
|
605
|
+
);
|
|
484
606
|
let mut builder = hyper::Response::builder()
|
|
485
607
|
.status(status)
|
|
486
608
|
.header("content-type", content_type);
|
|
@@ -503,11 +625,28 @@ mod tests {
|
|
|
503
625
|
|
|
504
626
|
fn snapshot(state: u8) -> StatsSnapshot {
|
|
505
627
|
StatsSnapshot {
|
|
506
|
-
mode: "ractor".to_string(),
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
628
|
+
mode: "ractor".to_string(),
|
|
629
|
+
lanes: false,
|
|
630
|
+
workers: 8,
|
|
631
|
+
threads: 1,
|
|
632
|
+
batch: 1,
|
|
633
|
+
respawns: 2,
|
|
634
|
+
queued: 3,
|
|
635
|
+
in_flight: 4,
|
|
636
|
+
served: 100,
|
|
637
|
+
rejected: 5,
|
|
638
|
+
timeouts: 6,
|
|
639
|
+
lane_depths: None,
|
|
640
|
+
state,
|
|
641
|
+
worker_status: vec![],
|
|
642
|
+
quarantined_count: 0,
|
|
643
|
+
quarantine_replacements: 0,
|
|
644
|
+
queue_histogram: crate::registry::QueueHistogramSnapshot {
|
|
645
|
+
buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()],
|
|
646
|
+
overflow: 0,
|
|
647
|
+
sum_us: 0,
|
|
648
|
+
count: 0,
|
|
649
|
+
},
|
|
511
650
|
}
|
|
512
651
|
}
|
|
513
652
|
|
|
@@ -515,10 +654,18 @@ mod tests {
|
|
|
515
654
|
fn stats_json_reports_every_field_and_the_state_name() {
|
|
516
655
|
let json = stats_json(&snapshot(crate::registry::STATE_READY));
|
|
517
656
|
for needle in [
|
|
518
|
-
r#""mode":"ractor""#,
|
|
519
|
-
r#""
|
|
520
|
-
r#""
|
|
521
|
-
r#""
|
|
657
|
+
r#""mode":"ractor""#,
|
|
658
|
+
r#""lanes":false"#,
|
|
659
|
+
r#""workers":8"#,
|
|
660
|
+
r#""threads":1"#,
|
|
661
|
+
r#""batch":1"#,
|
|
662
|
+
r#""respawns":2"#,
|
|
663
|
+
r#""queued":3"#,
|
|
664
|
+
r#""in_flight":4"#,
|
|
665
|
+
r#""served":100"#,
|
|
666
|
+
r#""rejected":5"#,
|
|
667
|
+
r#""timeouts":6"#,
|
|
668
|
+
r#""state":"ready""#,
|
|
522
669
|
r#""version":""#,
|
|
523
670
|
] {
|
|
524
671
|
assert!(json.contains(needle), "missing {needle} in {json}");
|
|
@@ -592,7 +739,10 @@ mod tests {
|
|
|
592
739
|
|
|
593
740
|
// The token guards stats and metrics; the probes stay open.
|
|
594
741
|
assert_eq!(route("GET", "/stats", None, Some("t"), &ready).0, 401);
|
|
595
|
-
assert_eq!(
|
|
742
|
+
assert_eq!(
|
|
743
|
+
route("GET", "/metrics", Some("Bearer t"), Some("t"), &ready).0,
|
|
744
|
+
200
|
|
745
|
+
);
|
|
596
746
|
assert_eq!(route("GET", "/ready", None, Some("t"), &ready).0, 200);
|
|
597
747
|
assert_eq!(route("GET", "/live", None, Some("t"), &ready).0, 200);
|
|
598
748
|
}
|
|
@@ -601,8 +751,20 @@ mod tests {
|
|
|
601
751
|
fn stats_json_emits_worker_status_array() {
|
|
602
752
|
let mut s = snapshot(crate::registry::STATE_READY);
|
|
603
753
|
s.worker_status = vec![
|
|
604
|
-
WorkerStat {
|
|
605
|
-
|
|
754
|
+
WorkerStat {
|
|
755
|
+
index: 0,
|
|
756
|
+
served: 10,
|
|
757
|
+
in_flight: 1,
|
|
758
|
+
busy_ms: 4,
|
|
759
|
+
quarantined: false,
|
|
760
|
+
},
|
|
761
|
+
WorkerStat {
|
|
762
|
+
index: 1,
|
|
763
|
+
served: 7,
|
|
764
|
+
in_flight: 0,
|
|
765
|
+
busy_ms: 0,
|
|
766
|
+
quarantined: false,
|
|
767
|
+
},
|
|
606
768
|
];
|
|
607
769
|
let json = stats_json(&s);
|
|
608
770
|
assert!(json.contains(r#""worker_status":[{"index":0,"served":10,"in_flight":1,"busy_ms":4,"quarantined":false},{"index":1,"served":7,"in_flight":0,"busy_ms":0,"quarantined":false}]"#), "got {json}");
|
|
@@ -618,8 +780,20 @@ mod tests {
|
|
|
618
780
|
fn metrics_text_emits_per_worker_series() {
|
|
619
781
|
let mut s = snapshot(crate::registry::STATE_READY);
|
|
620
782
|
s.worker_status = vec![
|
|
621
|
-
WorkerStat {
|
|
622
|
-
|
|
783
|
+
WorkerStat {
|
|
784
|
+
index: 0,
|
|
785
|
+
served: 10,
|
|
786
|
+
in_flight: 1,
|
|
787
|
+
busy_ms: 4,
|
|
788
|
+
quarantined: false,
|
|
789
|
+
},
|
|
790
|
+
WorkerStat {
|
|
791
|
+
index: 1,
|
|
792
|
+
served: 7,
|
|
793
|
+
in_flight: 0,
|
|
794
|
+
busy_ms: 0,
|
|
795
|
+
quarantined: false,
|
|
796
|
+
},
|
|
623
797
|
];
|
|
624
798
|
let text = metrics_text(&s);
|
|
625
799
|
assert!(text.contains("# TYPE kino_worker_requests_served_total counter"));
|
|
@@ -635,9 +809,15 @@ mod tests {
|
|
|
635
809
|
server.register_worker();
|
|
636
810
|
{
|
|
637
811
|
let slots = server.slots.read();
|
|
638
|
-
slots[0]
|
|
639
|
-
|
|
640
|
-
|
|
812
|
+
slots[0]
|
|
813
|
+
.in_flight
|
|
814
|
+
.store(1, std::sync::atomic::Ordering::Relaxed);
|
|
815
|
+
slots[0]
|
|
816
|
+
.last_started_ms
|
|
817
|
+
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
818
|
+
slots[0]
|
|
819
|
+
.quarantined
|
|
820
|
+
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
641
821
|
}
|
|
642
822
|
let rows = collect_worker_status(&server);
|
|
643
823
|
assert!(rows[0].quarantined);
|
|
@@ -649,12 +829,30 @@ mod tests {
|
|
|
649
829
|
let mut s = snapshot(crate::registry::STATE_READY);
|
|
650
830
|
s.quarantined_count = 1;
|
|
651
831
|
s.worker_status = vec![
|
|
652
|
-
WorkerStat {
|
|
653
|
-
|
|
832
|
+
WorkerStat {
|
|
833
|
+
index: 0,
|
|
834
|
+
served: 3,
|
|
835
|
+
in_flight: 1,
|
|
836
|
+
busy_ms: 0,
|
|
837
|
+
quarantined: true,
|
|
838
|
+
},
|
|
839
|
+
WorkerStat {
|
|
840
|
+
index: 1,
|
|
841
|
+
served: 9,
|
|
842
|
+
in_flight: 1,
|
|
843
|
+
busy_ms: 5,
|
|
844
|
+
quarantined: false,
|
|
845
|
+
},
|
|
654
846
|
];
|
|
655
847
|
let json = stats_json(&s);
|
|
656
|
-
assert!(
|
|
657
|
-
|
|
848
|
+
assert!(
|
|
849
|
+
json.contains(r#""quarantined":1"#),
|
|
850
|
+
"top-level count: {json}"
|
|
851
|
+
);
|
|
852
|
+
assert!(
|
|
853
|
+
json.contains(r#"{"index":0,"served":3,"in_flight":1,"busy_ms":0,"quarantined":true}"#),
|
|
854
|
+
"{json}"
|
|
855
|
+
);
|
|
658
856
|
assert!(json.contains(r#""quarantined":false"#));
|
|
659
857
|
}
|
|
660
858
|
|
|
@@ -677,7 +875,10 @@ mod tests {
|
|
|
677
875
|
buckets[0] = 3; // <= 0.0005s
|
|
678
876
|
buckets[2] = 1; // <= 0.0025s
|
|
679
877
|
s.queue_histogram = crate::registry::QueueHistogramSnapshot {
|
|
680
|
-
buckets,
|
|
878
|
+
buckets,
|
|
879
|
+
overflow: 1,
|
|
880
|
+
sum_us: 3 * 100 + 2_000 + 20_000_000,
|
|
881
|
+
count: 5,
|
|
681
882
|
};
|
|
682
883
|
let text = metrics_text(&s);
|
|
683
884
|
assert!(text.contains("# TYPE kino_request_queue_seconds histogram"));
|
|
@@ -693,9 +894,15 @@ mod tests {
|
|
|
693
894
|
fn stats_json_reports_queue_time() {
|
|
694
895
|
let mut s = snapshot(crate::registry::STATE_READY);
|
|
695
896
|
s.queue_histogram = crate::registry::QueueHistogramSnapshot {
|
|
696
|
-
buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()],
|
|
897
|
+
buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()],
|
|
898
|
+
overflow: 0,
|
|
899
|
+
sum_us: 1_500_000,
|
|
900
|
+
count: 2,
|
|
697
901
|
};
|
|
698
902
|
let json = stats_json(&s);
|
|
699
|
-
assert!(
|
|
903
|
+
assert!(
|
|
904
|
+
json.contains(r#""queue_time":{"count":2,"sum_seconds":1.5}"#),
|
|
905
|
+
"{json}"
|
|
906
|
+
);
|
|
700
907
|
}
|
|
701
908
|
}
|