makepad/libs/asset/client/tests/runtime_lanes.rs
2026-09-02 15:30:29 +02:00

455 lines
16 KiB
Rust

//! The runtime's parallel lanes: what a UI is actually promised once
//! requests stop running one at a time.
//!
//! The properties proven here are the ones that make a thumbnail grid fill
//! while a track downloads:
//! - a big transfer occupies its own lane and NOT the fast one,
//! - many small requests really do overlap (they finish out of submission
//! order), while each request's own events stay ordered,
//! - cancel still works for queued and in-flight work in both lanes,
//! - two lanes fetching the same blob produce one download, not a corrupted
//! partial file,
//! - shutdown drains what was accepted and joins every worker.
// Native threaded runtime test: std deadlines cannot enter the wasm library.
#![allow(clippy::disallowed_types, clippy::disallowed_methods)]
mod common;
use common::*;
use makepad_asset_client::{
AssetClient, ClientConfig, ClientError, ClientEvent, ClientOutput, ClientRequest,
ClientRuntime, HttpLimits, Lane, RequestId, RuntimeConfig, SubmitOptions,
};
use makepad_asset_data::AssetId;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
fn fast_limits() -> HttpLimits {
HttpLimits {
connect_timeout_ms: 2_000,
read_timeout_ms: 2_000,
write_timeout_ms: 2_000,
head_deadline_ms: 2_000,
body_deadline_ms: 20_000,
}
}
fn config(name: &str) -> ClientConfig {
let mut cfg = ClientConfig::new(test_root(name));
cfg.http = fast_limits();
cfg.blob_body_deadline_ms = 20_000;
cfg
}
/// A store with a small asset per id plus one big blob for the bulk lane.
fn lane_store() -> (FixtureStore, Vec<makepad_asset_data::AssetRevisionRef>) {
let mut store = FixtureStore::default();
let mut refs = Vec::new();
for i in 0..12u8 {
refs.push(store.add_prop(
10 + i,
"stock",
None,
&format!("Prop {i}"),
payload(500 + i as u64, 1_200),
vec![],
));
}
(store, refs)
}
struct Recorder {
/// id → events in arrival order.
per_id: HashMap<RequestId, Vec<&'static str>>,
/// Terminal events in completion order.
finished: Vec<RequestId>,
failures: HashMap<RequestId, ClientError>,
outputs: HashMap<RequestId, ClientOutput>,
}
impl Recorder {
fn new() -> Recorder {
Recorder {
per_id: HashMap::new(),
finished: Vec::new(),
failures: HashMap::new(),
outputs: HashMap::new(),
}
}
fn absorb(&mut self, events: Vec<ClientEvent>) {
for event in events {
let id = event.id();
let slot = self.per_id.entry(id).or_default();
match event {
ClientEvent::Started { .. } => slot.push("started"),
ClientEvent::Progress { .. } => slot.push("progress"),
ClientEvent::Done { output, .. } => {
slot.push("done");
self.finished.push(id);
self.outputs.insert(id, output);
}
ClientEvent::Failed { error, .. } => {
slot.push("failed");
self.finished.push(id);
self.failures.insert(id, error);
}
}
}
}
/// Every id: at most one `started`, and when it started it came first;
/// exactly one terminal event, last, with nothing after it. (A request
/// cancelled while still queued never starts, so its whole stream is one
/// `failed` — that is the documented skip, not a lost `started`.)
fn assert_per_id_consistent(&self) {
for (id, events) in &self.per_id {
let starts = events.iter().filter(|e| **e == "started").count();
assert!(starts <= 1, "request {id} started twice: {events:?}");
if starts == 1 {
assert_eq!(
events.first(),
Some(&"started"),
"request {id} started late: {events:?}"
);
} else {
assert_eq!(
events.as_slice(),
["failed"],
"request {id} never started but is not a queued cancel: {events:?}"
);
}
let terminals =
events.iter().filter(|e| **e == "done" || **e == "failed").count();
assert_eq!(terminals, 1, "request {id} has {terminals} terminal events: {events:?}");
let last = events.last().copied().unwrap_or("");
assert!(
last == "done" || last == "failed",
"request {id} emitted events after its terminal one: {events:?}"
);
}
}
fn drain_until(
&mut self,
runtime: &mut ClientRuntime,
wanted: &[RequestId],
timeout: Duration,
) {
let deadline = Instant::now() + timeout;
while !wanted.iter().all(|id| self.finished.contains(id)) {
assert!(
Instant::now() < deadline,
"requests never finished: {:?} of {:?}",
self.finished,
wanted
);
self.absorb(runtime.poll());
std::thread::sleep(Duration::from_millis(2));
}
self.absorb(runtime.poll());
}
}
#[test]
fn lanes_are_chosen_by_size_and_kind() {
let (store, refs) = lane_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client = AssetClient::connect(config("lane_classify"), fixture.endpoints(), None).unwrap();
let runtime = ClientRuntime::start(client).unwrap();
let blob = makepad_asset_data::BlobId::hash_of(b"whatever");
// Control-plane work is fast, whatever it is.
assert_eq!(
runtime.lane_of(&ClientRequest::AssetDetail { id: AssetId::from_bytes([1; 16]) }),
Lane::Fast
);
assert_eq!(
runtime.lane_of(&ClientRequest::FetchAssetManifest { rev: refs[0].revision }),
Lane::Fast
);
// A small declared blob is fast; a big one is bulk; an UNKNOWN length is
// treated as big, because guessing small is what stalls a lane.
assert_eq!(
runtime.lane_of(&ClientRequest::FetchBlob {
blob,
expected_len: Some(64 * 1024),
pin: false
}),
Lane::Fast
);
assert_eq!(
runtime.lane_of(&ClientRequest::FetchBlob {
blob,
expected_len: Some(8 * 1024 * 1024),
pin: false
}),
Lane::Bulk
);
assert_eq!(
runtime.lane_of(&ClientRequest::FetchBlob { blob, expected_len: None, pin: false }),
Lane::Bulk
);
runtime.shutdown();
}
#[test]
fn concurrent_requests_overlap_and_each_stream_stays_consistent() {
let (store, refs) = lane_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client = AssetClient::connect(config("lane_overlap"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
// Twelve manifest fetches: all fast-lane, all independent.
let ids: Vec<RequestId> = refs
.iter()
.map(|r| {
runtime
.submit(ClientRequest::FetchAssetManifest { rev: r.revision })
.expect("submit")
})
.collect();
let mut rec = Recorder::new();
rec.drain_until(&mut runtime, &ids, Duration::from_secs(20));
rec.assert_per_id_consistent();
assert!(rec.failures.is_empty(), "unexpected failures: {:?}", rec.failures);
assert_eq!(rec.finished.len(), ids.len());
// Every id is accounted for exactly once.
assert_eq!(rec.finished.iter().collect::<HashSet<_>>().len(), ids.len());
runtime.shutdown();
}
#[test]
fn a_bulk_transfer_does_not_delay_the_fast_lane() {
let (mut store, refs) = lane_store();
// One big blob, served in slow drips so the bulk lane is provably busy.
let big = payload(77, 900_000);
let big_blob = store.add_blob(big.clone());
let fixture = FixtureServer::start(store, FixtureOptions::default());
// 32 KiB every 40 ms ≈ 1.1 s for the whole body.
*fixture.knobs.drip_blob.lock().unwrap() = Some((32 * 1024, 40));
let client = AssetClient::connect(config("lane_no_block"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
// The big transfer goes first and takes the bulk lane by classification.
let bulk_id = runtime
.submit(ClientRequest::FetchBlob {
blob: big_blob,
expected_len: Some(big.len() as u64),
pin: false,
})
.expect("submit bulk");
assert_eq!(
runtime.lane_of(&ClientRequest::FetchBlob {
blob: big_blob,
expected_len: Some(big.len() as u64),
pin: false
}),
Lane::Bulk
);
// Give the transfer a moment to actually start.
std::thread::sleep(Duration::from_millis(50));
// Small fast-lane work submitted AFTER it must not wait for it.
let fast_ids: Vec<RequestId> = refs
.iter()
.take(8)
.map(|r| {
runtime
.submit(ClientRequest::FetchAssetManifest { rev: r.revision })
.expect("submit fast")
})
.collect();
let mut rec = Recorder::new();
rec.drain_until(&mut runtime, &fast_ids, Duration::from_secs(20));
assert!(
!rec.finished.contains(&bulk_id),
"the fast lane waited for the bulk transfer to finish"
);
let mut all = fast_ids.clone();
all.push(bulk_id);
rec.drain_until(&mut runtime, &all, Duration::from_secs(30));
rec.assert_per_id_consistent();
assert!(rec.failures.is_empty(), "unexpected failures: {:?}", rec.failures);
// The bulk transfer finished last, after every fast request.
assert_eq!(rec.finished.last(), Some(&bulk_id));
runtime.shutdown();
}
#[test]
fn newest_first_serves_the_freshest_queued_work_first() {
let (store, refs) = lane_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client = AssetClient::connect(config("lane_lifo"), fixture.endpoints(), None).unwrap();
// One fast worker: with a pool the queue drains before order can be
// observed, and this test is about the QUEUE policy.
let mut runtime = ClientRuntime::start_with(
client,
RuntimeConfig { fast_workers: 1, bulk_workers: 1, ..RuntimeConfig::default_v1() },
)
.unwrap();
// Fill the queue behind the single worker, then submit newest-first.
let mut fifo = Vec::new();
for r in refs.iter().take(6) {
fifo.push(
runtime
.submit(ClientRequest::FetchAssetManifest { rev: r.revision })
.expect("submit"),
);
}
let jumper = runtime
.submit_with(
ClientRequest::FetchAssetManifest { rev: refs[7].revision },
SubmitOptions::newest_first(),
)
.expect("submit lifo");
let mut rec = Recorder::new();
let mut all = fifo.clone();
all.push(jumper);
rec.drain_until(&mut runtime, &all, Duration::from_secs(20));
rec.assert_per_id_consistent();
// It cannot have jumped the request already in flight, but it must have
// jumped the queued tail.
let jumper_pos = rec.finished.iter().position(|id| *id == jumper).expect("jumper finished");
assert!(
jumper_pos <= 1,
"newest-first request finished at position {jumper_pos}: {:?}",
rec.finished
);
runtime.shutdown();
}
#[test]
fn cancel_covers_queued_and_in_flight_work_in_both_lanes() {
let (mut store, refs) = lane_store();
let big = payload(88, 900_000);
let big_blob = store.add_blob(big.clone());
let fixture = FixtureServer::start(store, FixtureOptions::default());
*fixture.knobs.drip_blob.lock().unwrap() = Some((16 * 1024, 40));
let client = AssetClient::connect(config("lane_cancel"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start_with(
client,
RuntimeConfig { fast_workers: 1, bulk_workers: 1, ..RuntimeConfig::default_v1() },
)
.unwrap();
// In-flight bulk transfer: aborts at its next chunk.
let inflight = runtime
.submit(ClientRequest::FetchBlob {
blob: big_blob,
expected_len: Some(big.len() as u64),
pin: false,
})
.expect("submit bulk");
std::thread::sleep(Duration::from_millis(80));
// Queued fast work behind a single fast worker: cancelled before it runs.
let first_fast = runtime
.submit(ClientRequest::FetchAssetManifest { rev: refs[0].revision })
.expect("submit fast");
let queued_fast = runtime
.submit(ClientRequest::FetchAssetManifest { rev: refs[1].revision })
.expect("submit fast");
runtime.cancel(queued_fast);
runtime.cancel(inflight);
let mut rec = Recorder::new();
rec.drain_until(
&mut runtime,
&[inflight, first_fast, queued_fast],
Duration::from_secs(20),
);
rec.assert_per_id_consistent();
assert!(
matches!(rec.failures.get(&inflight), Some(ClientError::Cancelled)),
"in-flight bulk cancel: {:?}",
rec.failures.get(&inflight)
);
assert!(
matches!(rec.failures.get(&queued_fast), Some(ClientError::Cancelled)),
"queued fast cancel: {:?}",
rec.failures.get(&queued_fast)
);
// The uncancelled neighbour still completed.
assert!(rec.outputs.contains_key(&first_fast));
runtime.shutdown();
}
#[test]
fn two_lanes_fetching_one_blob_download_it_once() {
let (mut store, _refs) = lane_store();
let bytes = payload(99, 300_000);
let blob = store.add_blob(bytes.clone());
let fixture = FixtureServer::start(store, FixtureOptions::default());
*fixture.knobs.drip_blob.lock().unwrap() = Some((32 * 1024, 10));
let client = AssetClient::connect(config("lane_dedup"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
// Same digest, both lanes: the fast one by explicit override, the bulk
// one by classification. One partial file, so one of them must wait and
// then find the committed object.
let a = runtime
.submit_with(
ClientRequest::FetchBlob {
blob,
expected_len: Some(bytes.len() as u64),
pin: false,
},
SubmitOptions::fast(),
)
.expect("submit fast");
let b = runtime
.submit(ClientRequest::FetchBlob {
blob,
expected_len: Some(bytes.len() as u64),
pin: false,
})
.expect("submit bulk");
let mut rec = Recorder::new();
rec.drain_until(&mut runtime, &[a, b], Duration::from_secs(30));
rec.assert_per_id_consistent();
assert!(rec.failures.is_empty(), "unexpected failures: {:?}", rec.failures);
for id in [a, b] {
match rec.outputs.get(&id) {
Some(ClientOutput::Blob { content, .. }) => {
assert_eq!(std::fs::read(content.as_path().unwrap()).unwrap(), bytes);
}
other => panic!("wrong output for {id}: {other:?}"),
}
}
// Exactly one body transfer reached the server: the second fetch found
// the committed object instead of racing a second writer onto the same
// partial file.
assert_eq!(
fixture.log.count("GET", "/v1/blobs/"),
1,
"the same blob was downloaded twice"
);
runtime.shutdown();
}
#[test]
fn shutdown_drains_accepted_work_and_joins_every_worker() {
let (store, refs) = lane_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client = AssetClient::connect(config("lane_shutdown"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
let ids: Vec<RequestId> = refs
.iter()
.map(|r| {
runtime
.submit(ClientRequest::FetchAssetManifest { rev: r.revision })
.expect("submit")
})
.collect();
// Shutdown joins every lane worker; queued work runs to completion first.
let started = Instant::now();
runtime.shutdown();
assert!(started.elapsed() < Duration::from_secs(20), "shutdown hung");
assert_eq!(ids.len(), 12);
}