Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,90 @@
|
||||
use dever_runtime::{
|
||||
adapter::{Document, Schema},
|
||||
value::secret::Secret,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const SCHEMAS: &[Schema] = &[Schema {
|
||||
name: "demo.value",
|
||||
implementations: &["local", "remote"],
|
||||
}];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Input {
|
||||
offset: i64,
|
||||
token: Secret,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_explicit_and_only_the_selected_setting_is_decoded() {
|
||||
let text = r#"{"adapter":{"demo.value":{"use":"remote","setting":{"offset":41,"token":"fixture-only-token"}}}}"#;
|
||||
for _ in 0..100 {
|
||||
let document = Document::parse(text.into()).unwrap();
|
||||
let selections = document.select(SCHEMAS).unwrap();
|
||||
assert_eq!(selections[0].index, 1);
|
||||
let input: Input = selections[0].decode().unwrap();
|
||||
assert_eq!(input.offset, 41);
|
||||
assert_eq!(format!("{:?}", input.token), "Secret([REDACTED])");
|
||||
drop(selections);
|
||||
drop(document);
|
||||
assert_eq!(format!("{:?}", input.token), "Secret([REDACTED])");
|
||||
}
|
||||
let document =
|
||||
Document::parse(r#"{"adapter":{"demo.\u0076alue":{"use":"\u006cocal"}}}"#.into()).unwrap();
|
||||
assert_eq!(document.select(SCHEMAS).unwrap()[0].index, 0);
|
||||
let document = Document::parse("{}".into()).unwrap();
|
||||
assert!(document.select(SCHEMAS).is_err());
|
||||
let single = [Schema {
|
||||
name: "demo.value",
|
||||
implementations: &["default"],
|
||||
}];
|
||||
let selections = document.select(&single).unwrap();
|
||||
assert_eq!(selections[0].index, 0);
|
||||
assert!(selections[0].without_setting().is_ok());
|
||||
assert!(selections[0].decode::<Input>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_configuration_is_bounded_strict_and_redacted() {
|
||||
for text in [
|
||||
"[]",
|
||||
"[{}]",
|
||||
"null",
|
||||
"{} {}",
|
||||
"{",
|
||||
r#"{"adapter":null}"#,
|
||||
r#"{"adapter":{},"adapter":{}}"#,
|
||||
r#"{"adapter":{"demo.value":["local",{}]}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":null}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"fixture-only-token"}}}"#,
|
||||
r#"{"adapter":{"fixture-only-token":{"use":"local"}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","fixture-only-token":1}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","use":"remote"}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","setting":{"token":"a","token":"b"}}}}"#,
|
||||
] {
|
||||
let error = match Document::parse(text.into()) {
|
||||
Err(error) => error,
|
||||
Ok(document) => document.select(SCHEMAS).err().expect(text),
|
||||
};
|
||||
assert!(!error.contains("fixture-only-token"), "{error}");
|
||||
}
|
||||
let too_large = format!("{{\"text\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
|
||||
assert!(Document::parse(too_large).is_err());
|
||||
for setting in [
|
||||
"null",
|
||||
"{}",
|
||||
r#"{"offset":"fixture-only-token","token":"x"}"#,
|
||||
r#"{"offset":1,"token":5}"#,
|
||||
r#"{"offset":1,"token":"x","extra":"fixture-only-token"}"#,
|
||||
] {
|
||||
let document = Document::parse(format!(
|
||||
r#"{{"adapter":{{"demo.value":{{"use":"local","setting":{setting}}}}}}}"#
|
||||
))
|
||||
.unwrap();
|
||||
let selections = document.select(SCHEMAS).unwrap();
|
||||
let error = selections[0].decode::<Input>().err().expect(setting);
|
||||
assert!(!error.contains("fixture-only-token"));
|
||||
assert!(selections[0].without_setting().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use dever_runtime::{
|
||||
async_stream::{AsyncStream, Error},
|
||||
channel::Channel,
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use futures_util::stream;
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
};
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn pending(future: std::pin::Pin<&mut impl Future>) {
|
||||
let mut future = future;
|
||||
poll_fn(|context| {
|
||||
assert!(future.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
struct Notice(Arc<AtomicUsize>);
|
||||
impl Drop for Notice {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_lazy_and_aliases_share_cursor_and_close() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = calls.clone();
|
||||
let values = AsyncStream::values((1..=3).inspect(move |_| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
assert_eq!(observed.load(Ordering::SeqCst), 0);
|
||||
let alias = values.clone();
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
assert_eq!(values.next().await, Ok(Some(1)));
|
||||
assert_eq!(alias.next().await, Ok(Some(2)));
|
||||
assert_eq!(observed.load(Ordering::SeqCst), 2);
|
||||
alias.close();
|
||||
assert_eq!(values.next().await, Ok(None));
|
||||
assert_eq!(observed.load(Ordering::SeqCst), 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_stream_preserves_null_items_and_external_channel_alias() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let channel = Channel::new(2).unwrap();
|
||||
channel.send(None).await.unwrap();
|
||||
channel.send(Some(2)).await.unwrap();
|
||||
let values = AsyncStream::channel(channel.clone());
|
||||
assert_eq!(values.next().await, Ok(Some(None)));
|
||||
values.close();
|
||||
assert_eq!(values.next().await, Ok(None));
|
||||
assert_eq!(channel.receive().await, Ok(Some(Some(2))));
|
||||
channel.send(Some(3)).await.unwrap();
|
||||
channel.close().await.unwrap();
|
||||
let remaining = AsyncStream::channel(channel);
|
||||
assert_eq!(remaining.next().await, Ok(Some(Some(3))));
|
||||
assert_eq!(remaining.next().await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_wakes_current_and_queued_pulls() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let values = AsyncStream::channel(Channel::<i64>::new(1).unwrap());
|
||||
let mut first = std::pin::pin!(values.next());
|
||||
let mut second = std::pin::pin!(values.next());
|
||||
pending(first.as_mut()).await;
|
||||
pending(second.as_mut()).await;
|
||||
values.close();
|
||||
assert_eq!(first.await, Ok(None));
|
||||
assert_eq!(second.await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_closes_aliases_and_releases_the_producer() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let observed = drops.clone();
|
||||
let values = AsyncStream::<i64>::new(stream::unfold(Notice(drops), |owner| async move {
|
||||
std::future::pending::<()>().await;
|
||||
Some((Ok(1), owner))
|
||||
}));
|
||||
let alias = values.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let pending = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _owner = values.close_on_drop();
|
||||
let mut next = std::pin::pin!(values.next());
|
||||
pending(next.as_mut()).await;
|
||||
started.send(()).unwrap();
|
||||
next.await
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
pending.stop().await.unwrap();
|
||||
assert_eq!(observed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(alias.next().await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_fault_releases_source_before_delivery_and_is_not_repeated() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let observed = drops.clone();
|
||||
let values = AsyncStream::<i64>::new(stream::unfold(Notice(drops), |owner| async move {
|
||||
Some((Err(Error::Overflow), owner))
|
||||
}));
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
assert_eq!(values.next().await, Err(Error::Overflow));
|
||||
assert_eq!(observed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(values.next().await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_are_created_without_runtime_and_closed_without_waiting_for_deadline() {
|
||||
assert!(matches!(AsyncStream::ticks(0), Err(Error::Period)));
|
||||
assert!(matches!(AsyncStream::ticks(-1), Err(Error::Period)));
|
||||
let values = AsyncStream::ticks(60000).unwrap();
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let mut first = std::pin::pin!(values.next());
|
||||
pending(first.as_mut()).await;
|
||||
values.close();
|
||||
assert_eq!(first.await, Ok(None));
|
||||
let values = AsyncStream::ticks(1).unwrap();
|
||||
assert_eq!(values.next().await, Ok(Some(1)));
|
||||
assert_eq!(values.next().await, Ok(Some(2)));
|
||||
values.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::request::{configured, request};
|
||||
use dever_runtime::{
|
||||
auth::{Error, Identity},
|
||||
authorization::{self, Failure},
|
||||
database::{Context as DatabaseContext, Registry, Sql},
|
||||
task::Runtime,
|
||||
};
|
||||
|
||||
struct Directory(std::path::PathBuf);
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_namespace_requires_role_authorization_and_ignores_request_coordinates() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let cookie = super::request::cookie(&service);
|
||||
let request = request(
|
||||
"POST",
|
||||
&[
|
||||
("Host", "unit.test"),
|
||||
("Origin", "https://unit.test"),
|
||||
("Cookie", &cookie),
|
||||
("X-Component", "forged"),
|
||||
("X-Domain", "forged"),
|
||||
],
|
||||
);
|
||||
let (mut context, _) = service.prepare("admin", &request, false, 100).unwrap();
|
||||
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
|
||||
context
|
||||
.establish(Identity {
|
||||
id: "user".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
|
||||
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let name = format!("dever-entry-{}-{unique}", std::process::id());
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let directory = Directory(executable.parent().unwrap().join(&name));
|
||||
std::fs::create_dir(&directory.0).unwrap();
|
||||
let settings = directory.0.join("setting.json");
|
||||
std::fs::write(&settings, serde_json::json!({"database":{"default":{"type":"sqlite","path":format!("{name}/entry.db"),"max_connections":1}}}).to_string()).unwrap();
|
||||
Runtime::new(Default::default()).unwrap().run(async |scope| {
|
||||
let registry = Registry::load(&settings, &authorization::schema::BINDINGS, scope).await.unwrap();
|
||||
let mut database = DatabaseContext::new(registry);
|
||||
let executor = database.on(1, scope).await.unwrap();
|
||||
executor.execute(scope, Sql::same("INSERT INTO _dever_permission(key,component,domain,site,action,method,kind,active) VALUES ('news.article.admin.create','news','article','admin','create','POST','http',1)"), vec![]).await.unwrap();
|
||||
assert!(matches!(authorization::authorize_entry(&mut database, 1, scope, &mut context, "news", "article", "create").await, Err(Failure::Access(403))));
|
||||
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
|
||||
authorization::owner(database.on(1, scope).await.unwrap(), scope, "admin", 7).await.unwrap();
|
||||
let transaction = database.enter();
|
||||
authorization::authorize_entry(&mut database, 1, scope, &mut context, "news", "article", "create").await.unwrap();
|
||||
assert_eq!(authorization::permissions(&mut database, 1, scope, &context, true).await.unwrap().len(), 1);
|
||||
database.finish(scope, transaction, true).await.unwrap();
|
||||
assert_eq!(context.entry_namespace().unwrap(), ("news", "article"));
|
||||
assert_eq!(context.site(), "admin");
|
||||
database.on(1, scope).await.unwrap().execute(scope, "UPDATE _dever_permission SET active=0", vec![]).await.unwrap();
|
||||
assert!(authorization::permissions(&mut database, 1, scope, &context, true).await.unwrap().is_empty());
|
||||
assert!(matches!(authorization::authorize_key(&mut database, 1, scope, &context, "news.article.admin.create").await, Err(Failure::Access(403))));
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::request::{configured, request};
|
||||
use dever_runtime::{
|
||||
auth::{CookieOptions, Error, SameSite},
|
||||
value::secret::Secret,
|
||||
};
|
||||
|
||||
fn options() -> CookieOptions {
|
||||
CookieOptions {
|
||||
path: "/".into(),
|
||||
domain: None,
|
||||
max_age: None,
|
||||
same_site: SameSite::Lax,
|
||||
secure: true,
|
||||
http_only: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_guards_secret_values_cookie_origin_and_response_fields() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let request = request(
|
||||
"POST",
|
||||
&[
|
||||
("Host", "unit.test"),
|
||||
("Origin", "https://unit.test"),
|
||||
("Cookie", "theme=light; private=v1.c2VjcmV0"),
|
||||
("X-Test", "value"),
|
||||
("X-Forwarded-For", "203.0.113.9"),
|
||||
],
|
||||
);
|
||||
let (mut context, _) = service
|
||||
.prepare_at(
|
||||
"admin",
|
||||
&request,
|
||||
true,
|
||||
100,
|
||||
"127.0.0.1:1234".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(context.client_address().unwrap(), "127.0.0.1");
|
||||
assert_eq!(context.request_id().unwrap(), context.request_id().unwrap());
|
||||
assert_eq!(context.header("X-Test").unwrap().as_deref(), Some("value"));
|
||||
assert_eq!(context.cookie("theme").unwrap().as_deref(), Some("light"));
|
||||
assert!(matches!(context.cookie("private"), Err(Error::Forbidden)));
|
||||
assert!(context.secret_cookie("private").unwrap().is_some());
|
||||
assert!(context.secret_cookie("theme").unwrap().is_none());
|
||||
for name in [
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
"Proxy-Authorization",
|
||||
"X-Api-Key",
|
||||
] {
|
||||
assert!(context.header(name).is_err());
|
||||
}
|
||||
for name in [
|
||||
"Set-Cookie",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
"Connection",
|
||||
"Upgrade",
|
||||
] {
|
||||
assert!(context.set_header(name, "value").is_err());
|
||||
}
|
||||
assert!(
|
||||
context
|
||||
.set_header("X-Test", "value\r\nInjected: yes")
|
||||
.is_err()
|
||||
);
|
||||
assert!(context.set_header("X-Test", &"x".repeat(9000)).is_err());
|
||||
assert!(
|
||||
context
|
||||
.set_cookie("session", "cannot overwrite provider", options())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
context
|
||||
.set_cookie("theme", "bad; value", options())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
context
|
||||
.set_secret_cookie(
|
||||
"private",
|
||||
&Secret::from_input(b"bytes".to_vec()),
|
||||
CookieOptions {
|
||||
secure: false,
|
||||
..options()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
context
|
||||
.set_secret_cookie("private", &Secret::from_input(b"bytes".to_vec()), options())
|
||||
.unwrap();
|
||||
assert!(
|
||||
context
|
||||
.set_cookie(
|
||||
"cross",
|
||||
"value",
|
||||
CookieOptions {
|
||||
same_site: SameSite::None,
|
||||
secure: false,
|
||||
..options()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
context.set_header("X-Test", "response").unwrap();
|
||||
let mut response = dever_runtime::api::response("{}");
|
||||
context.publish(&mut response);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.any(|header| header.as_bytes().starts_with(b"private=v1.Ynl0ZXM;"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use dever_runtime::{
|
||||
adapter::Document,
|
||||
auth::{Claims, Settings},
|
||||
value::secret::Secret,
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
#[cfg(feature = "authorization")]
|
||||
mod entry;
|
||||
mod metadata;
|
||||
mod request;
|
||||
mod tenant;
|
||||
|
||||
const KEY: &str = "unit-test-only-key-not-for-deployment";
|
||||
|
||||
fn settings() -> Settings {
|
||||
let document = Document::parse(serde_json::json!({"auth":{"providers":{
|
||||
"session":{"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"},
|
||||
"other":{"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60}
|
||||
}}}).to_string()).unwrap();
|
||||
Settings::load(&document).unwrap()
|
||||
}
|
||||
|
||||
fn signed(header: &str, payload: &str, key: &str) -> Secret {
|
||||
let signed = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(header),
|
||||
URL_SAFE_NO_PAD.encode(payload)
|
||||
);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes()).unwrap();
|
||||
mac.update(signed.as_bytes());
|
||||
Secret::from_input(
|
||||
format!(
|
||||
"{signed}.{}",
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_claims_remain_bound_to_provider_site_session_and_time() {
|
||||
let settings = settings();
|
||||
let provider = &settings.providers["session"];
|
||||
let claims = Claims {
|
||||
subject: "账号".into(),
|
||||
session: "opaque-session".into(),
|
||||
tenant: None,
|
||||
site: "admin".into(),
|
||||
};
|
||||
let token = provider.issue(&claims, 100).unwrap();
|
||||
assert_eq!(provider.verify_token(&token, "admin", 100).unwrap(), claims);
|
||||
assert_eq!(provider.verify_token(&token, "admin", 159).unwrap(), claims);
|
||||
assert!(provider.verify_token(&token, "admin", 160).is_err());
|
||||
assert!(provider.verify_token(&token, "admin", 99).is_err());
|
||||
assert!(provider.verify_token(&token, "front", 100).is_err());
|
||||
assert!(
|
||||
settings.providers["other"]
|
||||
.verify_token(&token, "admin", 100)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!format!("{token:?}").contains("opaque-session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_validation_rejects_algorithm_confusion_tampering_and_ambiguous_claims() {
|
||||
let settings = settings();
|
||||
let provider = &settings.providers["session"];
|
||||
let payload =
|
||||
r#"{"iss":"session","aud":"admin","sub":"u","sid":"s","tenant":null,"iat":100,"exp":160}"#;
|
||||
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
|
||||
assert!(
|
||||
provider
|
||||
.verify_token(&signed(header, payload, KEY), "admin", 100)
|
||||
.is_ok()
|
||||
);
|
||||
for token in [
|
||||
signed(r#"{"alg":"none","typ":"JWT"}"#, payload, KEY),
|
||||
signed(r#"{"alg":"RS256","typ":"JWT"}"#, payload, KEY),
|
||||
signed(header, payload, "different-unit-test-signing-key"),
|
||||
signed(
|
||||
header,
|
||||
&payload.replace("\"sub\":\"u\"", "\"sub\":\"u\",\"sub\":\"v\""),
|
||||
KEY,
|
||||
),
|
||||
signed(
|
||||
header,
|
||||
&payload.replace("\"sub\":\"u\"", "\"sub\":\"\""),
|
||||
KEY,
|
||||
),
|
||||
signed(header, &payload.replace("\"exp\":160", "\"exp\":161"), KEY),
|
||||
signed(header, &payload.replace("\"iat\":100", "\"iat\":101"), KEY),
|
||||
signed(header, &payload.replace("\"iat\":100", "\"iat\":-1"), KEY),
|
||||
signed(header, &payload.replace("\"exp\":160", "\"exp\":1e9"), KEY),
|
||||
signed(
|
||||
header,
|
||||
&payload.replace("\"tenant\":null,", "\"permissions\":[\"*\"],"),
|
||||
KEY,
|
||||
),
|
||||
Secret::from_input(vec![b'a'; 8193]),
|
||||
] {
|
||||
assert_eq!(
|
||||
provider.verify_token(&token, "admin", 100).unwrap_err(),
|
||||
"invalid authentication"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_settings_require_deployment_key_and_unambiguous_fields() {
|
||||
for value in [
|
||||
serde_json::json!({"verify":"user.account.verify","jwtSecret":"short","ttlSeconds":60}),
|
||||
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":0}),
|
||||
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session; bad"}),
|
||||
serde_json::json!({"verify":"user.account","jwtSecret":KEY,"ttlSeconds":60}),
|
||||
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"algorithm":"none"}),
|
||||
] {
|
||||
let document = Document::parse(
|
||||
serde_json::json!({"auth":{"providers":{"session":value}}}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Settings::load(&document).is_err());
|
||||
}
|
||||
assert!(
|
||||
Settings::load(&Document::parse("{}".into()).unwrap())
|
||||
.unwrap()
|
||||
.providers
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use super::*;
|
||||
use dever_runtime::{
|
||||
api,
|
||||
api::Request,
|
||||
auth::{Error, Identity, Route, Service, Verification},
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub(super) fn configured(origin: Option<&str>, verify: &str) -> Result<Service, String> {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-auth-config-{}-{}.json",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let settings = serde_json::json!({
|
||||
"http":{"host":"127.0.0.1","port":8181},
|
||||
"sites":{"admin":{"path":"admin","auth":"session","hosts":["unit.test"],"origin":origin}},
|
||||
"auth":{"providers":{"session":{"verify":verify,"jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"}}}
|
||||
});
|
||||
std::fs::write(&path, settings.to_string()).unwrap();
|
||||
let config = api::Config::load(&path, &["admin"]);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
Service::new(
|
||||
Arc::new(config?),
|
||||
&[Verification {
|
||||
name: "user.account.verify",
|
||||
user_model: 13,
|
||||
tenant_model: 17,
|
||||
}],
|
||||
&[Route {
|
||||
topic: "admin",
|
||||
required: true,
|
||||
user_models: &[],
|
||||
tenant_models: &[],
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn request(method: &str, fields: &[(&str, &str)]) -> Request {
|
||||
let mut request = hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri("/user/account/admin/me");
|
||||
for (name, value) in fields {
|
||||
request = request.header(*name, *value);
|
||||
}
|
||||
request.body(Default::default()).unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn cookie(service: &Service) -> String {
|
||||
let request = request(
|
||||
"POST",
|
||||
&[("Host", "unit.test"), ("Origin", "https://unit.test")],
|
||||
);
|
||||
let (mut context, claims) = service.prepare("admin", &request, true, 100).unwrap();
|
||||
assert!(claims.is_none());
|
||||
context
|
||||
.issue_cookie("user", "session-id", None, 100)
|
||||
.unwrap();
|
||||
let mut response = api::response("{}");
|
||||
assert!(!response.headers().contains_key("set-cookie"));
|
||||
context.publish(&mut response);
|
||||
let cookie = response.headers().get("set-cookie").unwrap();
|
||||
let value = std::str::from_utf8(cookie.as_bytes()).unwrap();
|
||||
assert!(value.contains("Secure; HttpOnly; SameSite=Lax; Max-Age=60"));
|
||||
value.split(';').next().unwrap().into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_needs_compiled_verifier_before_it_establishes_identity() {
|
||||
assert!(configured(None, "user.account.verify").is_err());
|
||||
assert!(configured(Some("https://unit.test"), "user.account.arbitrary").is_err());
|
||||
for origin in [
|
||||
"https://unit.test/path",
|
||||
"https://unit.test?query",
|
||||
"https://user@unit.test",
|
||||
"https://unit.test:bad",
|
||||
"https://unit.test:99999",
|
||||
"https://unit.test:0",
|
||||
] {
|
||||
assert!(
|
||||
configured(Some(origin), "user.account.verify").is_err(),
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let cookie = cookie(&service);
|
||||
let request = request("GET", &[("Host", "unit.test"), ("Cookie", &cookie)]);
|
||||
let (mut context, claims) = service.prepare("admin", &request, false, 100).unwrap();
|
||||
assert_eq!(context.id().unwrap_err(), Error::Unauthorized);
|
||||
assert_eq!(claims.unwrap().subject, "user");
|
||||
assert_eq!(context.verifier(), Some("user.account.verify"));
|
||||
context
|
||||
.establish(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(context.id().unwrap(), "verified-user");
|
||||
assert_eq!(context.session().unwrap(), "session-id");
|
||||
assert_eq!(context.user_id(Some(13)).unwrap(), Some(7));
|
||||
assert_eq!(context.user_id(Some(17)).unwrap_err(), Error::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_writes_require_exact_external_origin_and_host() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let cookie = cookie(&service);
|
||||
for fields in [
|
||||
vec![("Host", "unit.test")],
|
||||
vec![("Host", "unit.test"), ("Origin", "null")],
|
||||
vec![("Host", "unit.test"), ("Origin", "http://unit.test")],
|
||||
vec![("Host", "unit.test"), ("Origin", "https://unit.test:444")],
|
||||
vec![
|
||||
("Host", "internal.test"),
|
||||
("Origin", "https://unit.test"),
|
||||
("X-Forwarded-Host", "unit.test"),
|
||||
],
|
||||
vec![("Host", "unit.test:bad"), ("Origin", "https://unit.test")],
|
||||
vec![
|
||||
("Host", "unit.test"),
|
||||
("Origin", "https://unit.test"),
|
||||
("Origin", "https://unit.test"),
|
||||
],
|
||||
] {
|
||||
let mut fields = fields;
|
||||
fields.push(("Cookie", &cookie));
|
||||
let request = request("POST", &fields);
|
||||
assert!(matches!(
|
||||
service.prepare("admin", &request, false, 100),
|
||||
Err(Error::Forbidden)
|
||||
));
|
||||
let (mut public, _) = service.prepare("admin", &request, true, 100).unwrap();
|
||||
assert_eq!(public.clear_cookie().unwrap_err(), Error::Forbidden);
|
||||
let mut response = api::response("{}");
|
||||
public.publish(&mut response);
|
||||
assert!(!response.headers().contains_key("set-cookie"));
|
||||
}
|
||||
let request = request(
|
||||
"POST",
|
||||
&[
|
||||
("Host", "UNIT.TEST:443"),
|
||||
("Origin", "https://unit.test"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
);
|
||||
assert!(service.prepare("admin", &request, false, 100).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_ignores_old_cookie_but_rejects_explicit_bad_authorization() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let cookie = cookie(&service);
|
||||
let old = request(
|
||||
"GET",
|
||||
&[("Host", "unit.test"), ("Cookie", "session=old-expired")],
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.prepare("admin", &old, true, 100)
|
||||
.unwrap()
|
||||
.1
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
service.prepare("admin", &old, false, 100),
|
||||
Err(Error::Unauthorized)
|
||||
));
|
||||
for fields in [
|
||||
vec![("Authorization", "Bearer invalid")],
|
||||
vec![
|
||||
("Authorization", "Bearer invalid"),
|
||||
("Authorization", "Bearer invalid"),
|
||||
],
|
||||
vec![
|
||||
("Cookie", cookie.as_str()),
|
||||
("Authorization", "Bearer invalid"),
|
||||
],
|
||||
vec![("Cookie", cookie.as_str()), ("Cookie", cookie.as_str())],
|
||||
] {
|
||||
let request = request("GET", &fields);
|
||||
assert!(matches!(
|
||||
service.prepare("admin", &request, false, 100),
|
||||
Err(Error::Unauthorized)
|
||||
));
|
||||
if fields.iter().any(|(name, _)| *name == "Authorization") {
|
||||
assert!(matches!(
|
||||
service.prepare("admin", &request, true, 100),
|
||||
Err(Error::Unauthorized)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::{
|
||||
request::{configured, request},
|
||||
*,
|
||||
};
|
||||
use dever_runtime::auth::{Error, Identity};
|
||||
|
||||
#[test]
|
||||
fn signed_tenant_reference_needs_provider_identity_before_becoming_storage_id() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let login = request(
|
||||
"POST",
|
||||
&[("Host", "unit.test"), ("Origin", "https://unit.test")],
|
||||
);
|
||||
let (mut context, _) = service.prepare("admin", &login, true, 100).unwrap();
|
||||
let token = context
|
||||
.issue("user", "session-id", Some("external-tenant"), 100)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings().providers["session"]
|
||||
.verify_token(&token, "admin", 100)
|
||||
.unwrap()
|
||||
.tenant
|
||||
.as_deref(),
|
||||
Some("external-tenant")
|
||||
);
|
||||
context
|
||||
.issue_cookie("user", "session-id", Some("external-tenant"), 100)
|
||||
.unwrap();
|
||||
let mut response = dever_runtime::api::response("{}");
|
||||
context.publish(&mut response);
|
||||
let value = response.headers().get("set-cookie").unwrap().as_bytes();
|
||||
let cookie = std::str::from_utf8(value)
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let incoming = request("GET", &[("Host", "unit.test"), ("Cookie", cookie)]);
|
||||
let (mut verified, claims) = service.prepare("admin", &incoming, false, 100).unwrap();
|
||||
assert_eq!(claims.unwrap().tenant.as_deref(), Some("external-tenant"));
|
||||
assert_eq!(verified.verified_tenant(), None);
|
||||
assert_eq!(verified.tenant_id(None).unwrap_err(), Error::Unauthorized);
|
||||
for id in [0, -1] {
|
||||
assert_eq!(
|
||||
verified
|
||||
.establish(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: Some(id)
|
||||
})
|
||||
.unwrap_err(),
|
||||
Error::Unauthorized
|
||||
);
|
||||
assert_eq!(verified.verified_tenant(), None);
|
||||
}
|
||||
verified
|
||||
.establish(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: Some(43),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(verified.verified_tenant(), Some(43));
|
||||
assert_eq!(verified.tenant_id(Some(17)).unwrap(), Some(43));
|
||||
assert_eq!(verified.tenant_id(Some(13)).unwrap_err(), Error::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "job")]
|
||||
fn restored_job_reverifies_tenant_and_persists_the_internal_id_separately() {
|
||||
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
|
||||
let claims = Claims {
|
||||
subject: "user".into(),
|
||||
session: "session-id".into(),
|
||||
tenant: Some("external-tenant".into()),
|
||||
site: "admin".into(),
|
||||
};
|
||||
assert!(service.trusted("other", &claims).is_err());
|
||||
let mut context = service.trusted("session", &claims).unwrap();
|
||||
assert_eq!(context.verified_tenant(), None);
|
||||
assert!(
|
||||
context
|
||||
.job_execution("news.article.admin.create".into())
|
||||
.is_err()
|
||||
);
|
||||
context
|
||||
.establish(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: Some(43),
|
||||
})
|
||||
.unwrap();
|
||||
let execution = context
|
||||
.job_execution("news.article.admin.create".into())
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(execution, dever_runtime::job::Execution::User { tenant: Some(reference), tenant_id: Some(43), .. } if reference == "external-tenant")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use crate::{
|
||||
api,
|
||||
api::Request,
|
||||
auth::{Context, Identity, Service},
|
||||
authorization::{
|
||||
self as authz, Declaration, Failure,
|
||||
capability::{self, Tenant},
|
||||
},
|
||||
database::{Binding, Context as DatabaseContext, Registry, Sql, Storage},
|
||||
task::Runtime,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
pub(super) struct Fixture {
|
||||
directory: PathBuf,
|
||||
pub(super) setting: PathBuf,
|
||||
config: Arc<api::Config>,
|
||||
service: Service,
|
||||
request: Request,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub(super) fn new() -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let name = format!("dever-tenant-auth-{}-{unique}", std::process::id());
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let directory = executable.parent().unwrap().join(&name);
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let setting = directory.join("setting.json");
|
||||
std::fs::write(&setting, serde_json::json!({
|
||||
"http":{"host":"127.0.0.1","port":8181},
|
||||
"sites":{"admin":{"path":"admin","hosts":["unit.test"]}},
|
||||
"database":{"default":{"type":"sqlite","path":format!("{name}/control.db"),"tenant_directory":format!("{name}/tenants"),"max_connections":1}},
|
||||
"tenant":{"database":"default","max_pools":4,"idle_timeout_ms":1000}
|
||||
}).to_string()).unwrap();
|
||||
let config = Arc::new(api::Config::load(&setting, &["admin"]).unwrap());
|
||||
let service = Service::new(config.clone(), &[], &[]).unwrap();
|
||||
Self {
|
||||
directory,
|
||||
setting,
|
||||
config,
|
||||
service,
|
||||
request: hyper::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/news/article/admin/create")
|
||||
.body(Default::default())
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(&self, tenant: i64, user: i64) -> Context<'_> {
|
||||
let (mut context, _) = self
|
||||
.service
|
||||
.prepare("admin", &self.request, true, 100)
|
||||
.unwrap();
|
||||
context.identity_for_authorization_test(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(user),
|
||||
tenant_id: Some(tenant),
|
||||
});
|
||||
context
|
||||
}
|
||||
|
||||
async fn registry(&self, scope: &crate::task::Scope<'_>) -> Arc<Registry> {
|
||||
let mut bindings = authz::schema::BINDINGS.to_vec();
|
||||
bindings.push(Binding {
|
||||
storage: Storage::Tenant,
|
||||
component: "news",
|
||||
database: None,
|
||||
table: "article",
|
||||
schema: Sql::same("CREATE TABLE article(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
});
|
||||
let registry = Registry::load(&self.setting, &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 17).await.unwrap();
|
||||
registry.migrate_tenant(scope, 18).await.unwrap();
|
||||
let database = DatabaseContext::new(registry.clone());
|
||||
let mut control = database.control(scope).await.unwrap();
|
||||
authz::synchronize(
|
||||
&mut control,
|
||||
scope,
|
||||
&self.config,
|
||||
&[Declaration {
|
||||
topic: "admin",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "create",
|
||||
method: "POST",
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_routes_control_catalog_and_tenant_roles_without_cross_database_joins() {
|
||||
let fixture = Fixture::new();
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| routing(&fixture, scope).await)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) async fn routing(fixture: &Fixture, scope: &crate::task::Scope<'_>) {
|
||||
let registry = fixture.registry(scope).await;
|
||||
let mut first = DatabaseContext::new(registry.clone());
|
||||
first.select_tenant(scope, 17).await.unwrap();
|
||||
let mut second = DatabaseContext::new(registry.clone());
|
||||
second.select_tenant(scope, 18).await.unwrap();
|
||||
authz::owner(first.on(1, scope).await.unwrap(), scope, "admin", 7)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut actor = fixture.identity(17, 7);
|
||||
authz::authorize_entry(
|
||||
&mut first, 1, scope, &mut actor, "news", "article", "create",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actor.entry_namespace().unwrap(), ("news", "article"));
|
||||
let tenant_tables = first
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(
|
||||
scope,
|
||||
Sql {
|
||||
sqlite: "SELECT COUNT(*) FROM sqlite_master WHERE name='_dever_permission'",
|
||||
postgres: "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='_dever_permission'",
|
||||
},
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tenant_tables, [0]);
|
||||
let key = capability::register_capability(
|
||||
&mut Tenant::open(&mut first, 1, scope, &actor).await.unwrap(),
|
||||
scope,
|
||||
&actor,
|
||||
[1; 16],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::save(
|
||||
&mut first,
|
||||
1,
|
||||
scope,
|
||||
&actor,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
std::slice::from_ref(&key),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::grant(
|
||||
first.on(1, scope).await.unwrap(),
|
||||
scope,
|
||||
&actor,
|
||||
8,
|
||||
"editor",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let reader = fixture.identity(17, 8);
|
||||
let permissions = authz::permissions(&mut first, 1, scope, &reader, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(permissions.len(), 1);
|
||||
assert_eq!(permissions[0].key, key);
|
||||
let other = fixture.identity(18, 8);
|
||||
assert!(
|
||||
authz::permissions(&mut second, 1, scope, &other, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(matches!(
|
||||
Tenant::open(&mut second, 1, scope, &actor).await,
|
||||
Err(Failure::Access(403))
|
||||
));
|
||||
assert_eq!(
|
||||
authz::permissions(&mut first, 1, scope, &actor, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
registry
|
||||
.set_component(scope, 17, "news", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
authz::authorize_key(&mut first, 1, scope, &actor, "news.article.admin.create").await,
|
||||
Err(Failure::Access(403))
|
||||
));
|
||||
assert!(
|
||||
authz::permissions(&mut first, 1, scope, &actor, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
registry
|
||||
.set_component(scope, 17, "news", true)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::authorize_key(&mut first, 1, scope, &actor, "news.article.admin.create")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(first);
|
||||
drop(second);
|
||||
registry.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_role_and_model_changes_share_rollback_and_reject_platform_transactions() {
|
||||
let fixture = Fixture::new();
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| rollback(&fixture, scope).await)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) async fn rollback(fixture: &Fixture, scope: &crate::task::Scope<'_>) {
|
||||
let registry = fixture.registry(scope).await;
|
||||
let mut database = DatabaseContext::new(registry.clone());
|
||||
database.select_tenant(scope, 17).await.unwrap();
|
||||
authz::owner(database.on(1, scope).await.unwrap(), scope, "admin", 7)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut actor = fixture.identity(17, 7);
|
||||
authz::authorize_entry(
|
||||
&mut database,
|
||||
1,
|
||||
scope,
|
||||
&mut actor,
|
||||
"news",
|
||||
"article",
|
||||
"create",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let transaction = database.enter();
|
||||
database
|
||||
.on(5, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "INSERT INTO article VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let key = capability::register_capability(
|
||||
&mut Tenant::open(&mut database, 1, scope, &actor).await.unwrap(),
|
||||
scope,
|
||||
&actor,
|
||||
[2; 16],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::save(
|
||||
&mut database,
|
||||
1,
|
||||
scope,
|
||||
&actor,
|
||||
"rolled-back",
|
||||
"Rollback",
|
||||
false,
|
||||
&[key],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut database, 1, scope, &actor, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(database.on(0, scope).await.is_err());
|
||||
database.finish(scope, transaction, false).await.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut database, 1, scope, &actor, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
authz::roles(
|
||||
database.on(1, scope).await.unwrap(),
|
||||
scope,
|
||||
&actor,
|
||||
authz::Selector::Id("rolled-back".into())
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
database
|
||||
.on(5, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM article", vec![], 1, |row| row
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
let mut missing = DatabaseContext::new(registry.clone());
|
||||
assert!(missing.select_tenant(scope, 19).await.is_err());
|
||||
drop(missing);
|
||||
drop(database);
|
||||
registry.close().await;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use super::{Context, Identity};
|
||||
|
||||
impl Context<'_> {
|
||||
pub(crate) fn identity_for_authorization_test(&mut self, identity: Identity) {
|
||||
self.identity = Some(identity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
api,
|
||||
api::Request,
|
||||
auth::{Identity, Service},
|
||||
database::{Database, Options},
|
||||
task::Runtime,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
mod mutations;
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
const ID: [u8; 16] = [1, 144, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 1];
|
||||
const KEY: &str = "capability:news.article.admin.01900000-0000-7000-8000-000000000001";
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
impl<'database> Tenant<'database> {
|
||||
pub(crate) fn for_test(executor: &'database mut Executor, id: i64) -> Self {
|
||||
Self { executor, id }
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
directory: PathBuf,
|
||||
service: Service,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"dever-capability-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let setting = directory.join("setting.json");
|
||||
std::fs::write(&setting, r#"{"http":{"host":"127.0.0.1","port":8181},"sites":{"admin":{"path":"admin","hosts":["unit.test"]},"front":{"path":"front","hosts":["unit.test"]}}}"#).unwrap();
|
||||
let config = api::Config::load(&setting, &["admin", "front"]).unwrap();
|
||||
let service = Service::new(Arc::new(config), &[], &[]).unwrap();
|
||||
Self { directory, service }
|
||||
}
|
||||
|
||||
fn context<'request>(
|
||||
&'request self,
|
||||
request: &'request Request,
|
||||
site: &str,
|
||||
tenant: Option<i64>,
|
||||
entry: Option<(&'static str, &'static str)>,
|
||||
) -> Context<'request> {
|
||||
let (mut context, _) = self.service.prepare(site, request, true, 100).unwrap();
|
||||
context.identity_for_authorization_test(Identity {
|
||||
id: "test-only".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: tenant,
|
||||
});
|
||||
if let Some((component, domain)) = entry {
|
||||
context.bind_entry(component, domain);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
async fn database(&self, scope: &Scope<'_>, name: &str, initialize: bool) -> Database {
|
||||
let database = Database::open(
|
||||
&self.directory,
|
||||
Options {
|
||||
driver: "sqlite".into(),
|
||||
path: format!("{name}.db"),
|
||||
url: None,
|
||||
tls: None,
|
||||
tenant_directory: None,
|
||||
tenant_database_prefix: None,
|
||||
min_connections: 1,
|
||||
max_connections: 1,
|
||||
max_page_size: 100,
|
||||
wait_timeout_ms: 1000,
|
||||
io_timeout_ms: 1000,
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if initialize {
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
for binding in &crate::authorization::schema::BINDINGS[1..] {
|
||||
executor.initialize(scope, binding.schema).await.unwrap();
|
||||
}
|
||||
}
|
||||
database
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn request(method: &str) -> Request {
|
||||
hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri("/test")
|
||||
.body(Default::default())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn denied<Value>(result: Result<Value, Failure>) {
|
||||
assert!(matches!(result, Err(Failure::Access(403))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_and_entry_guards_run_before_database_access() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
let get = request("GET");
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = fixture.database(scope, "empty", false).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let mut tenant = Tenant {
|
||||
executor: &mut executor,
|
||||
id: 1,
|
||||
};
|
||||
for tenant_id in [None, Some(0), Some(-1), Some(2)] {
|
||||
let context = fixture.context(&post, "admin", tenant_id, Some(("news", "article")));
|
||||
denied(register_capability(&mut tenant, scope, &context, ID).await);
|
||||
denied(remove_capability(&mut tenant, scope, &context, ID).await);
|
||||
denied(has_protected_roles(&mut tenant, scope, &context, 7).await);
|
||||
denied(capabilities(&mut tenant, scope, &context, false).await);
|
||||
}
|
||||
for (request, entry) in [(&post, None), (&get, Some(("news", "article")))] {
|
||||
let context = fixture.context(request, "admin", Some(1), entry);
|
||||
denied(register_capability(&mut tenant, scope, &context, ID).await);
|
||||
denied(remove_capability(&mut tenant, scope, &context, ID).await);
|
||||
}
|
||||
let (mut anonymous, _) = fixture.service.prepare("admin", &post, true, 100).unwrap();
|
||||
assert!(
|
||||
anonymous
|
||||
.establish(Identity {
|
||||
id: "u".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: Some(1)
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
has_protected_roles(&mut tenant, scope, &anonymous, 7).await,
|
||||
Err(Failure::Access(401))
|
||||
));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_use_entry_namespace_and_distinct_physical_tenant_stores() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = fixture.database(scope, "first", true).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let mut tenant = Tenant {
|
||||
executor: &mut executor,
|
||||
id: 1,
|
||||
};
|
||||
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
register_capability(&mut tenant, scope, &context, ID)
|
||||
.await
|
||||
.unwrap(),
|
||||
KEY
|
||||
);
|
||||
}
|
||||
let other = fixture.context(&post, "admin", Some(1), Some(("other", "article")));
|
||||
let other_key = register_capability(&mut tenant, scope, &other, ID)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(other_key, KEY);
|
||||
let front = fixture.context(&post, "front", Some(1), Some(("news", "article")));
|
||||
let front_key = register_capability(&mut tenant, scope, &front, ID)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(front_key, KEY);
|
||||
let listed = capabilities(&mut tenant, scope, &context, false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(listed.iter().all(|value| value.site == "admin"
|
||||
&& value.kind == "capability"
|
||||
&& value.method.is_empty()));
|
||||
assert_eq!(listed[0].action, "01900000-0000-7000-8000-000000000001");
|
||||
assert_eq!(
|
||||
capabilities(&mut tenant, scope, &front, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let second = fixture.database(scope, "second", true).await;
|
||||
let mut second_executor = second.executor().await.unwrap();
|
||||
let mut second_tenant = Tenant {
|
||||
executor: &mut second_executor,
|
||||
id: 2,
|
||||
};
|
||||
let second_context =
|
||||
fixture.context(&post, "admin", Some(2), Some(("news", "article")));
|
||||
assert!(
|
||||
capabilities(&mut second_tenant, scope, &second_context, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
register_capability(&mut second_tenant, scope, &second_context, ID)
|
||||
.await
|
||||
.unwrap(),
|
||||
KEY
|
||||
);
|
||||
remove_capability(&mut tenant, scope, &context, ID)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
capabilities(&mut second_tenant, scope, &second_context, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities(&mut tenant, scope, &other, false)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.key,
|
||||
other_key
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_roles_span_sites_and_all_permission_roles_follow_the_active_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
let get = request("GET");
|
||||
Runtime::new(Default::default()).unwrap().run(async |scope| {
|
||||
let database = fixture.database(scope, "roles", true).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, "INSERT INTO _dever_role VALUES ('front','owner','Owner',1,0,1),('admin','all','All',1,1,0); INSERT INTO _dever_user_role VALUES ('front',7,'owner'),('admin',7,'all');").await.unwrap();
|
||||
let mut tenant = Tenant { executor: &mut executor, id: 1 };
|
||||
let context = fixture.context(&get, "admin", Some(1), None);
|
||||
assert!(has_protected_roles(&mut tenant, scope, &context, 7).await.unwrap());
|
||||
assert!(!has_protected_roles(&mut tenant, scope, &context, 8).await.unwrap());
|
||||
assert!(matches!(has_protected_roles(&mut tenant, scope, &context, 0).await, Err(Failure::Access(400))));
|
||||
let writer = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
|
||||
register_capability(&mut tenant, scope, &writer, ID).await.unwrap();
|
||||
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
|
||||
remove_capability(&mut tenant, scope, &writer, ID).await.unwrap();
|
||||
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
|
||||
register_capability(&mut tenant, scope, &writer, ID).await.unwrap();
|
||||
tenant.executor.execute(scope, "UPDATE _dever_role SET enabled=0 WHERE site='admin'", vec![]).await.unwrap();
|
||||
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capability_directory_includes_permissions_beyond_the_model_page_limit() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = fixture.database(scope, "pages", true).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
crate::authorization::owner(&mut executor, scope, "admin", 7)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut tenant = Tenant {
|
||||
executor: &mut executor,
|
||||
id: 1,
|
||||
};
|
||||
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
|
||||
for value in 1u128..=101 {
|
||||
register_capability(&mut tenant, scope, &context, value.to_be_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for current in [false, true] {
|
||||
let entries = capabilities(&mut tenant, scope, &context, current)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 101);
|
||||
assert!(entries.windows(2).all(|pair| pair[0].key < pair[1].key));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_is_atomic_clears_all_grants_and_reregistration_does_not_restore_them() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
Runtime::new(Default::default()).unwrap().run(async |scope| {
|
||||
let database = fixture.database(scope, "removal", true).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let mut tenant = Tenant { executor: &mut executor, id: 1 };
|
||||
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
|
||||
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
tenant.executor.initialize(scope, "INSERT INTO _dever_role VALUES ('admin','reader','Reader',0,1,0),('admin','second','Second',0,1,0); INSERT INTO _dever_user_role VALUES ('admin',7,'reader');").await.unwrap();
|
||||
for role in ["reader", "second"] {
|
||||
tenant.executor.execute(scope, "INSERT INTO _dever_role_permission VALUES ('admin',$1,$2)", vec![Parameter::Text(role.into()), Parameter::Text(KEY.into())]).await.unwrap();
|
||||
}
|
||||
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
|
||||
let owner = tenant.executor.begin(scope).await.unwrap();
|
||||
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
|
||||
tenant.executor.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
|
||||
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
let counts = tenant.executor.query(scope, "SELECT COUNT(*) FROM _dever_role_permission", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(counts, [0]);
|
||||
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
|
||||
assert_eq!(capabilities(&mut tenant, scope, &context, false).await.unwrap().len(), 1);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_and_models_share_rollback_and_failed_removal_keeps_the_catalog_active() {
|
||||
let fixture = Fixture::new();
|
||||
let post = request("POST");
|
||||
Runtime::new(Default::default()).unwrap().run(async |scope| {
|
||||
let database = fixture.database(scope, "rollback", true).await;
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, "CREATE TABLE article(id BIGINT PRIMARY KEY);").await.unwrap();
|
||||
let mut tenant = Tenant { executor: &mut executor, id: 1 };
|
||||
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
|
||||
let owner = tenant.executor.begin(scope).await.unwrap();
|
||||
tenant.executor.execute(scope, "INSERT INTO article VALUES (1)", vec![]).await.unwrap();
|
||||
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
tenant.executor.finish(scope, owner, false).await.unwrap();
|
||||
assert!(capabilities(&mut tenant, scope, &context, false).await.unwrap().is_empty());
|
||||
assert_eq!(tenant.executor.query(scope, "SELECT COUNT(*) FROM article", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
|
||||
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
|
||||
tenant.executor.initialize(scope, "CREATE TRIGGER reject_removal BEFORE DELETE ON _dever_role_permission BEGIN SELECT RAISE(ABORT, 'test-only failure'); END; INSERT INTO _dever_role VALUES ('admin','reader','Reader',0,1,0);").await.unwrap();
|
||||
tenant.executor.execute(scope, "INSERT INTO _dever_role_permission VALUES ('admin','reader',$1)", vec![Parameter::Text(KEY.into())]).await.unwrap();
|
||||
assert!(matches!(remove_capability(&mut tenant, scope, &context, ID).await, Err(Failure::Database(_))));
|
||||
assert_eq!(capabilities(&mut tenant, scope, &context, false).await.unwrap().len(), 1);
|
||||
assert_eq!(tenant.executor.query(scope, "SELECT COUNT(*) FROM _dever_role_permission", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [1]);
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 行锁测试固定使用已借用连接;目录路由的真实 Context 验证由租户集成用例覆盖。
|
||||
|
||||
pub(super) use crate::authorization::{
|
||||
disable, grant, owner, remove, revoke, roles, schema, synchronize,
|
||||
};
|
||||
use crate::{
|
||||
auth::Context,
|
||||
authorization::{Failure, Permission, access, capability, catalog, roles as storage},
|
||||
database::Executor,
|
||||
task::Scope,
|
||||
};
|
||||
|
||||
pub(super) async fn permissions(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
context: &Context<'_>,
|
||||
current: bool,
|
||||
) -> Result<Vec<Permission>, Failure> {
|
||||
context.id()?;
|
||||
let mut entries = catalog::read(executor, scope, context.site(), None).await?;
|
||||
if let Some(id) = context.verified_tenant() {
|
||||
let mut tenant = capability::Tenant::for_test(executor, id);
|
||||
entries.extend(capability::capabilities(&mut tenant, scope, context, false).await?);
|
||||
}
|
||||
if current {
|
||||
let Some(user) = context.user_id(None)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
access::filter(executor, scope, context, user, &mut entries).await?;
|
||||
}
|
||||
entries.sort_unstable_by(|left, right| left.key.cmp(&right.key));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(super) async fn authorize_key(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
context: &Context<'_>,
|
||||
key: &str,
|
||||
) -> Result<(), Failure> {
|
||||
context.id()?;
|
||||
if !catalog::read(executor, scope, context.site(), None)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|entry| entry.key == key)
|
||||
{
|
||||
return Err(Failure::Access(403));
|
||||
}
|
||||
storage::granted(executor, scope, context, key).await
|
||||
}
|
||||
|
||||
pub(super) async fn authorize_entry(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
context: &mut Context<'_>,
|
||||
component: &'static str,
|
||||
domain: &'static str,
|
||||
action: &str,
|
||||
) -> Result<(), Failure> {
|
||||
authorize_key(
|
||||
executor,
|
||||
scope,
|
||||
context,
|
||||
&format!("{component}.{domain}.{}.{action}", context.site()),
|
||||
)
|
||||
.await?;
|
||||
context.bind_entry(component, domain);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn save(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
context: &Context<'_>,
|
||||
id: &str,
|
||||
name: &str,
|
||||
all: bool,
|
||||
keys: &[String],
|
||||
) -> Result<(), Failure> {
|
||||
context.id()?;
|
||||
let catalog = catalog::read(executor, scope, context.site(), None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|entry| entry.key)
|
||||
.collect();
|
||||
storage::save(executor, scope, context, catalog, id, name, all, keys).await
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::*;
|
||||
use crate::authorization::capability::{self, Tenant};
|
||||
|
||||
const ID: [u8; 16] = [1, 144, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 1];
|
||||
|
||||
fn context(case: &Case) -> Context<'_> {
|
||||
let mut context = case.context("admin", Some(USER));
|
||||
context.identity_for_authorization_test(Identity {
|
||||
id: "verified-user".into(),
|
||||
user_id: Some(USER),
|
||||
tenant_id: Some(17),
|
||||
});
|
||||
context.bind_entry("news", "article");
|
||||
context
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_capabilities_share_role_transactions_and_reregistration_drops_old_grants() {
|
||||
fixture(async |case, scope| {
|
||||
let context = context(case);
|
||||
let mut executor = case.executor(scope).await;
|
||||
let key = capability::register_capability(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
"capability:news.article.admin.01900000-0000-7000-8000-000000000001"
|
||||
);
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
std::slice::from_ref(&key),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::grant(&mut executor, scope, &context, USER, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut executor, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let transaction = executor.begin(scope).await.unwrap();
|
||||
capability::remove_capability(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
authz::permissions(&mut executor, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
executor.finish(scope, transaction, false).await.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut executor, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
capability::remove_capability(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
capability::register_capability(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
authz::permissions(&mut executor, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
authz::owner(&mut executor, scope, "front", USER)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
capability::has_protected_roles(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
USER
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!capability::has_protected_roles(
|
||||
&mut Tenant::for_test(&mut executor, 17),
|
||||
scope,
|
||||
&context,
|
||||
USER + 1
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
authz::permissions(&mut executor, scope, &context, false)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.kind,
|
||||
"capability"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_capability_removal_serializes_with_grants_in_both_lock_orders() {
|
||||
fixture(async |case, scope| {
|
||||
let context = context(case);
|
||||
let mut first = case.executor(scope).await;
|
||||
let mut second = case.executor(scope).await;
|
||||
let blocker = case.pid(scope, &mut first).await;
|
||||
let waiter = case.pid(scope, &mut second).await;
|
||||
let key = capability::register_capability(
|
||||
&mut Tenant::for_test(&mut first, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let transaction = first.begin(scope).await.unwrap();
|
||||
authz::save(
|
||||
&mut first,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
std::slice::from_ref(&key),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let remove = async {
|
||||
capability::remove_capability(
|
||||
&mut Tenant::for_test(&mut second, 17),
|
||||
scope,
|
||||
&context,
|
||||
ID,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let commit = async {
|
||||
case.blocked(scope, waiter, blocker).await;
|
||||
first.finish(scope, transaction, true).await.unwrap();
|
||||
};
|
||||
let (removed, ()) = tokio::join!(remove, commit);
|
||||
removed.unwrap();
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
capability::register_capability(&mut Tenant::for_test(&mut first, 17), scope, &context, ID)
|
||||
.await
|
||||
.unwrap();
|
||||
let transaction = first.begin(scope).await.unwrap();
|
||||
capability::remove_capability(&mut Tenant::for_test(&mut first, 17), scope, &context, ID)
|
||||
.await
|
||||
.unwrap();
|
||||
let grant = authz::save(
|
||||
&mut second,
|
||||
scope,
|
||||
&context,
|
||||
"late",
|
||||
"Late",
|
||||
false,
|
||||
std::slice::from_ref(&key),
|
||||
);
|
||||
let commit = async {
|
||||
case.blocked(scope, waiter, blocker).await;
|
||||
first.finish(scope, transaction, true).await.unwrap();
|
||||
};
|
||||
let (granted, ()) = tokio::join!(grant, commit);
|
||||
denied(granted, 400);
|
||||
assert!(
|
||||
authz::roles(&mut first, scope, &context, Selector::Id("late".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_directory_sync_is_atomic_and_never_broadens_owner_permissions() {
|
||||
fixture(async |case, scope| {
|
||||
let mut first = case.executor(scope).await;
|
||||
let mut second = case.executor(scope).await;
|
||||
let context = case.context("admin", Some(USER));
|
||||
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::owner(&mut first, scope, "admin", USER)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut first, scope, &context, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
authz::permissions(&mut first, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let owner = first.begin(scope).await.unwrap();
|
||||
authz::synchronize(&mut first, scope, &case.config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::authorize_key(&mut first, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
authz::authorize_key(&mut second, scope, &context, CREATE)
|
||||
.await
|
||||
.unwrap();
|
||||
first.finish(scope, owner, false).await.unwrap();
|
||||
authz::authorize_key(&mut first, scope, &context, CREATE)
|
||||
.await
|
||||
.unwrap();
|
||||
let duplicate = [
|
||||
Declaration {
|
||||
topic: "admin",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "create",
|
||||
method: "POST",
|
||||
},
|
||||
Declaration {
|
||||
topic: "admin",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "create",
|
||||
method: "DELETE",
|
||||
},
|
||||
];
|
||||
denied(
|
||||
authz::synchronize(&mut first, scope, &case.config, &duplicate).await,
|
||||
500,
|
||||
);
|
||||
authz::authorize_key(&mut first, scope, &context, REMOVE)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS[1..])
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::authorize_key(&mut first, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
authz::authorize_key(&mut first, scope, &context, REMOVE)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authz::permissions(&mut first, scope, &context, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_startup_sync_serializes_new_permissions_before_replacing_the_catalog() {
|
||||
fixture(async |case, scope| {
|
||||
let mut first = case.executor(scope).await;
|
||||
let mut second = case.executor(scope).await;
|
||||
let context = case.context("admin", Some(USER));
|
||||
let blocker = case.pid(scope, &mut first).await;
|
||||
let pid = case.pid(scope, &mut second).await;
|
||||
let owner = first.begin(scope).await.unwrap();
|
||||
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS[..1])
|
||||
.await
|
||||
.unwrap();
|
||||
let replace = authz::synchronize(&mut second, scope, &case.config, &DECLARATIONS[1..]);
|
||||
let commit = async {
|
||||
case.blocked(scope, pid, blocker).await;
|
||||
first.finish(scope, owner, true).await.unwrap();
|
||||
};
|
||||
let (result, ()) = tokio::join!(replace, commit);
|
||||
result.unwrap();
|
||||
authz::owner(&mut first, scope, "admin", USER)
|
||||
.await
|
||||
.unwrap();
|
||||
let active = authz::permissions(&mut first, scope, &context, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].key, REMOVE);
|
||||
denied(
|
||||
authz::authorize_key(&mut first, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::save(
|
||||
&mut first,
|
||||
scope,
|
||||
&context,
|
||||
"stale",
|
||||
"Stale",
|
||||
false,
|
||||
&[CREATE.into()],
|
||||
)
|
||||
.await,
|
||||
400,
|
||||
);
|
||||
assert!(
|
||||
authz::roles(&mut first, scope, &context, Selector::Id("stale".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_role_mutations_check_protection_after_acquiring_the_row_lock() {
|
||||
fixture(async |case, scope| {
|
||||
let context = case.context("admin", Some(USER));
|
||||
for operation in 0..5 {
|
||||
let id = format!("role{operation}");
|
||||
let mut locker = case.executor(scope).await;
|
||||
authz::save(&mut locker, scope, &context, &id, "Original", false, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
authz::grant(&mut locker, scope, &context, USER, &id)
|
||||
.await
|
||||
.unwrap();
|
||||
let blocker = case.pid(scope, &mut locker).await;
|
||||
let owner = locker.begin(scope).await.unwrap();
|
||||
locker
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_role WHERE site='admin' AND id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(id.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut worker = case.executor(scope).await;
|
||||
let pid = case.pid(scope, &mut worker).await;
|
||||
let mutation = async {
|
||||
match operation {
|
||||
0 => authz::save(&mut worker, scope, &context, &id, "Changed", true, &[]).await,
|
||||
1 => authz::grant(&mut worker, scope, &context, USER + 1, &id).await,
|
||||
2 => authz::revoke(&mut worker, scope, &context, USER, &id).await,
|
||||
3 => authz::disable(&mut worker, scope, &context, &id).await,
|
||||
_ => authz::remove(&mut worker, scope, &context, &id).await,
|
||||
}
|
||||
};
|
||||
let protect = async {
|
||||
case.blocked(scope, pid, blocker).await;
|
||||
locker
|
||||
.execute(
|
||||
scope,
|
||||
"UPDATE _dever_role SET protected=true WHERE site='admin' AND id=$1",
|
||||
vec![Parameter::Text(id.clone())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
locker.finish(scope, owner, true).await.unwrap();
|
||||
};
|
||||
let (result, ()) = tokio::join!(mutation, protect);
|
||||
denied(result, 403);
|
||||
let role = authz::roles(&mut worker, scope, &context, Selector::Id(id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(role[0].name, "Original");
|
||||
assert!(role[0].protected && role[0].enabled && !role[0].all_permissions);
|
||||
}
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
|
||||
.await,
|
||||
5
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_concurrent_save_keeps_one_complete_role_and_owner_creation_cannot_be_overwritten() {
|
||||
fixture(async |case, scope| {
|
||||
let mut first = case.executor(scope).await;
|
||||
let mut second = case.executor(scope).await;
|
||||
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
let context = case.context("admin", Some(USER));
|
||||
for index in 0..8 {
|
||||
let id = format!("race{index}");
|
||||
let first_keys = [CREATE.into()];
|
||||
let second_keys = [REMOVE.into()];
|
||||
let (left, right) = tokio::join!(
|
||||
authz::save(
|
||||
&mut first,
|
||||
scope,
|
||||
&context,
|
||||
&id,
|
||||
"Create",
|
||||
false,
|
||||
&first_keys
|
||||
),
|
||||
authz::save(
|
||||
&mut second,
|
||||
scope,
|
||||
&context,
|
||||
&id,
|
||||
"Remove",
|
||||
true,
|
||||
&second_keys
|
||||
),
|
||||
);
|
||||
left.unwrap();
|
||||
right.unwrap();
|
||||
let role = authz::roles(&mut first, scope, &context, Selector::Id(id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
(role[0].name == "Create"
|
||||
&& !role[0].all_permissions
|
||||
&& role[0].permission_keys == [CREATE])
|
||||
|| (role[0].name == "Remove"
|
||||
&& role[0].all_permissions
|
||||
&& role[0].permission_keys == [REMOVE])
|
||||
);
|
||||
}
|
||||
let (owner, saved) = tokio::join!(
|
||||
authz::owner(&mut first, scope, "admin", USER),
|
||||
authz::save(
|
||||
&mut second,
|
||||
scope,
|
||||
&context,
|
||||
"owner",
|
||||
"Ordinary",
|
||||
false,
|
||||
&[]
|
||||
),
|
||||
);
|
||||
let role = authz::roles(&mut first, scope, &context, Selector::Id("owner".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
if owner.is_ok() {
|
||||
denied(saved, 403);
|
||||
assert!(role[0].protected && role[0].all_permissions && role[0].enabled);
|
||||
assert_eq!(role[0].name, "Owner");
|
||||
} else {
|
||||
denied(owner, 409);
|
||||
saved.unwrap();
|
||||
assert!(!role[0].protected && !role[0].all_permissions);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_user_role WHERE role_id='owner'"
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
api,
|
||||
api::Request,
|
||||
auth::{Context, Identity, Service},
|
||||
authorization::{Declaration, Failure, Selector},
|
||||
database::{Database, Error, Executor, Options, Parameter},
|
||||
task::{Runtime, Scope},
|
||||
};
|
||||
use futures_util::FutureExt;
|
||||
use std::{
|
||||
panic::AssertUnwindSafe,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[path = "access.rs"]
|
||||
mod authz;
|
||||
mod capability;
|
||||
mod catalog;
|
||||
mod concurrent;
|
||||
mod roles;
|
||||
|
||||
const CREATE: &str = "news.article.admin.create";
|
||||
const REMOVE: &str = "news.article.admin.remove";
|
||||
const USER: i64 = 4_294_967_300;
|
||||
const DECLARATIONS: [Declaration; 3] = [
|
||||
Declaration {
|
||||
topic: "admin",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "create",
|
||||
method: "POST",
|
||||
},
|
||||
Declaration {
|
||||
topic: "admin",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "remove",
|
||||
method: "DELETE",
|
||||
},
|
||||
Declaration {
|
||||
topic: "front",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
action: "read",
|
||||
method: "GET",
|
||||
},
|
||||
];
|
||||
|
||||
struct Case {
|
||||
database: Database,
|
||||
schema: String,
|
||||
config: Arc<api::Config>,
|
||||
service: Service,
|
||||
request: Request,
|
||||
}
|
||||
|
||||
impl Case {
|
||||
async fn executor(&self, scope: &Scope<'_>) -> Executor {
|
||||
let mut executor = self.database.executor().await.unwrap();
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT set_config('search_path',$1,false)",
|
||||
vec![Parameter::Text(self.schema.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
}
|
||||
|
||||
fn context(&self, site: &str, user: Option<i64>) -> Context<'_> {
|
||||
let (mut context, _) = self
|
||||
.service
|
||||
.prepare(site, &self.request, true, 100)
|
||||
.unwrap();
|
||||
if let Some(user) = user {
|
||||
context.identity_for_authorization_test(Identity {
|
||||
id: "verified-fixture-user".into(),
|
||||
user_id: Some(user),
|
||||
tenant_id: None,
|
||||
});
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
async fn count(&self, scope: &Scope<'_>, sql: &'static str) -> i64 {
|
||||
self.executor(scope)
|
||||
.await
|
||||
.query(scope, sql, vec![], 1, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap()[0]
|
||||
}
|
||||
|
||||
async fn pid(&self, scope: &Scope<'_>, executor: &mut Executor) -> i64 {
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT CAST(pg_backend_pid() AS BIGINT)",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
}
|
||||
|
||||
async fn blocked(&self, scope: &Scope<'_>, pid: i64, blocker: i64) {
|
||||
let mut observer = self.executor(scope).await;
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let waiting = observer.query(scope, "SELECT CAST(CAST($2 AS BIGINT) AS INTEGER) = ANY(pg_blocking_pids(CAST(CAST($1 AS BIGINT) AS INTEGER)))", vec![Parameter::Integer(pid), Parameter::Integer(blocker)], 1, |row| row.get::<_, bool>(0)).await.unwrap()[0];
|
||||
if waiting { break; }
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}).await.expect("authorization operation did not wait for its row lock");
|
||||
}
|
||||
}
|
||||
|
||||
fn denied<Value>(result: Result<Value, Failure>, expected: u16) {
|
||||
assert!(matches!(result, Err(Failure::Access(status)) if status == expected));
|
||||
}
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&Case, &Scope<'_>)) {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
|
||||
let document = Document::load(&path).expect("cannot read explicit PostgreSQL test settings");
|
||||
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
|
||||
let value = root
|
||||
.get("database")
|
||||
.and_then(|database| database.get("postgres_test"))
|
||||
.expect("database.postgres_test must explicitly select the test database");
|
||||
let mut options: Options = serde_json::from_value(value.clone())
|
||||
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
|
||||
assert_eq!(options.driver, "postgres");
|
||||
options.max_connections = 8;
|
||||
options.wait_timeout_ms = 1000;
|
||||
options.io_timeout_ms = 5000;
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let schema = format!("dever_auth_test_{}_{unique}", std::process::id());
|
||||
let setting = std::env::temp_dir().join(format!("{schema}.json"));
|
||||
std::fs::write(&setting, r#"{"http":{"host":"127.0.0.1","port":8181},"sites":{"admin":{"path":"admin","hosts":["unit.test"]},"front":{"path":"front","hosts":["unit.test"]}}}"#).unwrap();
|
||||
let config = api::Config::load(&setting, &["admin", "front"]);
|
||||
std::fs::remove_file(setting).unwrap();
|
||||
let config = Arc::new(config.unwrap());
|
||||
let service = Service::new(config.clone(), &[], &[]).unwrap();
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(Path::new("."), options, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let case = Case {
|
||||
database,
|
||||
schema,
|
||||
config,
|
||||
service,
|
||||
request: hyper::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/entry")
|
||||
.body(Default::default())
|
||||
.unwrap(),
|
||||
};
|
||||
let mut owner = case.database.executor().await.unwrap();
|
||||
owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("CREATE SCHEMA {}", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = AssertUnwindSafe(async {
|
||||
let mut executor = case.executor(scope).await;
|
||||
for _ in 0..2 {
|
||||
for binding in authz::schema::BINDINGS {
|
||||
executor
|
||||
.schema(scope, binding.table, binding.schema)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
drop(executor);
|
||||
tokio::time::timeout(Duration::from_secs(30), check(&case, scope))
|
||||
.await
|
||||
.expect("PostgreSQL authorization test deadline exceeded");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let cleanup = owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("DROP SCHEMA {} CASCADE", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await;
|
||||
case.database.close();
|
||||
cleanup.expect("PostgreSQL authorization schema cleanup failed");
|
||||
if let Err(panic) = outcome {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_role_grant_revoke_disable_remove_and_entry_authorization() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut context = case.context("admin", Some(USER));
|
||||
let front = case.context("front", Some(USER));
|
||||
denied(
|
||||
authz::authorize_entry(
|
||||
&mut executor,
|
||||
scope,
|
||||
&mut context,
|
||||
"news",
|
||||
"article",
|
||||
"create",
|
||||
)
|
||||
.await,
|
||||
403,
|
||||
);
|
||||
assert!(context.entry_namespace().is_err());
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
&[CREATE.into(), CREATE.into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
authz::grant(&mut executor, scope, &context, USER, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
authz::authorize_entry(
|
||||
&mut executor,
|
||||
scope,
|
||||
&mut context,
|
||||
"news",
|
||||
"article",
|
||||
"create",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(context.entry_namespace().unwrap(), ("news", "article"));
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &context, REMOVE).await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &front, CREATE).await,
|
||||
403,
|
||||
);
|
||||
let roles = authz::roles(&mut executor, scope, &context, Selector::User(USER))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(roles.len(), 1);
|
||||
assert_eq!(roles[0].permission_keys, [CREATE]);
|
||||
assert!(!roles[0].all_permissions && roles[0].enabled && !roles[0].protected);
|
||||
assert_eq!(
|
||||
authz::permissions(&mut executor, scope, &context, true)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.key,
|
||||
CREATE
|
||||
);
|
||||
authz::revoke(&mut executor, scope, &context, USER, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
authz::grant(&mut executor, scope, &context, USER, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
authz::disable(&mut executor, scope, &context, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
authz::save(&mut executor, scope, &context, "editor", "All", true, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
authz::authorize_key(&mut executor, scope, &context, REMOVE)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::remove(&mut executor, scope, &context, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
denied(
|
||||
authz::grant(&mut executor, scope, &context, USER, "missing").await,
|
||||
404,
|
||||
);
|
||||
denied(
|
||||
authz::grant(&mut executor, scope, &context, 0, "missing").await,
|
||||
400,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_owner_is_explicit_protected_and_never_authorizes_unknown_keys() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
let context = case.context("admin", Some(USER));
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
|
||||
403,
|
||||
);
|
||||
for _ in 0..2 {
|
||||
authz::owner(&mut executor, scope, "admin", USER)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
authz::authorize_key(&mut executor, scope, &context, CREATE)
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::authorize_key(&mut executor, scope, &context, "news.article.admin.unknown")
|
||||
.await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"owner",
|
||||
"Changed",
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::disable(&mut executor, scope, &context, "owner").await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::remove(&mut executor, scope, &context, "owner").await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::grant(&mut executor, scope, &context, 8, "owner").await,
|
||||
403,
|
||||
);
|
||||
denied(
|
||||
authz::revoke(&mut executor, scope, &context, USER, "owner").await,
|
||||
403,
|
||||
);
|
||||
let owner = authz::roles(&mut executor, scope, &context, Selector::Id("owner".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(owner.len(), 1);
|
||||
assert!(owner[0].protected && owner[0].all_permissions && owner[0].enabled);
|
||||
let front = case.context("front", Some(USER));
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&front,
|
||||
"owner",
|
||||
"Ordinary",
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
denied(authz::owner(&mut executor, scope, "front", USER).await, 409);
|
||||
assert!(
|
||||
authz::roles(&mut executor, scope, &front, Selector::User(USER))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_role_writes_reuse_ambient_transaction_and_roll_back_atomically() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
let context = case.context("admin", Some(USER));
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
&[CREATE.into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
authz::grant(&mut executor, scope, &context, USER, "editor")
|
||||
.await
|
||||
.unwrap();
|
||||
authz::authorize_key(&mut executor, scope, &context, CREATE)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role").await,
|
||||
0
|
||||
);
|
||||
denied(
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Invalid",
|
||||
true,
|
||||
&["unknown".into()],
|
||||
)
|
||||
.await,
|
||||
400,
|
||||
);
|
||||
assert!(executor.finish(scope, owner, true).await.is_err());
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_role").await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Editor",
|
||||
false,
|
||||
&[CREATE.into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
denied(
|
||||
authz::save(
|
||||
&mut executor,
|
||||
scope,
|
||||
&context,
|
||||
"editor",
|
||||
"Invalid",
|
||||
true,
|
||||
&["news.article.front.read".into()],
|
||||
)
|
||||
.await,
|
||||
400,
|
||||
);
|
||||
let role = authz::roles(&mut executor, scope, &context, Selector::All)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(role[0].name, "Editor");
|
||||
assert_eq!(role[0].permission_keys, [CREATE]);
|
||||
assert!(!role[0].all_permissions);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit config/setting.json database.postgres_test"]
|
||||
fn postgres_missing_identity_is_rejected_before_any_database_query() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute("SET search_path TO pg_catalog")
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut anonymous = case.context("admin", None);
|
||||
denied(
|
||||
authz::roles(&mut executor, scope, &anonymous, Selector::All).await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::permissions(&mut executor, scope, &anonymous, false).await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::save(&mut executor, scope, &anonymous, "x", "X", true, &[]).await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::grant(&mut executor, scope, &anonymous, USER, "x").await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::revoke(&mut executor, scope, &anonymous, USER, "x").await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::disable(&mut executor, scope, &anonymous, "x").await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::remove(&mut executor, scope, &anonymous, "x").await,
|
||||
401,
|
||||
);
|
||||
denied(
|
||||
authz::authorize_entry(
|
||||
&mut executor,
|
||||
scope,
|
||||
&mut anonymous,
|
||||
"news",
|
||||
"article",
|
||||
"create",
|
||||
)
|
||||
.await,
|
||||
401,
|
||||
);
|
||||
assert!(anonymous.entry_namespace().is_err());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::access_tests::{self, Fixture};
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Database, Options, Parameter},
|
||||
task::{Runtime, Scope},
|
||||
};
|
||||
use futures_util::FutureExt;
|
||||
use std::{panic::AssertUnwindSafe, path::Path};
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&Fixture, &Scope<'_>)) {
|
||||
let fixture = Fixture::new();
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let document = Document::load(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let settings: serde_json::Value = document.decode().unwrap();
|
||||
let admin: Options =
|
||||
serde_json::from_value(settings["database"]["postgres_test"].clone()).unwrap();
|
||||
let mut options = settings["database"]["postgres_tenant_test"].clone();
|
||||
let template = options["url"].as_str().unwrap();
|
||||
assert_eq!(template.matches("{case}").count(), 1);
|
||||
assert!(
|
||||
template
|
||||
.parse::<tokio_postgres::Config>()
|
||||
.unwrap()
|
||||
.get_dbname()
|
||||
.unwrap()
|
||||
.contains("{case}")
|
||||
);
|
||||
let token = crate::database::scalar::generated_uuid()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.replace('-', "");
|
||||
let url = template.replace("{case}", &token);
|
||||
let control = url
|
||||
.parse::<tokio_postgres::Config>()
|
||||
.unwrap()
|
||||
.get_dbname()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert!(
|
||||
control
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|
||||
);
|
||||
let prefix = format!("auth_{token}");
|
||||
let names = [
|
||||
format!("{prefix}_17"),
|
||||
format!("{prefix}_18"),
|
||||
control.clone(),
|
||||
];
|
||||
let admin = Database::open(Path::new("."), admin, scope).await.unwrap();
|
||||
let mut executor = admin.executor().await.unwrap();
|
||||
for name in &names {
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM pg_database WHERE datname=$1",
|
||||
vec![Parameter::Text(name.clone())],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
}
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("CREATE DATABASE \"{control}\""))
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
options["url"] = url.into();
|
||||
options["tenant_database_prefix"] = prefix.into();
|
||||
options["max_connections"] = 1.into();
|
||||
let result = AssertUnwindSafe(async {
|
||||
let mut config: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&fixture.setting).unwrap()).unwrap();
|
||||
config["database"]["default"] = options;
|
||||
std::fs::write(&fixture.setting, config.to_string()).unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), check(&fixture, scope))
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
for name in &names {
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!(
|
||||
"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)"
|
||||
))
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.await
|
||||
.expect("owned tenant authorization database cleanup failed");
|
||||
}
|
||||
drop(executor);
|
||||
admin.close();
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
|
||||
fn postgres_two_physical_tenants_keep_roles_and_capabilities_out_of_the_control_catalog() {
|
||||
fixture(async |fixture, scope| access_tests::routing(fixture, scope).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
|
||||
fn postgres_tenant_role_capability_and_model_rollback_uses_one_physical_transaction() {
|
||||
fixture(async |fixture, scope| access_tests::rollback(fixture, scope).await);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use dever_runtime::time::{
|
||||
self,
|
||||
calendar::{Date, DateTime, Duration, Time},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rfc3339_offsets_normalize_to_utc_at_millisecond_precision() {
|
||||
assert_eq!(
|
||||
DateTime::parse("2024-02-29T12:34:56.12+08:00")
|
||||
.unwrap()
|
||||
.format()
|
||||
.unwrap(),
|
||||
"2024-02-29T04:34:56.120Z"
|
||||
);
|
||||
for value in [
|
||||
"1969-12-31T23:59:59.999Z",
|
||||
"0000-01-01T00:00:00Z",
|
||||
"9999-12-31T23:59:59.999Z",
|
||||
] {
|
||||
assert_eq!(DateTime::parse(value).unwrap().format().unwrap(), value);
|
||||
}
|
||||
for value in [
|
||||
"2023-02-29T00:00:00Z",
|
||||
"2024-02-29T00:00:00.1230Z",
|
||||
"2024-02-29T00:00:00.1234Z",
|
||||
"2024-12-31T23:59:60Z",
|
||||
"2024-01-01T00:00:00",
|
||||
"9999-12-31T23:30:00-01:00",
|
||||
"0000-01-01T00:00:00+01:00",
|
||||
] {
|
||||
assert!(DateTime::parse(value).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_and_time_spellings_are_strict_and_zero_fractions_normalize() {
|
||||
assert_eq!(
|
||||
Date::parse("2024-02-29").unwrap().format().unwrap(),
|
||||
"2024-02-29"
|
||||
);
|
||||
for value in ["2024-2-29", "2023-02-29", "10000-01-01", "2024-01-01 "] {
|
||||
assert!(Date::parse(value).is_err(), "{value}");
|
||||
}
|
||||
assert_eq!(
|
||||
Time::parse("12:34:56.000").unwrap().format().unwrap(),
|
||||
"12:34:56"
|
||||
);
|
||||
assert_eq!(
|
||||
Time::parse("12:34:56.001").unwrap().format().unwrap(),
|
||||
"12:34:56.001"
|
||||
);
|
||||
for value in [
|
||||
"24:00:00",
|
||||
"23:60:00",
|
||||
"23:59:60",
|
||||
"12:34:56.1",
|
||||
"12:34:56.12",
|
||||
"12:34:56.1234",
|
||||
" 12:34:56",
|
||||
] {
|
||||
assert!(Time::parse(value).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_keeps_i64_values_and_checks_overflow_separately_from_formatting() {
|
||||
let epoch = DateTime::parse("1970-01-01T00:00:00Z").unwrap();
|
||||
assert_eq!(
|
||||
epoch
|
||||
.checked_sub(Duration::from_millis(1))
|
||||
.unwrap()
|
||||
.format()
|
||||
.unwrap(),
|
||||
"1969-12-31T23:59:59.999Z"
|
||||
);
|
||||
let maximum = epoch.checked_add(Duration::from_millis(i64::MAX)).unwrap();
|
||||
let minimum = epoch.checked_add(Duration::from_millis(i64::MIN)).unwrap();
|
||||
assert!(maximum.format().is_err());
|
||||
assert!(maximum.checked_add(Duration::from_millis(1)).is_err());
|
||||
assert!(minimum.checked_sub(Duration::from_millis(1)).is_err());
|
||||
assert!(maximum.difference(minimum).is_err());
|
||||
assert_eq!(epoch.difference(epoch).unwrap().millis(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_values_are_inline_and_clocks_round_trip() {
|
||||
assert_eq!(std::mem::size_of::<DateTime>(), 8);
|
||||
assert_eq!(std::mem::size_of::<Date>(), 8);
|
||||
assert_eq!(std::mem::size_of::<Time>(), 8);
|
||||
assert_eq!(std::mem::size_of::<Duration>(), 8);
|
||||
assert!(!std::mem::needs_drop::<DateTime>());
|
||||
let now = DateTime::now().unwrap();
|
||||
assert_eq!(DateTime::parse(&now.format().unwrap()).unwrap(), now);
|
||||
assert!(time::unix_millis().is_ok());
|
||||
let first = time::monotonic_nanos().unwrap();
|
||||
assert!(time::monotonic_nanos().unwrap() >= first);
|
||||
}
|
||||
|
||||
#[cfg(feature = "wire")]
|
||||
#[test]
|
||||
fn wire_uses_canonical_strings_and_integer_durations() {
|
||||
let value: DateTime = serde_json::from_str(r#""2024-02-29T12:34:56.12+08:00""#).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
r#""2024-02-29T04:34:56.120Z""#
|
||||
);
|
||||
let escaped: Date = serde_json::from_str(r#""\u0032024-02-29""#).unwrap();
|
||||
assert_eq!(serde_json::to_string(&escaped).unwrap(), r#""2024-02-29""#);
|
||||
let clock: Time = serde_json::from_str(r#""12:34:56.000""#).unwrap();
|
||||
assert_eq!(serde_json::to_string(&clock).unwrap(), r#""12:34:56""#);
|
||||
assert!(serde_json::from_str::<DateTime>("0").is_err());
|
||||
assert!(serde_json::from_str::<Duration>("1.0").is_err());
|
||||
assert!(serde_json::from_str::<Duration>(r#""1""#).is_err());
|
||||
let duration: Duration = serde_json::from_str("9223372036854775807").unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&duration).unwrap(),
|
||||
"9223372036854775807"
|
||||
);
|
||||
let far = DateTime::parse("1970-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.checked_add(duration)
|
||||
.unwrap();
|
||||
assert!(serde_json::to_string(&far).is_err());
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use dever_runtime::{
|
||||
channel::{Channel, Error},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
};
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn pending(future: std::pin::Pin<&mut impl Future>) {
|
||||
let mut future = future;
|
||||
poll_fn(|context| {
|
||||
assert!(future.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_is_bounded() {
|
||||
for capacity in [-1, 0, 65537] {
|
||||
assert!(matches!(
|
||||
Channel::<i64>::new(capacity),
|
||||
Err(Error::Capacity)
|
||||
));
|
||||
}
|
||||
Channel::<i64>::new(1).unwrap();
|
||||
Channel::<i64>::new(65536).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backpressure_preserves_fifo_and_nullable_items() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let channel = Channel::new(1).unwrap();
|
||||
channel.send(Some(1)).await.unwrap();
|
||||
let mut sent = std::pin::pin!(channel.send(None));
|
||||
pending(sent.as_mut()).await;
|
||||
assert_eq!(channel.receive().await, Ok(Some(Some(1))));
|
||||
sent.await.unwrap();
|
||||
let alias = channel.clone();
|
||||
alias.close().await.unwrap();
|
||||
channel.close().await.unwrap();
|
||||
assert_eq!(channel.receive().await, Ok(Some(None)));
|
||||
assert_eq!(alias.receive().await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_wakes_senders_and_all_receivers_without_discarding_queued_values() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let channel = Channel::new(1).unwrap();
|
||||
channel.send(1).await.unwrap();
|
||||
let mut sent = std::pin::pin!(channel.send(2));
|
||||
pending(sent.as_mut()).await;
|
||||
channel.close().await.unwrap();
|
||||
assert_eq!(sent.await, Err(Error::Closed));
|
||||
assert_eq!(channel.receive().await, Ok(Some(1)));
|
||||
assert_eq!(channel.receive().await, Ok(None));
|
||||
let empty = Channel::<i64>::new(1).unwrap();
|
||||
let mut first = std::pin::pin!(empty.receive());
|
||||
let mut second = std::pin::pin!(empty.receive());
|
||||
pending(first.as_mut()).await;
|
||||
pending(second.as_mut()).await;
|
||||
empty.close().await.unwrap();
|
||||
assert_eq!(first.await, Ok(None));
|
||||
assert_eq!(second.await, Ok(None));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_waiters_returns_reserved_capacity_and_items() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let channel = Channel::new(1).unwrap();
|
||||
channel.send(1).await.unwrap();
|
||||
{
|
||||
let mut sent = std::pin::pin!(channel.send(2));
|
||||
pending(sent.as_mut()).await;
|
||||
assert_eq!(channel.receive().await, Ok(Some(1)));
|
||||
}
|
||||
channel.send(3).await.unwrap();
|
||||
assert_eq!(channel.receive().await, Ok(Some(3)));
|
||||
{
|
||||
let mut received = std::pin::pin!(channel.receive());
|
||||
pending(received.as_mut()).await;
|
||||
channel.send(4).await.unwrap();
|
||||
}
|
||||
assert_eq!(channel.receive().await, Ok(Some(4)));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producers_and_consumers_transfer_every_item_once() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let channel = Channel::new(4).unwrap();
|
||||
let mut producers = Vec::new();
|
||||
let mut consumers = Vec::new();
|
||||
for start in [0, 64] {
|
||||
let sender = channel.clone();
|
||||
producers.push(
|
||||
scope
|
||||
.spawn_leaf(move || async move {
|
||||
for value in start..start + 64 {
|
||||
sender.send(value).await.unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
let channel = channel.clone();
|
||||
consumers.push(
|
||||
scope
|
||||
.spawn_leaf(move || async move {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = channel.receive().await.unwrap() {
|
||||
values.push(value);
|
||||
}
|
||||
values
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for task in producers {
|
||||
task.wait().await.unwrap();
|
||||
}
|
||||
channel.close().await.unwrap();
|
||||
let mut received = Vec::new();
|
||||
for task in consumers {
|
||||
received.extend(task.wait().await.unwrap());
|
||||
}
|
||||
received.sort();
|
||||
assert_eq!(received, (0..128).collect::<Vec<_>>());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct Notice(Arc<AtomicUsize>);
|
||||
impl Drop for Notice {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_alias_releases_nonclone_messages_once() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
let channel = Channel::new(1).unwrap();
|
||||
channel.send(Notice(drops.clone())).await.unwrap();
|
||||
let alias = channel.clone();
|
||||
drop(channel);
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 0);
|
||||
drop(alias);
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use dever_runtime::{crypto, value::secret::Secret};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_debug_is_redacted_and_token_bounds_are_fixed() {
|
||||
let secret = Secret::from_input(b"test-only-sensitive-input".to_vec());
|
||||
assert_eq!(format!("{secret:?}"), "Secret([REDACTED])");
|
||||
assert_eq!(format!("{:?}", secret.clone()), "Secret([REDACTED])");
|
||||
for length in [-1, 0, 15, 1025] {
|
||||
assert!(crypto::token(length).is_err());
|
||||
}
|
||||
assert!(crypto::token(16).is_ok());
|
||||
assert!(crypto::token(1024).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_and_hmac_match_known_vectors() {
|
||||
assert_eq!(
|
||||
hex(&crypto::sha256(b"abc")),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
let key = Secret::from_input(vec![0x0b; 20]);
|
||||
assert_eq!(
|
||||
hex(&crypto::hmac_sha256(&key, b"Hi There")),
|
||||
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_comparison_has_the_declared_value_semantics() {
|
||||
assert!(crypto::constant_time_eq(b"", b""));
|
||||
assert!(crypto::constant_time_eq(b"same", b"same"));
|
||||
assert!(!crypto::constant_time_eq(b"same", b"sXme"));
|
||||
assert!(!crypto::constant_time_eq(b"same", b"sam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_use_the_existing_profile_and_distinguish_mismatch_from_invalid_hash() {
|
||||
let password = Secret::from_input(b"test-only-password".to_vec());
|
||||
let wrong = Secret::from_input(b"other-test-password".to_vec());
|
||||
let encoded = crypto::password_hash(&password).unwrap();
|
||||
assert!(encoded.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
|
||||
assert_eq!(crypto::password_verify(&password, &encoded), Ok(true));
|
||||
assert_eq!(
|
||||
crypto::password_verify(&password.clone(), &encoded),
|
||||
Ok(true)
|
||||
);
|
||||
assert_eq!(crypto::password_verify(&wrong, &encoded), Ok(false));
|
||||
assert!(crypto::password_verify(&password, "not a hash").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_phc_costs_are_rejected_before_parameter_arithmetic_or_hashing() {
|
||||
let password = Secret::from_input(b"test-only-password".to_vec());
|
||||
let encoded =
|
||||
"$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
for invalid in [
|
||||
encoded.replace("m=19456", "m=19457"),
|
||||
encoded.replace("t=2", "t=3"),
|
||||
encoded.replace("p=1", "p=2"),
|
||||
encoded.replace("p=1", "p=536870912"),
|
||||
encoded.replace("p=1", "p=0"),
|
||||
encoded.replace("p=1", "p=invalid"),
|
||||
encoded.replace("$v=19$", "$v=16$"),
|
||||
encoded.replace("argon2id", "argon2i"),
|
||||
encoded.replace("p=1", "p=1,data=YQ"),
|
||||
encoded.replace("p=1", "p=1,keyid=YQ"),
|
||||
"x".repeat(257),
|
||||
] {
|
||||
assert!(
|
||||
crypto::password_verify(&password, &invalid).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use dever_runtime::{
|
||||
adapter::Document,
|
||||
database::{Database, Error, Kind, Options, Parameter, Settings},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
mod performance;
|
||||
mod scalar;
|
||||
mod schema;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-database-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options {
|
||||
driver: "sqlite".into(),
|
||||
path: "data/case.db".into(),
|
||||
url: None,
|
||||
tls: None,
|
||||
tenant_directory: None,
|
||||
tenant_database_prefix: None,
|
||||
min_connections: 1,
|
||||
max_connections: 1,
|
||||
max_page_size: 20,
|
||||
wait_timeout_ms: 100,
|
||||
io_timeout_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const CREATE: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);";
|
||||
|
||||
#[test]
|
||||
fn pool_and_checked_out_transaction_drop_outside_runtime_in_any_order() {
|
||||
for order in 0..3 {
|
||||
let directory = Directory::new();
|
||||
let runtime = runtime();
|
||||
let (database, executor) = runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES ('uncommitted')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(database, executor)
|
||||
})
|
||||
.unwrap();
|
||||
match order {
|
||||
0 => {
|
||||
drop(executor);
|
||||
database.close();
|
||||
drop(database);
|
||||
drop(runtime);
|
||||
}
|
||||
1 => {
|
||||
drop(database);
|
||||
drop(executor);
|
||||
drop(runtime);
|
||||
}
|
||||
_ => {
|
||||
drop(runtime);
|
||||
drop(database);
|
||||
drop(executor);
|
||||
}
|
||||
}
|
||||
self::runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let count = database
|
||||
.executor()
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [0]);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_commit_rollback_and_typed_rows() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
assert!(owner);
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text("Dever 中文".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let nested = executor.begin(scope).await.unwrap();
|
||||
assert!(!nested);
|
||||
executor.finish(scope, nested, true).await.unwrap();
|
||||
executor.finish(scope, owner, true).await.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text("rollback".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
let rows = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id, name FROM account ORDER BY id",
|
||||
vec![],
|
||||
20,
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, "Dever 中文".into())]);
|
||||
drop(executor);
|
||||
drop(database.executor().await.unwrap());
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_failure_prevents_outer_commit_and_preserves_pool() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let outer = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES ('temporary')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let inner = executor.begin(scope).await.unwrap();
|
||||
executor.finish(scope, inner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
executor.finish(scope, outer, true).await.unwrap_err().kind,
|
||||
Kind::Database
|
||||
);
|
||||
drop(executor);
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = executor
|
||||
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_errors_and_result_bounds_are_real_database_checks() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let input = "'); DROP TABLE account; --";
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text(input.into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text(input.into())],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, Kind::Constraint);
|
||||
assert!(!error.to_string().contains(input));
|
||||
let value = executor
|
||||
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, vec![input]);
|
||||
let invalid = executor
|
||||
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(invalid.kind, Kind::InvalidData);
|
||||
let overflow = executor
|
||||
.query(scope, "SELECT 1 UNION ALL SELECT 2", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(overflow.kind, Kind::InvalidData);
|
||||
let exhausted = database.executor().await.err().unwrap();
|
||||
assert_eq!(exhausted.kind, Kind::PoolExhausted);
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_discards_transaction_before_next_borrow() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let mut config = options();
|
||||
config.io_timeout_ms = 50;
|
||||
let database = Database::open(&directory.0, config, scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
executor.begin(scope).await.unwrap();
|
||||
executor.execute(scope, "INSERT INTO account(name) VALUES ('must rollback')", vec![]).await.unwrap();
|
||||
let result = executor.query(scope, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await;
|
||||
assert_eq!(result.unwrap_err().kind, Kind::Timeout);
|
||||
drop(executor);
|
||||
let mut replacement = database.executor().await.unwrap();
|
||||
let count = replacement.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_and_rollback_cause_do_not_expose_secrets() {
|
||||
for tenant in ["{}", "null"] {
|
||||
let document = Document::parse(format!(r#"{{"tenant":{tenant},"database":{{"default":{{"type":"sqlite","path":"data/app.db"}}}}}}"#)).unwrap();
|
||||
assert!(Settings::load(&document).is_err());
|
||||
}
|
||||
let settings = Settings::load(
|
||||
&Document::parse(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db"}}}"#.into(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.select("demo", None).unwrap().max_connections, 1);
|
||||
assert!(settings.select("demo", Some("missing")).is_err());
|
||||
for path in ["/tmp/escape", "../escape", "data/../escape", ""] {
|
||||
let mut value = options();
|
||||
value.path = path.into();
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
let error = Error::new(Kind::Constraint, "business failure")
|
||||
.with_cause(Error::new(Kind::Connection, "rollback failed"))
|
||||
.with_cause(Error::new(Kind::Pool, "pool close failed"));
|
||||
assert_eq!(error.kind, Kind::Constraint);
|
||||
assert!(error.to_string().contains("rollback failed"));
|
||||
assert!(error.to_string().contains("pool close failed"));
|
||||
let rollback = error.cause.as_ref().unwrap();
|
||||
assert_eq!(rollback.kind, Kind::Connection);
|
||||
assert_eq!(rollback.cause.as_ref().unwrap().kind, Kind::Pool);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_drift_fails_before_touching_data_or_history() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const SCHEMA: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL); CREATE INDEX account_name ON account(name);";
|
||||
executor.schema(scope, "account", SCHEMA).await.unwrap();
|
||||
executor.schema(scope, "account", SCHEMA).await.unwrap();
|
||||
executor.execute(scope, "INSERT INTO account(name) VALUES ('preserved')", vec![]).await.unwrap();
|
||||
executor.initialize(scope, "DROP INDEX account_name;").await.unwrap();
|
||||
assert_eq!(executor.schema(scope, "account", SCHEMA).await.unwrap_err().kind, Kind::Migration);
|
||||
let values = executor.query(scope, "SELECT name FROM account", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap();
|
||||
assert_eq!(values, vec!["preserved"]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_running_query_rolls_back_and_releases_its_worker() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut setup = database.executor().await.unwrap();
|
||||
setup.initialize(scope, CREATE).await.unwrap();
|
||||
drop(setup);
|
||||
let copy = database.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = scope.spawn(move |child| async move {
|
||||
let mut executor = copy.executor().await.unwrap();
|
||||
executor.begin(&child).await.unwrap();
|
||||
executor.execute(&child, "INSERT INTO account(name) VALUES ('cancelled')", vec![]).await.unwrap();
|
||||
started.send(()).unwrap();
|
||||
executor.query(&child, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await
|
||||
}).unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(task.stop().await.unwrap().is_none());
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = executor.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! PostgreSQL 文档规定的 NUMERIC 分组字节;预期值独立于编码实现。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numeric_encoding_matches_protocol_words() {
|
||||
for (text, precision, scale, words) in [
|
||||
("0", 5, 2, vec![0, 0, 0, 2]),
|
||||
("-12.34", 5, 2, vec![2, 0, 0x4000, 2, 12, 3400]),
|
||||
("0.00001", 5, 5, vec![1, -2, 0, 5, 1000]),
|
||||
("10000", 5, 0, vec![1, 1, 0, 0, 1]),
|
||||
("12345678.9012", 12, 4, vec![3, 1, 0, 4, 1234, 5678, 9012]),
|
||||
(
|
||||
"9999999999999999999999999999999999",
|
||||
34,
|
||||
0,
|
||||
vec![
|
||||
9, 8, 0, 0, 99, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
|
||||
],
|
||||
),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let expected = words
|
||||
.into_iter()
|
||||
.flat_map(i16::to_be_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
let mut output = BytesMut::new();
|
||||
encode(value, precision, scale, &mut output).unwrap();
|
||||
assert_eq!(output.as_ref(), expected, "{text}");
|
||||
assert_eq!(decode(&output).unwrap(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_numeric_preserves_decimal_extremes_and_zero() {
|
||||
for text in [
|
||||
"0",
|
||||
"-0",
|
||||
"0E-6176",
|
||||
"1E-6176",
|
||||
"1E6111",
|
||||
"-123.456",
|
||||
"1.0000",
|
||||
"9.999999999999999999999999999999999E6144",
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let mut output = BytesMut::new();
|
||||
encode_native(value, &mut output);
|
||||
assert_eq!(decode(&output).unwrap(), value, "{text}");
|
||||
}
|
||||
for (text, precision, scale) in [("1.001", 5, 2), ("1000", 5, 2), ("1", 35, 0), ("1", 2, 3)] {
|
||||
assert!(
|
||||
encode(
|
||||
Decimal::parse(text).unwrap(),
|
||||
precision,
|
||||
scale,
|
||||
&mut BytesMut::new()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_decode_rejects_malformed_or_inexact_values() {
|
||||
for words in [
|
||||
vec![1, 0, 0, 0],
|
||||
vec![-1, 0, 0, 0],
|
||||
vec![1, 0, 0, 0, 10000],
|
||||
vec![0, 0, 0xc000_u16 as i16, 0],
|
||||
vec![0, 0, 0, -1],
|
||||
vec![1, 1537, 0, 0, 1],
|
||||
vec![1, -1545, 0, 0, 1],
|
||||
vec![
|
||||
9, 8, 0, 0, 999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
|
||||
],
|
||||
] {
|
||||
let bytes = words
|
||||
.into_iter()
|
||||
.flat_map(i16::to_be_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(decode(&bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn numeric_decode_cost() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, text) in [
|
||||
("zero", "0"),
|
||||
("fraction", "-1234567890123456789012345.123456789"),
|
||||
("exponent", "9.999999999999999999999999999999999E6144"),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let mut encoded = BytesMut::new();
|
||||
encode_native(value, &mut encoded);
|
||||
let expected = decode(&encoded).unwrap();
|
||||
assert_eq!(expected, value);
|
||||
for _ in 0..100 {
|
||||
black_box(decode(&encoded).unwrap());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut decoded = Decimal::ZERO;
|
||||
for _ in 0..100_000 {
|
||||
decoded = black_box(decode(black_box(&encoded)).unwrap());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(decoded, expected);
|
||||
let checksum: u64 = decoded
|
||||
.to_be_bytes()
|
||||
.iter()
|
||||
.map(|byte| u64::from(*byte))
|
||||
.sum();
|
||||
println!("numeric_decode_{name},{sample},100000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
fn json_constraint_keeps_validation_without_constructing_a_value() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let sources = [
|
||||
"null".to_owned(),
|
||||
"1e9999".into(),
|
||||
r#"{"name":"雪\n🙂","values":[1,true]}"#.into(),
|
||||
r#"{"a":1,"a":2}"#.into(),
|
||||
r#""\ud800""#.into(),
|
||||
"[1,]".into(),
|
||||
"null true".into(),
|
||||
"[".repeat(257) + &"]".repeat(257),
|
||||
format!("[{}0]", "0,".repeat(65535)),
|
||||
format!("\"{}\"", "x".repeat(16 * 1024 * 1024)),
|
||||
];
|
||||
for source in sources {
|
||||
let expected = dever_runtime::wire::json::Json::parse(&source).is_ok();
|
||||
let actual = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT _dever_json(?1)",
|
||||
vec![Parameter::Text(source)],
|
||||
1,
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actual, [expected]);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release SQL validation performance probe"]
|
||||
fn sqlite_json_check_cost() {
|
||||
let directory = Directory::new();
|
||||
let runtime = runtime();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE documents (body TEXT NOT NULL)")
|
||||
.await
|
||||
.unwrap();
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (bytes, iterations) in [(64, 100), (4096, 100), (65536, 10)] {
|
||||
executor
|
||||
.execute(scope, "DELETE FROM documents", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let source = format!("\"{}\"", "x".repeat(bytes - 2));
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"WITH RECURSIVE rows(id) AS (SELECT 1 UNION ALL SELECT id+1 FROM rows WHERE id<256) INSERT INTO documents SELECT ?1 FROM rows",
|
||||
vec![Parameter::Text(source)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// 实际列逐行调用约束;避免常量折叠只验证一次参数。
|
||||
for sample in 0..16 {
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0i64;
|
||||
for _ in 0..iterations {
|
||||
let result = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT SUM(_dever_json(body)) FROM documents",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
checksum += black_box(result[0]);
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
assert_eq!(checksum, iterations * 256);
|
||||
if sample != 0 {
|
||||
println!("sqlite_json_check_{bytes},{},{iterations},{elapsed},{checksum}", sample - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use super::*;
|
||||
use crate::database::{Error, Options, Parameter};
|
||||
use futures_util::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use tokio_postgres::Client;
|
||||
|
||||
mod cases;
|
||||
mod codec;
|
||||
mod constraints;
|
||||
#[cfg(feature = "net")]
|
||||
mod registry;
|
||||
|
||||
const ID: Column = Column {
|
||||
constraints: &[("account_pkey", "p", "PRIMARY KEY (\"id\")")],
|
||||
..column("id", "bigint")
|
||||
};
|
||||
const NAME: Column = Column {
|
||||
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"name\")")],
|
||||
..column("name", "text")
|
||||
};
|
||||
const TITLE: Column = Column {
|
||||
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"title\")")],
|
||||
..column("title", "text")
|
||||
};
|
||||
const UID: Column = Column {
|
||||
generated: true,
|
||||
constraints: &[("_dever_unique_account_3", "u", "UNIQUE (\"uid\")")],
|
||||
..column("uid", "uuid")
|
||||
};
|
||||
|
||||
const INITIAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, legacy bigint NOT NULL DEFAULT 4, amount numeric(6,2) NOT NULL DEFAULT 1.25);";
|
||||
const FINAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE);";
|
||||
const INDEX: &str = "CREATE INDEX account_title ON account(title);";
|
||||
const HISTORICAL: Migration = Migration {
|
||||
name: "historical",
|
||||
revision: "historical-one",
|
||||
drops: &["ancient"],
|
||||
before: &[statement("UPDATE account SET ancient = 1")],
|
||||
after: &[statement("UPDATE account SET ancient = 2")],
|
||||
};
|
||||
const UPGRADE: Migration = Migration {
|
||||
name: "rename",
|
||||
revision: "rename-one",
|
||||
drops: &["legacy"],
|
||||
before: &[statement("UPDATE account SET name = name || '_before'")],
|
||||
after: &[statement("UPDATE account SET title = title || '_after'")],
|
||||
};
|
||||
const INITIAL_PLAN: Plan = Plan {
|
||||
create: Sql::same(INITIAL),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
NAME,
|
||||
Column {
|
||||
default: Some("4"),
|
||||
..column("legacy", "bigint")
|
||||
},
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(6,2)")
|
||||
},
|
||||
],
|
||||
migrations: &[HISTORICAL],
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO account(name) VALUES ($1) ON CONFLICT(name) DO NOTHING"),
|
||||
parameters: || Ok(vec![Parameter::Text("seed".into())]),
|
||||
}],
|
||||
seed_revision: "initial-seed",
|
||||
..EMPTY
|
||||
};
|
||||
const FINAL_PLAN: Plan = Plan {
|
||||
create: Sql::same(FINAL),
|
||||
indexes: Sql::same(INDEX),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
TITLE,
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(8,3)")
|
||||
},
|
||||
UID,
|
||||
],
|
||||
migrations: &[HISTORICAL, UPGRADE],
|
||||
renames: &[("title", "name")],
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same(
|
||||
"INSERT INTO account(title,uid) VALUES ($1,$2::uuid) ON CONFLICT(title) DO NOTHING",
|
||||
),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("new".into()),
|
||||
Parameter::Uuid(*crate::database::scalar::generated_uuid()?.as_bytes()),
|
||||
])
|
||||
},
|
||||
}],
|
||||
seed_revision: "final-seed",
|
||||
};
|
||||
|
||||
const fn statement(sql: &'static str) -> Statement {
|
||||
Statement {
|
||||
sql: Sql::same(sql),
|
||||
parameters: || Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn schema_migration_seed_uuid_and_rollback() {
|
||||
fixture(cases::migrations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn catalog_drift_fails_before_data_or_history_mutation() {
|
||||
fixture(cases::drift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn new_database_records_final_history_without_old_transforms() {
|
||||
fixture(cases::fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn native_binary_codecs_validate_precision_and_parameter_types() {
|
||||
fixture(codec::roundtrip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn foreign_keys_and_tightening_validate_existing_data() {
|
||||
fixture(constraints::foreign_keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn numeric_migrations_reject_precision_loss() {
|
||||
fixture(constraints::numeric);
|
||||
}
|
||||
|
||||
fn configuration() -> serde_json::Value {
|
||||
let path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
|
||||
let document =
|
||||
crate::adapter::Document::load(&path).expect("cannot read PostgreSQL test settings");
|
||||
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
|
||||
root.get("database")
|
||||
.and_then(|database| database.get("postgres_test"))
|
||||
.expect("database.postgres_test must explicitly select an isolated test database")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
let options: Options = serde_json::from_value(configuration())
|
||||
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
|
||||
assert_eq!(options.driver, "postgres");
|
||||
options
|
||||
}
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&mut Client)) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(isolated(options(), check));
|
||||
}
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
fn registry_fixture(check: impl AsyncFnOnce(&mut Client, &crate::task::Scope<'_>)) {
|
||||
let runtime = crate::task::Runtime::new(crate::task::Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| isolated(options(), async |client| check(client, scope).await).await)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn isolated(options: Options, check: impl AsyncFnOnce(&mut Client)) {
|
||||
let database = super::super::Database::open(options).await.unwrap();
|
||||
let mut client = database
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("cannot acquire PostgreSQL test connection"));
|
||||
let name = format!(
|
||||
"dever_test_{}",
|
||||
crate::database::scalar::generated_uuid()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.replace('-', "")
|
||||
);
|
||||
let quoted = schema::quote(&name);
|
||||
client
|
||||
.batch_execute(&format!("CREATE SCHEMA {quoted}"))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
.unwrap();
|
||||
let result = AssertUnwindSafe(async {
|
||||
client
|
||||
.batch_execute(&format!("SET search_path TO {quoted}"))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), check(&mut client))
|
||||
.await
|
||||
.expect("PostgreSQL test deadline exceeded");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let cleanup = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.batch_execute(&format!(
|
||||
"ROLLBACK; SET search_path TO pg_catalog; DROP SCHEMA {quoted} CASCADE"
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
database.close();
|
||||
cleanup
|
||||
.expect("PostgreSQL test cleanup timed out")
|
||||
.map_err(Error::from)
|
||||
.expect("PostgreSQL test schema cleanup failed");
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn fresh(client: &mut Client) {
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let row = client
|
||||
.query_one("SELECT title,uid::text FROM account", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<_, String>(0), "new");
|
||||
assert_eq!(row.get::<_, String>(1).as_bytes()[14], b'7');
|
||||
assert_eq!(
|
||||
count(client, "SELECT count(*) FROM _dever_migration").await,
|
||||
2
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM _dever_seed").await, 1);
|
||||
}
|
||||
|
||||
pub(super) async fn migrations(client: &mut Client) {
|
||||
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client
|
||||
.execute("UPDATE account SET name='business'", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client.batch_execute("INSERT INTO account(name) VALUES ('removed-high-id'); DELETE FROM account WHERE name='removed-high-id'").await.unwrap();
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO account(name) SELECT 'row_' || value FROM generate_series(1,257) AS value",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id,title,amount::text,uid::text FROM account ORDER BY id",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 259);
|
||||
assert_eq!(rows[0].get::<_, String>(1), "business_before_after");
|
||||
assert_eq!(rows[0].get::<_, String>(2), "1.250");
|
||||
assert!(rows.last().unwrap().get::<_, i64>(0) > 259);
|
||||
let mut generated = std::collections::BTreeSet::new();
|
||||
for row in &rows {
|
||||
let uuid: String = row.get(3);
|
||||
assert_eq!(uuid.as_bytes()[14], b'7');
|
||||
assert!(generated.insert(uuid));
|
||||
}
|
||||
let first: String = rows[0].get(3);
|
||||
let second: String = rows[1].get(3);
|
||||
assert_eq!(first.as_bytes()[14], b'7');
|
||||
assert_eq!(second.as_bytes()[14], b'7');
|
||||
assert_ne!(first, second);
|
||||
let removed = Plan {
|
||||
migrations: &[HISTORICAL],
|
||||
..FINAL_PLAN
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&removed))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let changed = const {
|
||||
Plan {
|
||||
migrations: &[HISTORICAL, step("rename", "changed", &["legacy"])],
|
||||
..FINAL_PLAN
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&changed))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
|
||||
const FAILED: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE, note text);";
|
||||
let failed = const {
|
||||
Plan {
|
||||
create: Sql::same(FAILED),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
TITLE,
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(8,3)")
|
||||
},
|
||||
UID,
|
||||
Column {
|
||||
nullable: true,
|
||||
..column("note", "text")
|
||||
},
|
||||
],
|
||||
migrations: &[
|
||||
HISTORICAL,
|
||||
UPGRADE,
|
||||
Migration {
|
||||
name: "fail",
|
||||
revision: "fail-one",
|
||||
drops: &[],
|
||||
before: &[statement("UPDATE account SET title=title || '_rollback'")],
|
||||
after: &[statement("UPDATE account SET title=NULL")],
|
||||
},
|
||||
],
|
||||
..FINAL_PLAN
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(
|
||||
client,
|
||||
"account",
|
||||
&format!("{FAILED}{INDEX}"),
|
||||
Some(&failed)
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM pg_attribute WHERE attrelid='account'::regclass AND attname='note' AND NOT attisdropped").await, 0);
|
||||
assert_eq!(
|
||||
count(client, "SELECT count(*) FROM _dever_migration").await,
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT title FROM account WHERE id=1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"business_before_after"
|
||||
);
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) async fn drift(client: &mut Client) {
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let original: String = client
|
||||
.query_one(
|
||||
"SELECT catalog FROM _dever_history WHERE name='account'",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.get(0);
|
||||
for (change, restore) in [
|
||||
("DROP INDEX account_title", INDEX),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 2.25",
|
||||
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 1.25",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN title DROP NOT NULL",
|
||||
"ALTER TABLE account ALTER COLUMN title SET NOT NULL",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ADD CONSTRAINT _dever_manual CHECK (amount>0)",
|
||||
"ALTER TABLE account DROP CONSTRAINT _dever_manual",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 2",
|
||||
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 1",
|
||||
),
|
||||
] {
|
||||
client.batch_execute(change).await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one(
|
||||
"SELECT catalog FROM _dever_history WHERE name='account'",
|
||||
&[]
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
original
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client.batch_execute(restore).await.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
client.batch_execute("DROP TABLE account").await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
async fn count(client: &Client, sql: &str) -> i64 {
|
||||
client.query_one(sql, &[]).await.unwrap().get(0)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::*;
|
||||
use crate::{database::Row, value::decimal::Decimal};
|
||||
|
||||
pub(super) async fn roundtrip(client: &mut Client) {
|
||||
for text in [
|
||||
"0",
|
||||
"-0.0001",
|
||||
"12345.6789",
|
||||
"9.999999999999999999999999999999999E+6144",
|
||||
"1E-6176",
|
||||
] {
|
||||
let expected = Decimal::parse(text).unwrap();
|
||||
let parameter = Parameter::NativeDecimal(expected);
|
||||
let row = client
|
||||
.query_one("SELECT $1::numeric", &[¶meter])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(Row::Postgres(&row).native_decimal(0).unwrap(), expected);
|
||||
}
|
||||
let uuid = crate::database::scalar::generated_uuid().unwrap();
|
||||
let integer = Decimal::parse("9999999999999999999999999999999999").unwrap();
|
||||
let fraction = Decimal::parse("-0.9999999999999999999999999999999999").unwrap();
|
||||
let parameters = [
|
||||
Parameter::Bool(true),
|
||||
Parameter::Integer(i64::MIN),
|
||||
Parameter::Real(1.25),
|
||||
Parameter::Uuid(*uuid.as_bytes()),
|
||||
Parameter::Decimal {
|
||||
value: integer,
|
||||
precision: 34,
|
||||
scale: 0,
|
||||
},
|
||||
Parameter::Decimal {
|
||||
value: fraction,
|
||||
precision: 34,
|
||||
scale: 34,
|
||||
},
|
||||
Parameter::Null,
|
||||
Parameter::Text("雪'\\native".into()),
|
||||
Parameter::Blob(vec![0, 128, 255]),
|
||||
];
|
||||
let references = parameters
|
||||
.iter()
|
||||
.map(|value| value as &(dyn tokio_postgres::types::ToSql + Sync))
|
||||
.collect::<Vec<_>>();
|
||||
let result = client.query_one("SELECT $1::boolean,$2::bigint,$3::float8,$4::uuid,$5::numeric(34,0),$6::numeric(34,34),$7::uuid,$8::text,$9::bytea", &references).await.unwrap();
|
||||
let row = Row::Postgres(&result);
|
||||
assert!(row.get::<_, bool>(0).unwrap());
|
||||
assert_eq!(row.get::<_, i64>(1).unwrap(), i64::MIN);
|
||||
assert_eq!(row.get::<_, f64>(2).unwrap(), 1.25);
|
||||
assert_eq!(row.uuid(3).unwrap(), *uuid.as_bytes());
|
||||
assert_eq!(row.decimal(4, 34, 0).unwrap(), integer);
|
||||
assert_eq!(row.decimal(5, 34, 34).unwrap(), fraction);
|
||||
assert!(row.is_null(6).unwrap());
|
||||
assert_eq!(row.text(7).unwrap(), "雪'\\native");
|
||||
assert_eq!(row.bytes(8).unwrap(), [0, 128, 255]);
|
||||
for value in [
|
||||
"0",
|
||||
"0.0000000000000000000000000000000001",
|
||||
"0.1234000000000000000000000000000000",
|
||||
] {
|
||||
let expected = Decimal::parse(value).unwrap();
|
||||
let parameter = Parameter::Decimal {
|
||||
value: expected,
|
||||
precision: 34,
|
||||
scale: 34,
|
||||
};
|
||||
let result = client
|
||||
.query_one("SELECT $1::numeric(34,34)", &[¶meter])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(Row::Postgres(&result).decimal(0, 34, 34).unwrap(), expected);
|
||||
}
|
||||
for (sql, parameter) in [
|
||||
("SELECT $1::integer", Parameter::Integer(1)),
|
||||
("SELECT $1::text::uuid", Parameter::Uuid(*uuid.as_bytes())),
|
||||
("SELECT $1::float8", Parameter::Real(f64::INFINITY)),
|
||||
("SELECT $1::float8", Parameter::Real(f64::NAN)),
|
||||
(
|
||||
"SELECT $1::numeric(5,2)",
|
||||
Parameter::Decimal {
|
||||
value: Decimal::parse("0.001").unwrap(),
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
"SELECT $1::numeric(5,2)",
|
||||
Parameter::Decimal {
|
||||
value: Decimal::parse("1000").unwrap(),
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert!(client.query_one(sql, &[¶meter]).await.is_err(), "{sql}");
|
||||
}
|
||||
let invalid = client
|
||||
.query_one("SELECT 0.001::numeric,1000::numeric,'NaN'::numeric", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
for position in 0..3 {
|
||||
assert_eq!(
|
||||
Row::Postgres(&invalid)
|
||||
.decimal(position, 5, 2)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
let result = client
|
||||
.query_one(
|
||||
"SELECT $1::bigint,$2::float8",
|
||||
&[&Parameter::Integer(i64::MAX), &Parameter::Real(-f64::MAX)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.get::<_, i64>(0), i64::MAX);
|
||||
assert_eq!(result.get::<_, f64>(1), -f64::MAX);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn foreign_keys(client: &mut Client) {
|
||||
const PARENT: &str = "CREATE TABLE parent(id bigint PRIMARY KEY);";
|
||||
schema::migrate(client, "parent", PARENT, None)
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "parent", PARENT, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(
|
||||
client,
|
||||
"parent",
|
||||
"CREATE TABLE parent(id bigint PRIMARY KEY, extra text);",
|
||||
None
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
client
|
||||
.execute("INSERT INTO parent VALUES (1)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
const START: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id));";
|
||||
const REFERENCE: Column = Column {
|
||||
constraints: &[(
|
||||
"_dever_fk_parent",
|
||||
"f",
|
||||
"FOREIGN KEY (parent_id) REFERENCES parent(id)",
|
||||
)],
|
||||
..column("parent_id", "bigint")
|
||||
};
|
||||
const START_PLAN: Plan = Plan {
|
||||
create: Sql::same(START),
|
||||
postgres_columns: &[ID, REFERENCE],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "child", START, Some(&START_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("INSERT INTO child(parent_id) VALUES (1)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
const NEXT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=4));";
|
||||
const NEXT_PLAN: Plan = Plan {
|
||||
create: Sql::same(NEXT),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
REFERENCE,
|
||||
Column {
|
||||
default: Some("'ok'"),
|
||||
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=4)")],
|
||||
..column("label", "text")
|
||||
},
|
||||
],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.execute("INSERT INTO child(parent_id) VALUES (2)", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.execute("UPDATE child SET label='too long'", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.execute("DELETE FROM parent WHERE id=1", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
const TIGHT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=1));";
|
||||
const TIGHT_PLAN: Plan = Plan {
|
||||
create: Sql::same(TIGHT),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
REFERENCE,
|
||||
Column {
|
||||
default: Some("'ok'"),
|
||||
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=1)")],
|
||||
..column("label", "text")
|
||||
},
|
||||
],
|
||||
..EMPTY
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "child", TIGHT, Some(&TIGHT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT label FROM child", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn numeric(client: &mut Client) {
|
||||
const INTEGER: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value bigint NOT NULL);";
|
||||
const FLOAT: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value double precision NOT NULL);";
|
||||
const INTEGER_PLAN: Plan = Plan {
|
||||
create: Sql::same(INTEGER),
|
||||
postgres_columns: &[ID, column("value", "bigint")],
|
||||
..EMPTY
|
||||
};
|
||||
const FLOAT_PLAN: Plan = Plan {
|
||||
create: Sql::same(FLOAT),
|
||||
postgres_columns: &[ID, column("value", "double precision")],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "numbers", INTEGER, Some(&INTEGER_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO numbers(value) VALUES (9007199254740993), (9223372036854775807)",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
9007199254740993
|
||||
);
|
||||
client.batch_execute("DELETE FROM numbers; INSERT INTO numbers(value) VALUES (9007199254740992), (-9223372036854775808), (9223372036854774784)").await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
9007199254740992
|
||||
);
|
||||
|
||||
const WIDE: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(8,3) NOT NULL);";
|
||||
const NARROW: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(5,2) NOT NULL);";
|
||||
const WIDE_PLAN: Plan = Plan {
|
||||
create: Sql::same(WIDE),
|
||||
postgres_columns: &[ID, column("value", "numeric(8,3)")],
|
||||
..EMPTY
|
||||
};
|
||||
const NARROW_PLAN: Plan = Plan {
|
||||
create: Sql::same(NARROW),
|
||||
postgres_columns: &[ID, column("value", "numeric(5,2)")],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("INSERT INTO decimal_numbers(value) VALUES (1.251)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
client
|
||||
.execute("UPDATE decimal_numbers SET value=1000", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("UPDATE decimal_numbers SET value=1.250", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value::text FROM decimal_numbers", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"1.25"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use super::*;
|
||||
use crate::database::{Binding, Context, Registry};
|
||||
use std::{io::Write, os::unix::fs::OpenOptionsExt, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release pagination performance probe with isolated PostgreSQL"]
|
||||
fn postgres_page_cost() {
|
||||
use crate::database::{Database, PageQuery};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = options();
|
||||
let timeout = settings.io_timeout();
|
||||
let database = Database::open(std::path::Path::new("."), settings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = "SELECT 1::bigint WHERE $1::text IS NOT NULL";
|
||||
let select = "SELECT octet_length($1::text)::bigint LIMIT $2::bigint OFFSET $3::bigint";
|
||||
let count_statement = client.prepare(count).await.unwrap();
|
||||
let select_statement = client.prepare(select).await.unwrap();
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for bytes in [64, 65536, 1048576] {
|
||||
let source = "x".repeat(bytes);
|
||||
for sample in 0..16 {
|
||||
for offset in 0..2 {
|
||||
let generated = (sample + offset) % 2 == 0;
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..20 {
|
||||
let payload = black_box(source.clone());
|
||||
let value = if generated {
|
||||
let page = executor
|
||||
.page(
|
||||
scope,
|
||||
PageQuery {
|
||||
count: Sql::same(count),
|
||||
select: Sql::same(select),
|
||||
parameters: vec![Parameter::Text(payload)],
|
||||
page: 1,
|
||||
size: 1,
|
||||
},
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((page.total, page.pages, page.items.len()), (1, 1, 1));
|
||||
page.items[0]
|
||||
} else {
|
||||
// 原生驱动同样执行两个有独立期限的查询;载荷只持有一份。
|
||||
let total = tokio::time::timeout(
|
||||
timeout,
|
||||
client.query_one(&count_statement, &[&payload]),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(total.get::<_, i64>(0), 1);
|
||||
tokio::time::timeout(
|
||||
timeout,
|
||||
client.query_one(&select_statement, &[&payload, &1i64, &0i64]),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get::<_, i64>(0)
|
||||
};
|
||||
assert_eq!(value, bytes as i64);
|
||||
checksum += black_box(value);
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
if sample != 0 {
|
||||
let implementation = if generated { "dever" } else { "rust" };
|
||||
println!(
|
||||
"postgres_page_{bytes},{},{implementation},20,{elapsed},{checksum}",
|
||||
sample - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
database.close();
|
||||
});
|
||||
}
|
||||
|
||||
const fn binding(
|
||||
table: &'static str,
|
||||
sql: &'static str,
|
||||
database: Option<&'static str>,
|
||||
references: &'static [usize],
|
||||
) -> Binding {
|
||||
Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database,
|
||||
table,
|
||||
schema: Sql::same(sql),
|
||||
plan: None,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn first_create_orders_parent_before_alphabetically_earlier_child() {
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = SettingsFile::new(client).await;
|
||||
let bindings = [
|
||||
binding(
|
||||
"a_child",
|
||||
"CREATE TABLE a_child(id bigint PRIMARY KEY, parent_id bigint NOT NULL REFERENCES z_parent(id));",
|
||||
None,
|
||||
&[2],
|
||||
),
|
||||
binding(
|
||||
"m_independent",
|
||||
"CREATE TABLE m_independent(id bigint PRIMARY KEY);",
|
||||
Some("secondary"),
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"z_parent",
|
||||
"CREATE TABLE z_parent(id bigint PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"self_node",
|
||||
"CREATE TABLE self_node(id bigint PRIMARY KEY, parent_id bigint REFERENCES self_node(id));",
|
||||
None,
|
||||
&[3],
|
||||
),
|
||||
];
|
||||
let registry = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.check_transactions(&[&[0, 2, 3]]).unwrap();
|
||||
assert_eq!(
|
||||
registry.check_transactions(&[&[0, 1]]).unwrap_err().kind,
|
||||
Kind::Connection
|
||||
);
|
||||
let mut context = Context::new(registry);
|
||||
context
|
||||
.on(2, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, Sql::same("INSERT INTO z_parent VALUES (42)"), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO a_child VALUES (1,42)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO m_independent VALUES (1)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(3, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO self_node VALUES (1,1)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(context);
|
||||
let repeated = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
repeated.check_transactions(&[&[0, 2]]).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT parent_id FROM a_child", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
42
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT count(*) FROM _dever_history", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
4
|
||||
);
|
||||
assert!(client.execute("DELETE FROM z_parent", &[]).await.is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn cyclic_postgres_bindings_fail_before_any_schema_or_history_ddl() {
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = SettingsFile::new(client).await;
|
||||
let bindings = [
|
||||
binding(
|
||||
"independent",
|
||||
"CREATE TABLE independent(id bigint PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"cycle_left",
|
||||
"CREATE TABLE cycle_left(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_right(id));",
|
||||
None,
|
||||
&[2],
|
||||
),
|
||||
binding(
|
||||
"cycle_right",
|
||||
"CREATE TABLE cycle_right(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_left(id));",
|
||||
None,
|
||||
&[1],
|
||||
),
|
||||
];
|
||||
let error = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind, Kind::Migration);
|
||||
assert_eq!(client.query_one("SELECT count(*) FROM pg_class WHERE relnamespace=current_schema()::regnamespace", &[]).await.unwrap().get::<_, i64>(0), 0);
|
||||
});
|
||||
}
|
||||
|
||||
struct SettingsFile(PathBuf);
|
||||
|
||||
impl SettingsFile {
|
||||
async fn new(client: &Client) -> Self {
|
||||
let schema: String = client
|
||||
.query_one("SELECT current_schema()", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get(0);
|
||||
let mut connection = configuration();
|
||||
let mut url = connection["url"].as_str().unwrap().to_owned();
|
||||
let parsed: tokio_postgres::Config = url.parse().unwrap();
|
||||
assert!(
|
||||
parsed.get_options().is_none(),
|
||||
"test connection cannot override search_path"
|
||||
);
|
||||
url.push(if url.contains('?') { '&' } else { '?' });
|
||||
url.push_str(&format!("options=-csearch_path%3D{schema}"));
|
||||
connection["url"] = serde_json::Value::String(url);
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"dever_registry_{}",
|
||||
crate::database::scalar::generated_uuid().unwrap()
|
||||
));
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let settings = Self(directory);
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(settings.path())
|
||||
.unwrap();
|
||||
let document =
|
||||
serde_json::json!({"database":{"default":connection,"secondary":connection}});
|
||||
file.write_all(&serde_json::to_vec(&document).unwrap())
|
||||
.unwrap();
|
||||
settings
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
self.0.join("setting.json")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SettingsFile {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use super::{alter, history, schema};
|
||||
use crate::database::{Column, Kind, Migration, Plan, Sql, Statement};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
mod live;
|
||||
mod registry;
|
||||
|
||||
const EMPTY: Plan = Plan {
|
||||
create: Sql::same(""),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seeds: &[],
|
||||
seed_revision: "empty",
|
||||
};
|
||||
|
||||
const fn column(name: &'static str, kind: &'static str) -> Column {
|
||||
Column {
|
||||
name,
|
||||
kind,
|
||||
default: None,
|
||||
nullable: false,
|
||||
generated: false,
|
||||
constraints: &[],
|
||||
}
|
||||
}
|
||||
|
||||
const fn step(
|
||||
name: &'static str,
|
||||
revision: &'static str,
|
||||
drops: &'static [&'static str],
|
||||
) -> Migration {
|
||||
Migration {
|
||||
name,
|
||||
revision,
|
||||
drops,
|
||||
before: &[],
|
||||
after: &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_history_is_an_immutable_contiguous_prefix() {
|
||||
let plan = const {
|
||||
Plan {
|
||||
migrations: &[step("first", "one", &[]), step("second", "two", &[])],
|
||||
..EMPTY
|
||||
}
|
||||
};
|
||||
assert_eq!(history::validate(&[], &plan).unwrap(), 0);
|
||||
assert_eq!(
|
||||
history::validate(&[(0, "first".into(), "one".into())], &plan).unwrap(),
|
||||
1
|
||||
);
|
||||
for records in [
|
||||
vec![(1, "first".into(), "one".into())],
|
||||
vec![(0, "second".into(), "two".into())],
|
||||
vec![(0, "first".into(), "modified".into())],
|
||||
vec![
|
||||
(0, "first".into(), "one".into()),
|
||||
(2, "second".into(), "two".into()),
|
||||
],
|
||||
] {
|
||||
assert_eq!(
|
||||
history::validate(&records, &plan).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
history::validate(&[(0, "first".into(), "one".into())], &EMPTY)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_mapping_requires_exact_unused_drops_and_unambiguous_renames() {
|
||||
let old = BTreeMap::from([
|
||||
("id".into(), "bigint".into()),
|
||||
("name".into(), "text".into()),
|
||||
("removed".into(), "bigint".into()),
|
||||
]);
|
||||
let plan = const {
|
||||
Plan {
|
||||
postgres_columns: &[
|
||||
column("id", "bigint"),
|
||||
column("title", "text"),
|
||||
column("new", "uuid"),
|
||||
],
|
||||
renames: &[("title", "name")],
|
||||
migrations: &[step("remove", "one", &["removed"])],
|
||||
..EMPTY
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &plan, 0).unwrap(),
|
||||
[Some("id"), Some("name"), None]
|
||||
);
|
||||
assert_eq!(
|
||||
alter::sources(&old, &plan, 1).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let mut ambiguous = old.clone();
|
||||
ambiguous.insert("title".into(), "text".into());
|
||||
assert_eq!(
|
||||
alter::sources(&ambiguous, &plan, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let missing = Plan {
|
||||
renames: &[("title", "absent")],
|
||||
..plan
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &missing, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let duplicated = Plan {
|
||||
postgres_columns: const {
|
||||
&[
|
||||
column("id", "bigint"),
|
||||
column("title", "text"),
|
||||
column("other", "text"),
|
||||
]
|
||||
},
|
||||
renames: &[("title", "name"), ("other", "name")],
|
||||
..missing
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &duplicated, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_supported_storage_conversions_reach_postgres_ddl() {
|
||||
for (old, next) in [
|
||||
("text", "text"),
|
||||
("numeric(6,2)", "numeric(8,3)"),
|
||||
("numeric(8,3)", "numeric(6,2)"),
|
||||
] {
|
||||
assert!(alter::conversion(old, next));
|
||||
}
|
||||
for (old, next) in [
|
||||
("double precision", "bigint"),
|
||||
("bigint", "double precision"),
|
||||
("text", "uuid"),
|
||||
("bigint", "numeric(8,2)"),
|
||||
("bytea", "text"),
|
||||
("numeric(6,2)", "numeric(3,4)"),
|
||||
] {
|
||||
assert!(!alter::conversion(old, next));
|
||||
}
|
||||
assert_eq!(schema::quote("a\"b"), "\"a\"\"b\"");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::database::{Binding, Kind, Sql, context::initialization_order};
|
||||
|
||||
fn binding(references: &'static [usize]) -> Binding {
|
||||
Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database: None,
|
||||
table: "unused",
|
||||
schema: Sql::same(""),
|
||||
plan: None,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_dependencies_precede_children_without_changing_binding_indices() {
|
||||
let bindings = [
|
||||
binding(&[0, 1, 2, 2]),
|
||||
binding(&[2]),
|
||||
binding(&[]),
|
||||
binding(&[]),
|
||||
];
|
||||
assert_eq!(
|
||||
initialization_order(&bindings, &[true; 4]).unwrap(),
|
||||
[2, 1, 0, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_cycles_fail_before_initialization_but_self_references_are_valid() {
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[1]), binding(&[0])], &[true, true])
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[0])], &[true]).unwrap(),
|
||||
[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_keeps_source_order_and_accepts_forward_and_cyclic_references() {
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[1]), binding(&[0])], &[false, false]).unwrap(),
|
||||
[0, 1]
|
||||
);
|
||||
let bindings = [binding(&[2]), binding(&[3]), binding(&[]), binding(&[1])];
|
||||
assert_eq!(
|
||||
initialization_order(&bindings, &[true, false, true, false]).unwrap(),
|
||||
[1, 2, 0, 3]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Context, Parameter, Plan, Registry, Settings, Sql, Statement, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let directory = Self(std::env::temp_dir().join(format!(
|
||||
"dever-registry-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
)));
|
||||
std::fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const PARENT: &str = "CREATE TABLE z_parent(id INTEGER PRIMARY KEY);";
|
||||
const CHILD: &str = "CREATE TABLE a_child(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES z_parent(id));";
|
||||
const PARENT_PLAN: Plan = Plan {
|
||||
create: Sql::same(PARENT),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seed_revision: "parent-v1",
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO z_parent VALUES (?1)"),
|
||||
parameters: || Ok(vec![Parameter::Integer(7)]),
|
||||
}],
|
||||
};
|
||||
const CHILD_PLAN: Plan = Plan {
|
||||
create: Sql::same(CHILD),
|
||||
seed_revision: "child-v1",
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO a_child VALUES (?1,?2)"),
|
||||
parameters: || Ok(vec![Parameter::Integer(1), Parameter::Integer(7)]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
|
||||
fn binding(
|
||||
table: &'static str,
|
||||
schema: &'static str,
|
||||
plan: Option<&'static Plan>,
|
||||
references: &'static [usize],
|
||||
) -> Binding {
|
||||
Binding {
|
||||
storage: Storage::Global,
|
||||
component: "demo",
|
||||
database: None,
|
||||
table,
|
||||
schema: Sql::same(schema),
|
||||
plan,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings() -> Settings {
|
||||
Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"registry.db","max_connections":1}}}"#.into()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_internal_bindings_initialize_once_and_reject_conflicting_schema() {
|
||||
let directory = Directory::new();
|
||||
let shared = binding(
|
||||
"queue",
|
||||
"CREATE TABLE queue(id INTEGER PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
let mut bindings = [shared.clone(), shared];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for expected in [1, 2] {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.same_pool(0, 1));
|
||||
let mut context = Context::new(registry.clone());
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO queue VALUES (?1)",
|
||||
vec![Parameter::Integer(expected)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let count = context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM queue", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [expected]);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
bindings[1].schema =
|
||||
Sql::same("CREATE TABLE queue(id INTEGER PRIMARY KEY, extra TEXT);");
|
||||
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.expect("conflicting shared schema accepted");
|
||||
assert_eq!(error.kind, crate::database::Kind::Migration);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_seeds_forward_references_after_all_tables_exist() {
|
||||
let directory = Directory::new();
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for _ in 0..2 {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
let executor = context.on(0, scope).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[7]
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO a_child VALUES (2,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_preserves_cyclic_and_self_references_with_and_without_seeds() {
|
||||
const LEFT: &str = "CREATE TABLE cycle_left(id INTEGER PRIMARY KEY, right_id INTEGER REFERENCES cycle_right(id));";
|
||||
const RIGHT: &str = "CREATE TABLE cycle_right(id INTEGER PRIMARY KEY, left_id INTEGER REFERENCES cycle_left(id));";
|
||||
const SELF: &str = "CREATE TABLE self_node(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES self_node(id));";
|
||||
const LEFT_PLAN: Plan = Plan {
|
||||
create: Sql::same(LEFT),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO cycle_left VALUES (1,2)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const RIGHT_PLAN: Plan = Plan {
|
||||
create: Sql::same(RIGHT),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO cycle_right VALUES (2,1)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const SELF_PLAN: Plan = Plan {
|
||||
create: Sql::same(SELF),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO self_node VALUES (1,1)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const EMPTY_LEFT: Plan = Plan {
|
||||
seeds: &[],
|
||||
..LEFT_PLAN
|
||||
};
|
||||
const EMPTY_RIGHT: Plan = Plan {
|
||||
seeds: &[],
|
||||
..RIGHT_PLAN
|
||||
};
|
||||
const EMPTY_SELF: Plan = Plan {
|
||||
seeds: &[],
|
||||
..SELF_PLAN
|
||||
};
|
||||
for seeded in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let bindings = [
|
||||
binding(
|
||||
"cycle_left",
|
||||
LEFT,
|
||||
Some(if seeded { &LEFT_PLAN } else { &EMPTY_LEFT }),
|
||||
&[1],
|
||||
),
|
||||
binding(
|
||||
"cycle_right",
|
||||
RIGHT,
|
||||
Some(if seeded { &RIGHT_PLAN } else { &EMPTY_RIGHT }),
|
||||
&[0],
|
||||
),
|
||||
binding(
|
||||
"self_node",
|
||||
SELF,
|
||||
Some(if seeded { &SELF_PLAN } else { &EMPTY_SELF }),
|
||||
&[2],
|
||||
),
|
||||
];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for _ in 0..2 {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
let executor = context.on(0, scope).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT count(*) FROM cycle_left", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
[i64::from(seeded)]
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO cycle_left VALUES (3,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO self_node VALUES (3,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_invalid_seed_rolls_back_the_physical_group() {
|
||||
const INVALID: Plan = Plan {
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO a_child VALUES (1,999)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..CHILD_PLAN
|
||||
};
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&INVALID), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind, crate::database::Kind::Migration);
|
||||
let options = settings().select("", None).unwrap().clone();
|
||||
let database = crate::database::Database::open(&directory.0, options, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='table'",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
drop(executor);
|
||||
database.close();
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
bindings[1].clone(),
|
||||
];
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_tenant_migration_batches_seed_dependencies_in_each_physical_database() {
|
||||
let directory = Directory::new();
|
||||
let mut config = settings();
|
||||
config.0.get_mut("default").unwrap().tenant_directory = Some("tenants".into());
|
||||
config.1 = Some(crate::database::TenantOptions {
|
||||
database: "default".into(),
|
||||
max_pools: 2,
|
||||
idle_timeout_ms: 100,
|
||||
});
|
||||
let mut bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
for binding in &mut bindings {
|
||||
binding.storage = Storage::Tenant;
|
||||
}
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, config, &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
for tenant in [1, 2, 1] {
|
||||
registry.migrate_tenant(scope, tenant).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, tenant).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[7]
|
||||
);
|
||||
}
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use super::*;
|
||||
use dever_runtime::{database::scalar, value::decimal::Decimal};
|
||||
|
||||
#[test]
|
||||
fn decimal_storage_covers_every_precision_and_scale() {
|
||||
for precision in 1..=34 {
|
||||
let limit = 10_i128.pow(precision);
|
||||
for scale in 0..=precision {
|
||||
for coefficient in [-limit + 1, -1, 0, 1, limit - 1] {
|
||||
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
|
||||
let key = scalar::decimal::encode(value, precision, scale).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
format!(
|
||||
"{:0width$}",
|
||||
coefficient + limit,
|
||||
width = precision as usize + 1
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(&key, precision, scale).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
for coefficient in [-limit, limit] {
|
||||
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
|
||||
assert!(scalar::decimal::encode(value, precision, scale).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_storage_rejects_non_digit_keys() {
|
||||
for position in 0..6 {
|
||||
for byte in 0..=127u8 {
|
||||
if byte.is_ascii_digit() {
|
||||
continue;
|
||||
}
|
||||
let mut key = b"100100".to_vec();
|
||||
key[position] = byte;
|
||||
assert!(scalar::decimal::decode(std::str::from_utf8(&key).unwrap(), 5, 2).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_keys_are_exact_unique_and_sort_like_numbers() {
|
||||
let values = [
|
||||
"-999.99", "-100", "-1.01", "-0.01", "0", "0.01", "1.01", "100", "999.99",
|
||||
];
|
||||
let encoded =
|
||||
values.map(|text| scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap());
|
||||
assert!(encoded.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
for (text, key) in values.iter().zip(&encoded) {
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(key, 5, 2).unwrap(),
|
||||
Decimal::parse(text).unwrap()
|
||||
);
|
||||
}
|
||||
for text in ["1", "1.0", "1.00", "1.000", "1E0"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
|
||||
"100100"
|
||||
);
|
||||
}
|
||||
for text in ["0", "-0", "0E-6176"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
|
||||
"100000"
|
||||
);
|
||||
}
|
||||
for text in ["1000", "-1000", "0.001", "-0.001", "1E6144", "1E-6176"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData,
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for (precision, scale, text) in [
|
||||
(34, 0, "9999999999999999999999999999999999"),
|
||||
(34, 34, "0.9999999999999999999999999999999999"),
|
||||
(34, 4, "-999999999999999999999999999999.9999"),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let key = scalar::decimal::encode(value, precision, scale).unwrap();
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(&key, precision, scale).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"000000", "200000", "10001", "0100000", "+00001", "10000x", "100\0xx",
|
||||
] {
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(key, 5, 2).unwrap_err().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(
|
||||
scope,
|
||||
"CREATE TABLE numbers (amount TEXT UNIQUE, optional TEXT)",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for key in encoded.iter().rev() {
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO numbers(amount) VALUES (?1)",
|
||||
vec![Parameter::Text(key.clone())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let rows = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT amount, optional FROM numbers ORDER BY amount",
|
||||
vec![],
|
||||
20,
|
||||
|row| {
|
||||
assert_eq!(
|
||||
row.get_ref(1)?.data_type(),
|
||||
dever_runtime::database::DataType::Null
|
||||
);
|
||||
scalar::decimal(row, 0, 5, 2)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, values.map(|text| Decimal::parse(text).unwrap()));
|
||||
assert_eq!(
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO numbers(amount) VALUES (?1)",
|
||||
vec![Parameter::Text(encoded[4].clone())]
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "calendar", feature = "uuid"))]
|
||||
#[test]
|
||||
fn typed_scalars_preserve_storage_bytes_and_reject_corrupt_values() {
|
||||
use dever_runtime::{
|
||||
time::calendar::{Date, Time},
|
||||
value::uuid::Uuid,
|
||||
wire::json::Json,
|
||||
};
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(
|
||||
scope,
|
||||
"CREATE TABLE values_test (uid BLOB, day INTEGER, time INTEGER, document TEXT)",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let uid = Uuid::parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap();
|
||||
let document = "{\"value\":123456789012345678901234567890.0010,\"nested\":[true,null]}";
|
||||
for (day, time) in [
|
||||
("0000-01-01", "00:00:00"),
|
||||
("1969-12-31", "23:59:59.999"),
|
||||
("9999-12-31", "12:30:42.001"),
|
||||
] {
|
||||
let day = Date::parse(day).unwrap();
|
||||
let time = Time::parse(time).unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO values_test VALUES (?1,?2,?3,?4)",
|
||||
vec![
|
||||
Parameter::Blob(uid.as_bytes().to_vec()),
|
||||
Parameter::Integer(day.millis()),
|
||||
Parameter::Integer(time.millis()),
|
||||
Parameter::Text(document.into()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let actual = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
|
||||
vec![],
|
||||
1,
|
||||
|row| {
|
||||
assert_eq!(
|
||||
row.get_ref(0)?.data_type(),
|
||||
dever_runtime::database::DataType::Blob
|
||||
);
|
||||
assert_eq!(
|
||||
row.get_ref(1)?.data_type(),
|
||||
dever_runtime::database::DataType::Integer
|
||||
);
|
||||
Ok((
|
||||
scalar::uuid(row, 0)?,
|
||||
scalar::date(row, 1)?,
|
||||
scalar::time(row, 2)?,
|
||||
scalar::json(row, 3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actual, [(uid, day, time, Json::parse(document).unwrap())]);
|
||||
assert_eq!(actual[0].3.as_str(), document);
|
||||
}
|
||||
executor
|
||||
.execute(scope, "DELETE FROM values_test", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
for (sql, kind) in [
|
||||
("INSERT INTO values_test(uid) VALUES (zeroblob(15))", "Uuid"),
|
||||
("INSERT INTO values_test(day) VALUES (1)", "Date"),
|
||||
(
|
||||
"INSERT INTO values_test(day) VALUES (-62167219200001)",
|
||||
"Date",
|
||||
),
|
||||
("INSERT INTO values_test(time) VALUES (86400000)", "Time"),
|
||||
("INSERT INTO values_test(time) VALUES (-1)", "Time"),
|
||||
(
|
||||
"INSERT INTO values_test(document) VALUES ('{\"a\":1,\"a\":2}')",
|
||||
"Json",
|
||||
),
|
||||
(
|
||||
"INSERT INTO values_test(document) VALUES ('invalid')",
|
||||
"Json",
|
||||
),
|
||||
] {
|
||||
executor.execute(scope, sql, vec![]).await.unwrap();
|
||||
let failure = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
|
||||
vec![],
|
||||
1,
|
||||
move |row| match kind {
|
||||
"Uuid" => scalar::uuid(row, 0).map(|_| ()),
|
||||
"Date" => scalar::date(row, 1).map(|_| ()),
|
||||
"Time" => scalar::time(row, 2).map(|_| ()),
|
||||
_ => scalar::json(row, 3).map(|_| ()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.kind, Kind::InvalidData, "{sql}");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
use dever_runtime::database::{Migration, Plan, Sql, Statement};
|
||||
|
||||
const BASE: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE CHECK(_dever_length(name) <= 24), legacy TEXT, amount TEXT(5,2) NOT NULL);";
|
||||
const NEXT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 8), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
|
||||
const BEFORE: Statement = Statement {
|
||||
sql: Sql::same("UPDATE account SET name = ?1 WHERE name = ?2"),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Editor".into()),
|
||||
Parameter::Text("Legacy".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const AFTER: Statement = Statement {
|
||||
sql: Sql::same("UPDATE account SET label = ?1 WHERE label = ?2"),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Final".into()),
|
||||
Parameter::Text("Editor".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const SEED: Statement = Statement {
|
||||
sql: Sql::same(
|
||||
"INSERT INTO account(label,amount) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE label = ?1)",
|
||||
),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Seed".into()),
|
||||
Parameter::Text("10001000".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const STEP: Migration = Migration {
|
||||
name: "normalize",
|
||||
revision: "normalize-v1",
|
||||
drops: &["legacy"],
|
||||
before: &[BEFORE],
|
||||
after: &[AFTER],
|
||||
};
|
||||
const INITIAL: Plan = Plan {
|
||||
create: Sql::same(BASE),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seeds: &[],
|
||||
seed_revision: "empty",
|
||||
};
|
||||
const UPGRADE: Plan = Plan {
|
||||
create: Sql::same(NEXT),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[("label", "name")],
|
||||
migrations: &[STEP],
|
||||
seeds: &[SEED],
|
||||
seed_revision: "seed-v1",
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn schema_upgrade_preserves_rows_decimal_foreign_keys_and_id_sequence() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(id,name,legacy,amount) VALUES (1,'Legacy','old','101230'),(20,'Deleted','old','100000'); DELETE FROM account WHERE id=20; CREATE TABLE dependent(account_id INTEGER REFERENCES account(id) ON DELETE CASCADE); INSERT INTO dependent VALUES (1);").await.unwrap();
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
let rows = executor.query(scope,"SELECT id,label,amount,enabled FROM account ORDER BY id",vec![],20,|row|Ok((row.get::<_,i64>(0)?,row.get::<_,String>(1)?,dever_runtime::database::scalar::decimal(row,2,7,3)?.to_string(),row.get::<_,bool>(3)?))).await.unwrap();
|
||||
assert_eq!(rows,[(1,"Final".into(),"12.3".into(),true),(21,"Seed".into(),"1".into(),true)]);
|
||||
let child = executor.query(scope,"SELECT account_id FROM dependent",vec![],20,|row|row.get::<_,i64>(0)).await.unwrap();
|
||||
assert_eq!(child,[1]);
|
||||
assert!(executor.execute(scope,"INSERT INTO dependent VALUES (999)",vec![]).await.is_err());
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[2]);
|
||||
let old = executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap_err();
|
||||
assert_eq!(old.kind,Kind::Migration);
|
||||
const ALTERED: Plan = Plan { migrations:&[Migration { revision:"changed", ..STEP }], ..UPGRADE };
|
||||
assert_eq!(executor.migrate(scope,"account",NEXT,Some(&ALTERED)).await.unwrap_err().kind,Kind::Migration);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_after_step_or_narrowing_rolls_back_data_ddl_seed_and_history() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(name,legacy,amount) VALUES ('Legacy','old','101230');").await.unwrap();
|
||||
const FAIL: Plan = Plan { migrations:&[Migration { after:&[Statement { sql:Sql::same("UPDATE absent_table SET value = ?1"),parameters:||Ok(vec![Parameter::Integer(1)]) }], ..STEP }],..UPGRADE };
|
||||
assert!(executor.migrate(scope,"account",NEXT,Some(&FAIL)).await.is_err());
|
||||
let rows = executor.query(scope,"SELECT name,legacy,amount FROM account",vec![],20,|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,String>(2)?))).await.unwrap();
|
||||
assert_eq!(rows,[("Legacy".into(),"old".into(),"101230".into())]);
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM _dever_migration",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
|
||||
assert_eq!(executor.query(scope,"SELECT definition FROM _dever_history WHERE name='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),[BASE]);
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
const TIGHT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 4), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
|
||||
const TIGHT_PLAN: Plan = Plan {create:Sql::same(TIGHT),..UPGRADE};
|
||||
assert_eq!(executor.migrate(scope,"account",TIGHT,Some(&TIGHT_PLAN)).await.unwrap_err().kind,Kind::Constraint);
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT label FROM account ORDER BY id",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["Final","Seed"]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_conflict_rolls_back_prior_seed_rows_and_revision() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const SQL: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE);";
|
||||
const FIRST: Statement = Statement { sql:Sql::same("INSERT INTO account(name,email) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE name=?1)"), parameters:||Ok(vec![Parameter::Text("first".into()),Parameter::Text("one".into())]) };
|
||||
const START: Plan = Plan {create:Sql::same(SQL),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[FIRST],seed_revision:"one"};
|
||||
executor.migrate(scope,"account",SQL,Some(&START)).await.unwrap();
|
||||
const MORE: Plan = Plan {seeds:&[FIRST,
|
||||
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("second".into()),Parameter::Text("two".into())])},
|
||||
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("third".into()),Parameter::Text("one".into())])}],seed_revision:"more",..START};
|
||||
assert_eq!(executor.migrate(scope,"account",SQL,Some(&MORE)).await.unwrap_err().kind,Kind::Constraint);
|
||||
assert_eq!(executor.query(scope,"SELECT name FROM account",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["first"]);
|
||||
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["one"]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rebuild_keeps_the_last_allocated_id() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(id,name,amount) VALUES (20,'gone','100000'); DELETE FROM account;").await.unwrap();
|
||||
const EMPTY: Plan = Plan { seeds:&[],seed_revision:"empty",..UPGRADE };
|
||||
executor.migrate(scope,"account",NEXT,Some(&EMPTY)).await.unwrap();
|
||||
executor.execute(scope,"INSERT INTO account(label,amount) VALUES ('new','10000000')",vec![]).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT id FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[21]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_seed_failure_preserves_earlier_models_and_rolls_back_its_model() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const CHILD: &str = "CREATE TABLE \"child\" (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id));";
|
||||
const EMPTY: Plan = Plan {create:Sql::same(CHILD),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[],seed_revision:"empty"};
|
||||
// 没有记录时可以先建外键表;真正的 Seed 必须引用已存在的目标记录。
|
||||
executor.migrate(scope,"child",CHILD,Some(&EMPTY)).await.unwrap();
|
||||
const PARENT: &str = "CREATE TABLE \"parent\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
||||
executor.schema(scope,"parent",PARENT).await.unwrap();
|
||||
executor.execute(scope,"INSERT INTO parent VALUES (1,'preserved')",vec![]).await.unwrap();
|
||||
const INVALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,999)"),parameters:||Ok(vec![])}],seed_revision:"invalid",..EMPTY};
|
||||
assert!(executor.migrate(scope,"child",CHILD,Some(&INVALID)).await.is_err());
|
||||
assert_eq!(executor.query(scope,"SELECT name FROM parent",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["preserved"]);
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM child",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
|
||||
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='child'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["empty"]);
|
||||
assert!(executor.execute(scope,"INSERT INTO child VALUES (1,999)",vec![]).await.is_err());
|
||||
const VALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,1)"),parameters:||Ok(vec![])}],seed_revision:"valid",..EMPTY};
|
||||
executor.migrate(scope,"child",CHILD,Some(&VALID)).await.unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Settings, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-row-stream-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn registry(directory: &Directory, scope: &Scope<'_>) -> Arc<Registry> {
|
||||
let settings = Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"case.db","max_connections":1,"max_page_size":2,"wait_timeout_ms":1000}}}"#.into()).unwrap()).unwrap();
|
||||
Registry::prepare(
|
||||
&directory.0,
|
||||
settings,
|
||||
&[Binding {
|
||||
component: "demo",
|
||||
database: None,
|
||||
table: "rows",
|
||||
schema: Sql::same("CREATE TABLE rows (id INTEGER PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
storage: Storage::Global,
|
||||
}],
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const ROWS: Sql = Sql::same(
|
||||
"WITH RECURSIVE items(value) AS (SELECT 1 UNION ALL SELECT value+1 FROM items WHERE value < 10000) SELECT value FROM items",
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn bounded_stream_close_drop_and_decode_error_release_single_connection() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let registry = registry(&directory, scope).await;
|
||||
let mut context = Context::new(registry.clone());
|
||||
for mode in 0..3 {
|
||||
let produced = Arc::new(AtomicUsize::new(0));
|
||||
let counted = produced.clone();
|
||||
let rows = context
|
||||
.stream(0, scope, ROWS, vec![], move |row| {
|
||||
counted.fetch_add(1, Ordering::Relaxed);
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.next().await.unwrap(), Some(1));
|
||||
// 消费者暂停时最多只多解码通道一行与待发送一行。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(produced.load(Ordering::Relaxed) <= 3);
|
||||
// SQLite 游标在阻塞线程中借用 statement;背压期间仍持有 worker。
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(10),
|
||||
scope.blocking(|| 42),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
match mode {
|
||||
0 => {
|
||||
rows.close();
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
}
|
||||
1 => drop(rows),
|
||||
_ => {
|
||||
let alias = rows.clone();
|
||||
alias.close();
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
assert_eq!(scope.blocking(|| 42).await.unwrap(), 42);
|
||||
let found = context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT 42", vec![], 1, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(found, [42]);
|
||||
}
|
||||
let rows = context
|
||||
.stream(0, scope, Sql::same("SELECT 'bad'"), vec![], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let StreamError::Database(error) = rows.next().await.unwrap_err() else {
|
||||
panic!("database error must retain its category")
|
||||
};
|
||||
assert_eq!(error.kind, Kind::InvalidData);
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
assert!(context.on(0, scope).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_stream_owner_is_drained_and_pool_remains_usable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let registry = registry(&directory, scope).await;
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let owned = registry.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut context = Context::new(owned);
|
||||
let rows = context
|
||||
.stream(0, &child, ROWS, vec![], |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.next().await.unwrap(), Some(1));
|
||||
ready.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
drop(rows);
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
drop(task);
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.on(0, scope).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
use crate::database::tenant::{Manager, Pool};
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn reaping_deadline_keeps_pending_and_active_pools_visible() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut settings = settings(true, 2);
|
||||
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
|
||||
let database = crate::database::Database::open(
|
||||
&directory.0,
|
||||
settings.0.remove("default").unwrap(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manager = Manager::new(
|
||||
&directory.0,
|
||||
settings.1.unwrap(),
|
||||
std::slice::from_ref(&database),
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&["default"],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut cache = manager.cache.lock().await;
|
||||
let first_state = manager.reserve(&mut cache, (0, 1)).unwrap();
|
||||
let active = Arc::new(Pool {
|
||||
database: database.clone(),
|
||||
});
|
||||
{
|
||||
let mut ready = first_state.try_lock().unwrap();
|
||||
ready.pool = Some(active.clone());
|
||||
ready.ready = true;
|
||||
}
|
||||
let first = Arc::downgrade(&first_state);
|
||||
drop(first_state);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
|
||||
let pending_state = manager.reserve(&mut cache, (0, 2)).unwrap();
|
||||
let pending = Arc::downgrade(&pending_state);
|
||||
drop(pending_state);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(pending.strong_count(), 0);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
|
||||
cache.entries.get_mut(&(0, 1)).unwrap().touched =
|
||||
Instant::now() - Duration::from_secs(172800);
|
||||
cache.check_at = Instant::now();
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
drop(active);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
assert!(cache.check_at > Instant::now());
|
||||
cache.check_at = Instant::now();
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 0);
|
||||
drop(cache);
|
||||
manager.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tenant_configuration_rejects_unknown_controls_and_escaping_locations() {
|
||||
for tenant in [
|
||||
serde_json::json!({"database":"missing"}),
|
||||
serde_json::json!({"database":"default","mode":"table"}),
|
||||
serde_json::json!({"database":"default","max_pools":0}),
|
||||
serde_json::json!({"database":"default","idle_timeout_ms":0}),
|
||||
serde_json::Value::Null,
|
||||
] {
|
||||
let document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"app.db","tenant_directory":"tenants"}}, "tenant":tenant});
|
||||
assert!(Settings::load(&Document::parse(document.to_string()).unwrap()).is_err());
|
||||
}
|
||||
for path in ["", "../tenants", "/tmp/tenants", "data/../tenants"] {
|
||||
let mut config = settings(true, 2);
|
||||
config.0.get_mut("default").unwrap().tenant_directory = Some(path.to_owned());
|
||||
assert!(config.0["default"].validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_tenant_namespaces_and_platform_files_are_rejected() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut config = settings(true, 2);
|
||||
let mut secondary = config.0["default"].clone();
|
||||
secondary.path = "secondary.db".to_owned();
|
||||
secondary.tenant_directory = Some("tenants//".to_owned());
|
||||
config.0.insert("secondary".to_owned(), secondary);
|
||||
let mut models = bindings();
|
||||
models[0].database = Some("secondary");
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, config, &models, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
let mut config = settings(true, 2);
|
||||
config.0.get_mut("default").unwrap().path = "tenants/1.db".to_owned();
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, config, &bindings(), scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_database_is_selected_explicitly_and_multiple_physical_sources_migrate() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let mut config = settings(true, 1);
|
||||
let mut security = config.0["default"].clone();
|
||||
security.path = "security.db".to_owned();
|
||||
security.tenant_directory = Some("security-tenants".to_owned());
|
||||
config.0.insert("security".to_owned(), security);
|
||||
config.1.as_mut().unwrap().database = "security".to_owned();
|
||||
let registry = Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
let marker = context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM _dever_tenant_storage WHERE ready=1 AND length(fingerprint)=64", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(marker, [2]);
|
||||
assert!(registry.check_transactions(&[&[0,2]]).is_err());
|
||||
assert!(context.select_tenant(scope, 2).await.is_err());
|
||||
assert_eq!(context.tenant_id(), None);
|
||||
assert!(context.on(0, scope).await.is_err());
|
||||
registry.close().await;
|
||||
assert!(directory.0.join("security-tenants/1.db").exists());
|
||||
assert!(directory.0.join("tenants/1.db").exists());
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_migration_routes_models_roles_and_control_to_distinct_physical_storage() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 4), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.tenant_enabled());
|
||||
assert!(registry.is_tenant(0));
|
||||
let mut missing = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
missing.on(0, scope).await.err().unwrap().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
assert_eq!(
|
||||
missing.select_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert!(!directory.0.join("tenants").exists());
|
||||
let rows = missing
|
||||
.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM sqlite_schema WHERE name='items'",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, [0]);
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
assert_eq!(
|
||||
registry.queue_tenants(0, scope).await.unwrap(),
|
||||
[Some(1), Some(2)]
|
||||
);
|
||||
assert_eq!(registry.queue_tenants(1, scope).await.unwrap(), [None]);
|
||||
for id in [1, 2] {
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, id).await.unwrap();
|
||||
assert_eq!(context.tenant_id(), Some(id));
|
||||
assert_eq!(context.detached().tenant_id(), Some(id));
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,$1)",
|
||||
vec![crate::database::Parameter::Text(format!("tenant {id}"))],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(2, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
let rows = context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT value FROM items", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, [format!("tenant {id}")]);
|
||||
let count = context
|
||||
.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [0]);
|
||||
}
|
||||
registry.close().await;
|
||||
assert!(directory.0.join("tenants/1.db").is_file());
|
||||
assert!(directory.0.join("tenants/2.db").is_file());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_switches_are_immediate_validated_and_do_not_hide_ready_queues() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
registry
|
||||
.set_component(scope, 1, "shop", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
assert!(context.require_components(scope, &["shop"]).await.is_err());
|
||||
assert!(
|
||||
context
|
||||
.components_enabled(scope, &["platform"])
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1)]);
|
||||
for invalid in ["platform", "unknown", "dever"] {
|
||||
assert_eq!(
|
||||
registry
|
||||
.set_component(scope, 1, invalid, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
registry
|
||||
.set_component(scope, 1, "shop", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_component_listing_paginates_and_observes_current_tenant_state() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut models = bindings().to_vec();
|
||||
for component in ["billing", "catalog"] {
|
||||
models.push(crate::database::Binding {
|
||||
component,
|
||||
table: component,
|
||||
schema: if component == "billing" {
|
||||
Sql::same("CREATE TABLE billing(id BIGINT PRIMARY KEY)")
|
||||
} else {
|
||||
Sql::same("CREATE TABLE catalog(id BIGINT PRIMARY KEY)")
|
||||
},
|
||||
..bindings()[0].clone()
|
||||
});
|
||||
}
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &models, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
let components = ["billing", "catalog", "shop", "platform", "unknown"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
for component in ["billing", "catalog", "shop"] {
|
||||
registry
|
||||
.set_component(scope, 1, component, false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap(),
|
||||
["billing", "catalog", "shop"]
|
||||
.map(String::from)
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
registry
|
||||
.set_component(scope, 1, "catalog", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap(),
|
||||
["billing", "shop"].map(String::from).into_iter().collect()
|
||||
);
|
||||
context.select_tenant(scope, 2).await.unwrap();
|
||||
assert!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactions_and_foreign_keys_cannot_cross_tenant_boundaries() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.check_transactions(&[&[0, 1]]).is_err());
|
||||
registry.check_transactions(&[&[0, 2]]).unwrap();
|
||||
registry.check_transactions(&[&[1, 2]]).unwrap();
|
||||
assert!(registry.check_transactions(&[&[2, 0, 1]]).is_err());
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,'rollback')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.on(1, scope).await.is_err());
|
||||
assert!(context.select_tenant(scope, 2).await.is_err());
|
||||
context.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM items", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "INSERT INTO accounts(id) VALUES(1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.on(0, scope).await.is_err());
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[1]
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
let mut invalid = bindings();
|
||||
invalid[0].references = &[1];
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, settings(true, 2), &invalid, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_tenant_configuration_preserves_ordinary_database_routing() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(false, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!registry.tenant_enabled());
|
||||
registry.check_transactions(&[&[0, 1, 2]]).unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,'platform')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.require_components(scope, &["shop"]).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounded_cache_never_evicts_an_active_tenant_pool() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
let mut first = Context::new(registry.clone());
|
||||
first.select_tenant(scope, 1).await.unwrap();
|
||||
first.on(0, scope).await.unwrap();
|
||||
let mut second = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
second.select_tenant(scope, 2).await.unwrap_err().kind,
|
||||
Kind::PoolExhausted
|
||||
);
|
||||
assert_eq!(
|
||||
registry.migrate_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Pool
|
||||
);
|
||||
drop(first);
|
||||
second.select_tenant(scope, 2).await.unwrap();
|
||||
second.on(0, scope).await.unwrap();
|
||||
drop(second);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_or_missing_markers_and_files_never_fall_back_or_recreate_storage() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"UPDATE _dever_tenant_ready SET fingerprint='wrong'",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(context);
|
||||
let mut next = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
next.select_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
next.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "UPDATE _dever_tenant_storage SET ready=0", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(next.select_tenant(scope, 1).await.is_err());
|
||||
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
std::fs::remove_file(directory.0.join("tenants/1.db")).unwrap();
|
||||
assert!(next.select_tenant(scope, 1).await.is_err());
|
||||
assert!(!directory.0.join("tenants/1.db").exists());
|
||||
for id in [0, -1] {
|
||||
assert_eq!(
|
||||
registry.migrate_tenant(scope, id).await.unwrap_err().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_schema_migration_stays_unready_and_never_becomes_a_cached_success() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.close().await;
|
||||
drop(registry);
|
||||
let mut changed = bindings();
|
||||
changed[0].schema = Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL, changed BIGINT NOT NULL)");
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &changed, scope).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
assert_eq!(registry.migrate_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
|
||||
registry.close().await;
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Context, Kind, Registry, Settings, Sql, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
mod cache;
|
||||
mod configuration;
|
||||
mod isolation;
|
||||
mod lifecycle;
|
||||
mod performance;
|
||||
#[cfg(feature = "postgres")]
|
||||
mod postgres;
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let directory = Self(std::env::temp_dir().join(format!(
|
||||
"dever-tenant-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
)));
|
||||
std::fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn settings(tenant: bool, pools: usize) -> Settings {
|
||||
let mut document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"platform.db","tenant_directory":"tenants","max_connections":2,"max_page_size":1,"wait_timeout_ms":100}}});
|
||||
if tenant {
|
||||
document["tenant"] =
|
||||
serde_json::json!({"database":"default","max_pools":pools,"idle_timeout_ms":10});
|
||||
}
|
||||
Settings::load(&Document::parse(document.to_string()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn bindings() -> [Binding; 4] {
|
||||
[
|
||||
Binding {
|
||||
storage: Storage::Tenant,
|
||||
component: "shop",
|
||||
database: None,
|
||||
table: "items",
|
||||
schema: Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Global,
|
||||
component: "platform",
|
||||
database: None,
|
||||
table: "accounts",
|
||||
schema: Sql::same("CREATE TABLE accounts(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Authorization,
|
||||
component: "dever",
|
||||
database: None,
|
||||
table: "roles",
|
||||
schema: Sql::same("CREATE TABLE roles(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Control,
|
||||
component: "dever",
|
||||
database: None,
|
||||
table: "permissions",
|
||||
schema: Sql::same("CREATE TABLE permissions(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::*;
|
||||
use crate::database::tenant::{Manager, Pool};
|
||||
use std::{hint::black_box, sync::Arc, time::Instant};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn cached_pool_reaping_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for count in [1, 32, 256] {
|
||||
for busy in [false, true] {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut settings = settings(true, count);
|
||||
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
|
||||
let database = crate::database::Database::open(
|
||||
&directory.0,
|
||||
settings.0.remove("default").unwrap(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manager = Manager::new(
|
||||
&directory.0,
|
||||
settings.1.unwrap(),
|
||||
std::slice::from_ref(&database),
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&["default"],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut cache = manager.cache.lock().await;
|
||||
let mut states = Vec::new();
|
||||
for tenant in 0..count {
|
||||
let state = manager.reserve(&mut cache, (0, tenant as i64 + 1)).unwrap();
|
||||
let mut ready = state.try_lock().unwrap();
|
||||
ready.pool = Some(Arc::new(Pool {
|
||||
database: database.clone(),
|
||||
}));
|
||||
ready.ready = true;
|
||||
states.push(Arc::downgrade(&state));
|
||||
}
|
||||
// 只测回收检查,不包含建库、SQL 或池获取;Weak 不改变使用中状态。
|
||||
let active = busy.then(|| states[0].upgrade().unwrap());
|
||||
for _ in 0..100 {
|
||||
manager.evict(&mut cache);
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
for _ in 0..10_000 {
|
||||
black_box(&manager).evict(black_box(&mut cache));
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(states[0].strong_count(), if busy { 2 } else { 1 });
|
||||
println!("tenant_cache_{count}_{busy},{sample},10000,{elapsed},{count}");
|
||||
}
|
||||
drop(active);
|
||||
drop(cache);
|
||||
manager.close().await;
|
||||
assert!(states.iter().all(|state| state.strong_count() == 0));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::*;
|
||||
use crate::database::{Database, Lease, Options, Parameter};
|
||||
use futures_util::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
|
||||
fn physical_databases_are_explicit_isolated_idempotent_and_fail_closed() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let document = Document::load(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json")).unwrap();
|
||||
let settings: serde_json::Value = document.decode().unwrap();
|
||||
let admin: Options = serde_json::from_value(settings["database"]["postgres_test"].clone()).unwrap();
|
||||
let mut options: Options = serde_json::from_value(settings["database"]["postgres_tenant_test"].clone()).unwrap();
|
||||
let template = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap();
|
||||
assert!(template.get_dbname().unwrap().contains("{case}"));
|
||||
assert_eq!(options.url.as_ref().unwrap().matches("{case}").count(), 1);
|
||||
let token = crate::database::scalar::generated_uuid().unwrap().to_string().replace('-', "");
|
||||
options.url = Some(options.url.unwrap().replace("{case}", &token));
|
||||
let control_name = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap().get_dbname().unwrap().to_owned();
|
||||
assert!((1..=63).contains(&control_name.len()));
|
||||
assert!(control_name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'));
|
||||
let prefix = format!("dv_{token}");
|
||||
options.tenant_database_prefix = Some(prefix.clone());
|
||||
options.max_connections = 2;
|
||||
options.max_page_size = 1;
|
||||
let tenant_options = options.clone();
|
||||
let admin = Database::open(&directory.0, admin, scope).await.unwrap();
|
||||
let mut admin_executor = admin.executor().await.unwrap();
|
||||
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
|
||||
lease.call(async |client| client.batch_execute(&format!("CREATE DATABASE \"{control_name}\"")).await.map_err(Into::into)).await.unwrap();
|
||||
let mut database = std::collections::BTreeMap::new();
|
||||
database.insert("default".to_owned(), options);
|
||||
let config = Settings(database, Some(crate::database::TenantOptions { database: "default".to_owned(), max_pools: 2, idle_timeout_ms: 100 }));
|
||||
let mut registry = None;
|
||||
let result = AssertUnwindSafe(async {
|
||||
registry = Some(Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap());
|
||||
let registry = registry.as_ref().unwrap();
|
||||
let mut absent = Context::new(registry.clone());
|
||||
assert_eq!(absent.select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
assert_eq!(admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_database WHERE datname=$1"), vec![Parameter::Text(format!("{prefix}_1"))], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
|
||||
for id in [1, 2, 1] { registry.migrate_tenant(scope, id).await.unwrap(); }
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
|
||||
for id in [1, 2] {
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, id).await.unwrap();
|
||||
let owner = context.enter();
|
||||
context.on(0, scope).await.unwrap().execute(scope, Sql::same("INSERT INTO items(id,value) VALUES(1,$1)"), vec![Parameter::Text(format!("tenant {id}"))]).await.unwrap();
|
||||
context.on(2, scope).await.unwrap().execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![]).await.unwrap();
|
||||
assert!(context.on(1, scope).await.is_err());
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(context.on(0, scope).await.unwrap().query(scope, "SELECT value FROM items", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap(), [format!("tenant {id}")]);
|
||||
assert_eq!(context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
|
||||
}
|
||||
// 冷租户的标记被真实表锁阻塞时,另一租户仍能进入;取消后可重新准备。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(110)).await;
|
||||
Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap();
|
||||
let held = Database::open_existing(&directory.0, tenant_options.clone(), Some(&format!("{prefix}_2")), scope).await.unwrap();
|
||||
let mut held_executor = held.executor().await.unwrap();
|
||||
held_executor.begin(scope).await.unwrap();
|
||||
held_executor.initialize(scope, "LOCK TABLE _dever_tenant_ready IN ACCESS EXCLUSIVE MODE").await.unwrap();
|
||||
let copy = registry.clone();
|
||||
let blocked = scope.spawn(move |child| async move { Context::new(copy).select_tenant(&child, 2).await }).unwrap();
|
||||
let started = tokio::time::Instant::now();
|
||||
loop {
|
||||
let waiting = admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_stat_activity WHERE datname=$1 AND wait_event_type='Lock'"), vec![Parameter::Text(format!("{prefix}_2"))], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
if waiting[0] > 0 { break; }
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), Context::new(registry.clone()).select_tenant(scope, 1)).await.unwrap().unwrap();
|
||||
assert!(blocked.stop().await.unwrap().is_none());
|
||||
held_executor.finish(scope, true, false).await.unwrap();
|
||||
drop(held_executor);
|
||||
held.close();
|
||||
Context::new(registry.clone()).select_tenant(scope, 2).await.unwrap();
|
||||
registry.set_component(scope, 1, "shop", false).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
|
||||
context.on(0, scope).await.unwrap().execute(scope, "UPDATE _dever_tenant_ready SET ready=0", vec![]).await.unwrap();
|
||||
drop(context);
|
||||
assert_eq!(Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
}).catch_unwind().await;
|
||||
if let Some(registry) = registry.take() {
|
||||
registry.close().await;
|
||||
drop(registry);
|
||||
}
|
||||
// Pool::close 释放驱动句柄后,让同一 runtime 的连接任务完成退出。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
|
||||
for name in [format!("{prefix}_1"), format!("{prefix}_2"), control_name] {
|
||||
lease.call(async |client| client.batch_execute(&format!("DROP DATABASE IF EXISTS \"{name}\"")).await.map_err(Into::into)).await.unwrap();
|
||||
}
|
||||
drop(admin_executor);
|
||||
admin.close();
|
||||
if let Err(panic) = result { std::panic::resume_unwind(panic); }
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::{Header, headers};
|
||||
use crate::http::Limits;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
fn request_and_response_preserve_shared_body_storage() {
|
||||
let shared = bytes::Bytes::from(vec![0x80; 65536]);
|
||||
let request = hyper::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/echo")
|
||||
.header("host", "localhost")
|
||||
.body(shared.clone())
|
||||
.unwrap();
|
||||
let request = super::request_value(request);
|
||||
assert_eq!(request.body.as_ptr(), shared.as_ptr());
|
||||
let response = super::response_value(super::Response {
|
||||
status: 200,
|
||||
headers: Vec::new(),
|
||||
body: request.body,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(response.body().as_ptr(), shared.as_ptr());
|
||||
assert_eq!(response.body(), &shared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn header_buffer_cost() {
|
||||
let limits = Limits {
|
||||
header_bytes: 131072,
|
||||
..Limits::default()
|
||||
}
|
||||
.check()
|
||||
.unwrap();
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, count, bytes) in [("short", 8, 32), ("wide", 32, 32), ("large", 4, 16384)] {
|
||||
let input = (0..count)
|
||||
.map(|index| Header {
|
||||
name: format!("x-field-{index}"),
|
||||
value: vec![0x80; bytes].into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let converted = headers(input.clone(), &limits, false, 80).unwrap();
|
||||
for field in &input {
|
||||
assert_eq!(
|
||||
converted.get(field.name.as_str()).unwrap().as_bytes(),
|
||||
field.value
|
||||
);
|
||||
}
|
||||
let convert = || {
|
||||
let output = headers(black_box(input.clone()), &limits, false, 80).unwrap();
|
||||
black_box(output.values().map(|value| value.len()).sum::<usize>())
|
||||
};
|
||||
for _ in 0..100 {
|
||||
assert_eq!(convert(), count * bytes);
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
checksum += convert();
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, count * bytes * 1000);
|
||||
println!("http_headers_{name},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits {
|
||||
http2: Some(http::Http2Limits::default()),
|
||||
..Limits::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_http2_connection_releases_capacity_after_response() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let limits = Limits {
|
||||
connections: 1,
|
||||
timeout_ms: 50,
|
||||
..limits()
|
||||
};
|
||||
let serving = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(&incoming, &serving, &child, route).await
|
||||
})
|
||||
.unwrap();
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut sender, connection) =
|
||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake::<_, Full<bytes::Bytes>>(TokioIo::new(stream))
|
||||
.await
|
||||
.unwrap();
|
||||
let driver = scope.spawn_leaf(move || connection).unwrap();
|
||||
let response = sender
|
||||
.send_request(
|
||||
hyper::Request::builder()
|
||||
.uri("http://fixture.test/echo")
|
||||
.body(Full::new(bytes::Bytes::from_static(b"first")))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"first"[..]
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(2), driver.wait())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
http::send("127.0.0.1", port, request("/echo", b"second"), &limits)
|
||||
.await
|
||||
.unwrap()
|
||||
.body
|
||||
.as_ref(),
|
||||
b"second"
|
||||
);
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http2_single_request_preserves_body_headers_and_validates_protocol_options() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(&incoming, &limits(), &child, route).await
|
||||
})
|
||||
.unwrap();
|
||||
let response = http::send("127.0.0.1", port, request("/echo", b"http2"), &limits())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.body.as_ref(), b"http2");
|
||||
let headers = response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|header| header.name == "x-data")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers[0].value.as_ref(), [0x80]);
|
||||
for header in ["Connection", "Keep-Alive", "Proxy-Connection", "TE"] {
|
||||
let mut message = request("/", b"");
|
||||
message.headers.push(Header {
|
||||
name: header.into(),
|
||||
value: b"invalid".to_vec().into(),
|
||||
});
|
||||
assert!(
|
||||
http::send("127.0.0.1", port, message, &limits())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("connection header")
|
||||
);
|
||||
}
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http2_multiplexes_independent_streams_normalizes_authority_and_drains_goaway() {
|
||||
for threads in [1, 2] {
|
||||
let runtime = Runtime::new(dever_runtime::task::Limits {
|
||||
threads,
|
||||
workers: 2,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
runtime.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let started = Arc::new(Notify::new());
|
||||
let ready = started.clone();
|
||||
let release = Arc::new(Notify::new());
|
||||
let released = release.clone();
|
||||
let server = scope.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(&incoming, &limits(), &child, move |request, _| {
|
||||
let ready = ready.clone();
|
||||
let released = released.clone();
|
||||
async move {
|
||||
assert!(request.headers.iter().any(|header| header.name == "host" && header.value.as_ref() == b"fixture.test"));
|
||||
if request.target == "/slow" {
|
||||
ready.notify_one();
|
||||
released.notified().await;
|
||||
}
|
||||
Ok(Response { status: 200, headers: vec![], body: request.target.into_bytes().into() })
|
||||
}
|
||||
}).await
|
||||
}).unwrap();
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16)).await.unwrap();
|
||||
let (mut sender, connection) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake::<_, Full<bytes::Bytes>>(TokioIo::new(stream)).await.unwrap();
|
||||
let driver = scope.spawn_leaf(move || connection).unwrap();
|
||||
let message = |path: &str| hyper::Request::builder().uri(format!("http://fixture.test{path}"))
|
||||
.body(Full::new(bytes::Bytes::new())).unwrap();
|
||||
let slow = sender.send_request(message("/slow"));
|
||||
tokio::pin!(slow);
|
||||
tokio::select! {
|
||||
result = &mut slow => panic!("slow stream finished before release: {result:?}"),
|
||||
() = started.notified() => (),
|
||||
() = tokio::time::sleep(Duration::from_secs(3)) => panic!("slow stream did not start"),
|
||||
}
|
||||
let fast = tokio::time::timeout(Duration::from_secs(3), sender.send_request(message("/fast"))).await.unwrap().unwrap();
|
||||
assert_eq!(fast.into_body().collect().await.unwrap().to_bytes(), b"/fast"[..]);
|
||||
listener.close().unwrap();
|
||||
release.notify_one();
|
||||
let slow = slow.await.unwrap();
|
||||
assert_eq!(slow.into_body().collect().await.unwrap().to_bytes(), b"/slow"[..]);
|
||||
drop(sender);
|
||||
tokio::time::timeout(Duration::from_secs(3), server.wait()).await.unwrap().unwrap().unwrap();
|
||||
driver.stop().await.unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http2_limits_reject_invalid_windows_and_total_stream_capacity() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
for http2 in [
|
||||
http::Http2Limits {
|
||||
streams: 2049,
|
||||
..http::Http2Limits::default()
|
||||
},
|
||||
http::Http2Limits {
|
||||
streams: 0,
|
||||
..http::Http2Limits::default()
|
||||
},
|
||||
http::Http2Limits {
|
||||
stream_window_bytes: 0,
|
||||
..http::Http2Limits::default()
|
||||
},
|
||||
http::Http2Limits {
|
||||
connection_window_bytes: 65534,
|
||||
..http::Http2Limits::default()
|
||||
},
|
||||
] {
|
||||
let limits = Limits {
|
||||
http2: Some(http2),
|
||||
..Limits::default()
|
||||
};
|
||||
assert!(
|
||||
http::send("127.0.0.1", 1, request("/", b""), &limits)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("HTTP/2 limits")
|
||||
);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use super::*;
|
||||
use crate::wire::document::{MAX_BYTES, MAX_VALUES};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Arguments {
|
||||
name: String,
|
||||
count: i64,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn form_decode_cost() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for bytes in [64, 4096, 65536] {
|
||||
let text = "a".repeat(bytes);
|
||||
for sample in 0..15 {
|
||||
for offset in 0..2 {
|
||||
let native = (sample + offset) % 2 == 1;
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
let fields = [
|
||||
("name".into(), black_box(&text).clone()),
|
||||
("count".into(), "-7".into()),
|
||||
("enabled".into(), "true".into()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let result: Arguments = if native {
|
||||
native_form(fields).unwrap()
|
||||
} else {
|
||||
Input::fields(fields).decode(crate::wire::decode).unwrap()
|
||||
};
|
||||
assert_eq!(result.count, -7);
|
||||
assert!(result.enabled);
|
||||
checksum += black_box(result.name.len());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
let implementation = if native { "rust" } else { "dever" };
|
||||
println!("form_{bytes},{sample},{implementation},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn native_form(mut fields: BTreeMap<String, String>) -> Option<Arguments> {
|
||||
if fields.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let result = Arguments {
|
||||
name: fields.remove("name")?,
|
||||
count: fields.remove("count")?.parse().ok()?,
|
||||
enabled: fields.remove("enabled")?.parse().ok()?,
|
||||
};
|
||||
// 字段名固定;Int、Bool 和标点合计不超过 60 字节。
|
||||
if result.name.len().saturating_mul(6).saturating_add(60) > MAX_BYTES
|
||||
&& serde_json::to_vec(&result).ok()?.len() > MAX_BYTES
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_fields_use_target_types_without_url_decoding() {
|
||||
let fields = BTreeMap::from([
|
||||
("name".into(), "%FF+雪".into()),
|
||||
("count".into(), "-7".into()),
|
||||
("enabled".into(), "true".into()),
|
||||
]);
|
||||
let parsed: Arguments = Input::fields(fields.clone())
|
||||
.decode(crate::wire::decode)
|
||||
.unwrap();
|
||||
assert_eq!(parsed, native_form(fields.clone()).unwrap());
|
||||
assert_eq!(parsed.name, "%FF+雪");
|
||||
for (name, value) in [("count", "1.5"), ("enabled", "1"), ("extra", "x")] {
|
||||
let mut invalid = fields.clone();
|
||||
invalid.insert(name.into(), value.into());
|
||||
assert!(
|
||||
Input::fields(invalid.clone())
|
||||
.decode::<Arguments>(crate::wire::decode)
|
||||
.is_err()
|
||||
);
|
||||
assert!(native_form(invalid).is_none());
|
||||
}
|
||||
let mut missing = fields;
|
||||
missing.remove("enabled");
|
||||
assert!(
|
||||
Input::fields(missing)
|
||||
.decode::<Arguments>(crate::wire::decode)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn query_request(query: &str) -> Request {
|
||||
hyper::Request::builder()
|
||||
.uri(format!("/test?{query}"))
|
||||
.body(Default::default())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_fields_decode_without_changing_argument_rules() {
|
||||
let request = query_request("name=%E9%9B%AA+test&count=-7&enabled=true");
|
||||
assert_eq!(
|
||||
input(&request)
|
||||
.unwrap()
|
||||
.decode::<Arguments>(crate::wire::decode)
|
||||
.unwrap(),
|
||||
Arguments {
|
||||
name: "雪 test".into(),
|
||||
count: -7,
|
||||
enabled: true
|
||||
}
|
||||
);
|
||||
for query in [
|
||||
"name=x&name=y",
|
||||
"unknown=x",
|
||||
"count=1.5",
|
||||
"enabled=1",
|
||||
"name=%FF",
|
||||
"name=%",
|
||||
] {
|
||||
assert!(
|
||||
input(&query_request(query))
|
||||
.unwrap()
|
||||
.decode::<Arguments>(crate::wire::decode)
|
||||
.is_err(),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
input(&query_request("name=x"))
|
||||
.unwrap()
|
||||
.decode::<Arguments>(crate::wire::decode)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_budgets_count_json_escaping_and_root_value() {
|
||||
let mut fields = BTreeMap::new();
|
||||
fields.insert("text".into(), "\0".repeat(MAX_BYTES / 6));
|
||||
assert!(
|
||||
Input::fields(fields)
|
||||
.decode::<BTreeMap<String, String>>(crate::wire::decode)
|
||||
.is_err()
|
||||
);
|
||||
let fields = (0..MAX_VALUES - 1)
|
||||
.map(|index| (index.to_string(), "false".to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert!(
|
||||
Input::fields(fields.clone())
|
||||
.decode::<BTreeMap<String, String>>(crate::wire::decode)
|
||||
.is_ok()
|
||||
);
|
||||
let mut fields = fields;
|
||||
fields.insert("extra".into(), "true".to_owned());
|
||||
assert!(
|
||||
Input::fields(fields)
|
||||
.decode::<BTreeMap<String, String>>(crate::wire::decode)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_rejects_bad_trailing_optional_fields_and_non_finite_numbers() {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Scalars {
|
||||
ratio: f64,
|
||||
tag: Option<String>,
|
||||
}
|
||||
for query in [
|
||||
"ratio=1&tag=%",
|
||||
"ratio=1&tag=%FF",
|
||||
"ratio=1&tag",
|
||||
"ratio=1&tag=a&tag=b",
|
||||
"ratio=1&extra=x",
|
||||
"ratio=NaN",
|
||||
"ratio=inf",
|
||||
"ratio=1e999",
|
||||
] {
|
||||
assert!(
|
||||
input(&query_request(query))
|
||||
.unwrap()
|
||||
.decode::<Scalars>(crate::wire::decode)
|
||||
.is_err(),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
let result: Scalars = input(&query_request("ratio=-0&tag=%E9%9B%AA+test"))
|
||||
.unwrap()
|
||||
.decode(crate::wire::decode)
|
||||
.unwrap();
|
||||
assert_eq!(result.ratio.to_bits(), (-0.0f64).to_bits());
|
||||
assert_eq!(result.tag.as_deref(), Some("雪 test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_query_uses_exact_existing_json_byte_budget() {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TextArgument {
|
||||
text: String,
|
||||
}
|
||||
let overhead = serde_json::to_vec(&serde_json::json!({"text":""}))
|
||||
.unwrap()
|
||||
.len();
|
||||
for (plain, count) in [
|
||||
(true, MAX_BYTES / 6 + 1),
|
||||
(true, MAX_BYTES - overhead),
|
||||
(true, MAX_BYTES - overhead + 1),
|
||||
(false, (MAX_BYTES - overhead) / 6),
|
||||
(false, (MAX_BYTES - overhead) / 6 + 1),
|
||||
] {
|
||||
let text = if plain { "a" } else { "\0" }.repeat(count);
|
||||
let encoded = if plain { "a" } else { "%00" }.repeat(count);
|
||||
let source = serde_json::to_string(&serde_json::json!({"text": text})).unwrap();
|
||||
let expected = crate::wire::decode::<TextArgument>(&source);
|
||||
let fields = BTreeMap::from([("text".into(), text)]);
|
||||
let form = Input::fields(fields).decode::<TextArgument>(crate::wire::decode);
|
||||
assert_eq!(form.is_ok(), expected.is_ok());
|
||||
if let (Ok(form), Ok(expected)) = (&form, &expected) {
|
||||
assert_eq!(form.text, expected.text);
|
||||
}
|
||||
// 此处独立验证字段 codec 的 JSON 预算;超长 URI 会先被 HTTP 库拒绝。
|
||||
let query = format!("text={encoded}");
|
||||
let actual = Input(Data::Query(&query)).decode::<TextArgument>(crate::wire::decode);
|
||||
assert_eq!(
|
||||
actual.is_ok(),
|
||||
expected.is_ok(),
|
||||
"plain={plain}, count={count}"
|
||||
);
|
||||
if let (Ok(actual), Ok(expected)) = (actual, expected) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "calendar", feature = "uuid"))]
|
||||
#[test]
|
||||
fn query_uses_existing_scalar_codecs_and_optional_types() {
|
||||
use crate::{
|
||||
time::calendar::{Date, DateTime, Duration, Time},
|
||||
value::{decimal::Decimal, id::Id, uuid::Uuid},
|
||||
};
|
||||
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Scalars {
|
||||
decimal: Decimal,
|
||||
id: Id,
|
||||
uuid: Uuid,
|
||||
date: Date,
|
||||
time: Time,
|
||||
datetime: DateTime,
|
||||
duration: Duration,
|
||||
count: Option<i64>,
|
||||
enabled: Option<bool>,
|
||||
ratio: Option<f64>,
|
||||
}
|
||||
|
||||
let fields = serde_json::json!({
|
||||
"decimal": "123456789.0123456789",
|
||||
"id": "雪",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"date": "2026-10-26",
|
||||
"time": "12:34:56.789",
|
||||
"datetime": "2026-10-26T12:34:56Z",
|
||||
"duration": -1500,
|
||||
"count": 7,
|
||||
"enabled": true,
|
||||
"ratio": 1.25
|
||||
});
|
||||
let expected: Scalars = crate::wire::decode(&fields.to_string()).unwrap();
|
||||
let query = "decimal=123456789.0123456789&id=%E9%9B%AA&uuid=550e8400-e29b-41d4-a716-446655440000&date=2026-10-26&time=12:34:56.789&datetime=2026-10-26T12:34:56Z&duration=-1500&count=7&enabled=true&ratio=1.25";
|
||||
let actual: Scalars = input(&query_request(query))
|
||||
.unwrap()
|
||||
.decode(crate::wire::decode)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
for (valid, invalid) in [
|
||||
("123456789.0123456789", "NaN"),
|
||||
("550e8400-e29b-41d4-a716-446655440000", "bad-uuid"),
|
||||
("date=2026-10-26", "date=2026-02-30"),
|
||||
("time=12:34:56.789", "time=25:34:56.789"),
|
||||
("duration=-1500", "duration=1.5"),
|
||||
("count=7", "count=9223372036854775808"),
|
||||
("enabled=true", "enabled=yes"),
|
||||
("ratio=1.25", "ratio=NaN"),
|
||||
] {
|
||||
assert!(
|
||||
input(&query_request(&query.replace(valid, invalid)))
|
||||
.unwrap()
|
||||
.decode::<Scalars>(crate::wire::decode)
|
||||
.is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
struct Running(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_and_stop_drain_handler_descendants() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for cancel in [false, true] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let running = Arc::new(AtomicUsize::new(0));
|
||||
let live = running.clone();
|
||||
let started = Arc::new(Notify::new());
|
||||
let ready = started.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
let limits = Limits {
|
||||
timeout_ms: if cancel { 5000 } else { 100 },
|
||||
..Limits::default()
|
||||
};
|
||||
http::serve::<_, _, true>(
|
||||
&incoming,
|
||||
&limits,
|
||||
&child,
|
||||
move |request, scope| {
|
||||
let live = live.clone();
|
||||
let ready = ready.clone();
|
||||
async move {
|
||||
let pending = scope
|
||||
.spawn_leaf(move || async move {
|
||||
live.fetch_add(1, Ordering::SeqCst);
|
||||
let _running = Running(live);
|
||||
ready.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
pending.wait().await.unwrap();
|
||||
route(request, scope).await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let client = scope
|
||||
.spawn_leaf(move || async move {
|
||||
http::send("127.0.0.1", port, request("/", b""), &Limits::default()).await
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), started.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(running.load(Ordering::SeqCst), 1);
|
||||
if cancel {
|
||||
server.stop().await.unwrap();
|
||||
assert!(client.wait().await.unwrap().is_err());
|
||||
listener.close().unwrap();
|
||||
} else {
|
||||
assert_eq!(client.wait().await.unwrap().unwrap().status, 504);
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}
|
||||
assert_eq!(running.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_listener_drains_an_inflight_response() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let started = Arc::new(Notify::new());
|
||||
let ready = started.clone();
|
||||
let release = Arc::new(Notify::new());
|
||||
let released = release.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&child,
|
||||
move |request, scope| {
|
||||
let ready = ready.clone();
|
||||
let released = released.clone();
|
||||
async move {
|
||||
ready.notify_one();
|
||||
released.notified().await;
|
||||
route(request, scope).await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let client = scope
|
||||
.spawn_leaf(move || async move {
|
||||
http::send(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
request("/", b"drained"),
|
||||
&Limits::default(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), started.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
listener.close().unwrap();
|
||||
release.notify_one();
|
||||
assert_eq!(
|
||||
client.wait().await.unwrap().unwrap().body.as_ref(),
|
||||
b"drained"
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(3), server.wait())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_deadline_drops_its_connection_driver() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = i64::from(listener.local_addr().unwrap().port());
|
||||
let peer = scope
|
||||
.spawn(move |child| async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut stream = stream.into_std().unwrap();
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
child
|
||||
.blocking(move || {
|
||||
let mut data = Vec::new();
|
||||
stream.read_to_end(&mut data).unwrap();
|
||||
assert!(data.starts_with(b"POST / HTTP/1.1"));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
let limits = Limits {
|
||||
timeout_ms: 100,
|
||||
..Limits::default()
|
||||
};
|
||||
let error = http::send("127.0.0.1", port, request("/", b""), &limits)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "HTTP request timed out");
|
||||
peer.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_service_configuration_keeps_listener_available() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let limits = Limits {
|
||||
connections: 0,
|
||||
..Limits::default()
|
||||
};
|
||||
assert_eq!(
|
||||
http::serve::<_, _, false>(&listener, &limits, scope, route).await,
|
||||
Err(http::ServeError::Limits)
|
||||
);
|
||||
assert!(listener.port().unwrap() > 0);
|
||||
listener.close().unwrap();
|
||||
assert_eq!(
|
||||
http::serve::<_, _, false>(&listener, &Limits::default(), scope, route).await,
|
||||
Err(http::ServeError::Listener)
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_task_pool_preserves_connections_and_resumes_accepting() {
|
||||
capacity_recovery(CapacityAction::Resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_listener_interrupts_waiting_for_task_capacity() {
|
||||
capacity_recovery(CapacityAction::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_server_interrupts_waiting_for_task_capacity() {
|
||||
capacity_recovery(CapacityAction::Cancel);
|
||||
}
|
||||
|
||||
enum CapacityAction {
|
||||
Resume,
|
||||
Close,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn capacity_recovery(action: CapacityAction) {
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper_util::rt::TokioIo;
|
||||
|
||||
Runtime::new(dever_runtime::task::Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 3,
|
||||
})
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap() as u16;
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(&incoming, &Limits::default(), &child, route).await
|
||||
})
|
||||
.unwrap();
|
||||
let mut clients = Vec::new();
|
||||
let mut drivers = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.unwrap();
|
||||
let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(socket))
|
||||
.await
|
||||
.unwrap();
|
||||
clients.push(sender);
|
||||
drivers.push(tokio::spawn(connection));
|
||||
}
|
||||
let request = || {
|
||||
hyper::Request::builder()
|
||||
.uri("/echo")
|
||||
.header("host", "fixture.test")
|
||||
.body(Empty::<bytes::Bytes>::new())
|
||||
.unwrap()
|
||||
};
|
||||
for client in &mut clients[..2] {
|
||||
let response = client.send_request(request()).await.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
response.into_body().collect().await.unwrap();
|
||||
}
|
||||
let mut waiting = Box::pin(clients[2].send_request(request()));
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut waiting)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
if matches!(action, CapacityAction::Resume) {
|
||||
let response = clients[0].send_request(request()).await.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
response.into_body().collect().await.unwrap();
|
||||
drivers[0].abort();
|
||||
let response = tokio::time::timeout(Duration::from_secs(3), &mut waiting)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
response.into_body().collect().await.unwrap();
|
||||
}
|
||||
if matches!(action, CapacityAction::Cancel) {
|
||||
tokio::time::timeout(Duration::from_secs(3), server.stop())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
listener.close().unwrap();
|
||||
} else {
|
||||
listener.close().unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), server.wait())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
drop(waiting);
|
||||
drop(clients);
|
||||
for driver in drivers {
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
}
|
||||
assert_eq!(
|
||||
scope
|
||||
.spawn_leaf(|| async { 42 })
|
||||
.unwrap()
|
||||
.wait()
|
||||
.await
|
||||
.unwrap(),
|
||||
42
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_budgets_timeouts_invalid_messages_and_recovery_are_bounded() {
|
||||
runtime().run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope.spawn(move |child| async move {
|
||||
let limits = Limits { body_bytes: 4, timeout_ms: 100, ..Limits::default() };
|
||||
http::serve::<_, _, false>(&incoming, &limits, &child, route).await
|
||||
}).unwrap();
|
||||
scope.blocking(move || {
|
||||
let query_only = format!("GET ?{} HTTP/1.1\r\nHost: t\r\n\r\n", "a".repeat(8163));
|
||||
for (message, expected) in [
|
||||
("POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 5\r\n\r\n12345", 413),
|
||||
("POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 4\r\n\r\n1", 408),
|
||||
("GET /wait HTTP/1.1\r\nHost: fixture.test\r\n\r\n", 504),
|
||||
("GET /echo HTTP/1.1\r\nHost: one\r\nHost: two\r\n\r\n", 400),
|
||||
(query_only.as_str(), 400),
|
||||
] {
|
||||
let mut stream = connect(port);
|
||||
stream.get_mut().write_all(message.as_bytes()).unwrap();
|
||||
assert_eq!(read_response(&mut stream, false).0, expected);
|
||||
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
|
||||
}
|
||||
let mut stream = connect(port);
|
||||
stream.get_mut().write_all(b"GET / HTTP/1.1\r\nHost:").unwrap();
|
||||
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
|
||||
}).await.unwrap();
|
||||
let response = http::send("127.0.0.1", port, request("/echo", b"ok"), &Limits::default()).await.unwrap();
|
||||
assert_eq!(response.body.as_ref(), b"ok");
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_contracts_reject_framing_injection_and_bad_limits() {
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
for header in [
|
||||
Header {
|
||||
name: "Content-Length".into(),
|
||||
value: b"0".to_vec().into(),
|
||||
},
|
||||
Header {
|
||||
name: "Transfer-Encoding".into(),
|
||||
value: b"chunked".to_vec().into(),
|
||||
},
|
||||
Header {
|
||||
name: "x-test".into(),
|
||||
value: b"a\r\nb".to_vec().into(),
|
||||
},
|
||||
] {
|
||||
let mut message = request("/", b"");
|
||||
message.headers.push(header);
|
||||
let error = http::send("127.0.0.1", 1, message, &Limits::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.contains("connection failed"), "{error}");
|
||||
}
|
||||
for limits in [
|
||||
Limits {
|
||||
header_bytes: 0,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
body_bytes: -1,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
timeout_ms: 0,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
connections: 65537,
|
||||
..Limits::default()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
http::send("127.0.0.1", 1, request("/", b""), &limits)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let long_target = format!("/{}", "x".repeat(8192));
|
||||
for (method, target) in [
|
||||
("CONNECT", "/"),
|
||||
("GET", "http://example.test/"),
|
||||
("GET", "/#fragment"),
|
||||
("GET", long_target.as_str()),
|
||||
] {
|
||||
let mut message = request(target, b"");
|
||||
message.method = method.into();
|
||||
let error = http::send("127.0.0.1", 1, message, &Limits::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.contains("connection failed"), "{error}");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use super::*;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[test]
|
||||
fn head_and_disconnect_cancel_requests_before_reusing_the_connection() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let running = active.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
move |request, reply, _| {
|
||||
let active = active.clone();
|
||||
async move {
|
||||
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
|
||||
let _running = Running(active);
|
||||
reply.start(200, vec![]).await.unwrap();
|
||||
if request.method == "HEAD" {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
reply.write(b"data").await.unwrap();
|
||||
std::future::pending::<Result<(), String>>().await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
|
||||
let response = sender.send_request(message("HEAD")).await.unwrap();
|
||||
assert!(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(running.load(Ordering::SeqCst), 0);
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
let mut body = response.into_body();
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b"data"[..]
|
||||
);
|
||||
drop(body);
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while running.load(Ordering::SeqCst) != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_peer_bounds_writer_and_parent_stop_drains_blocking_descendants() {
|
||||
for sse in [false, true] {
|
||||
slow_peer_case(sse);
|
||||
}
|
||||
}
|
||||
|
||||
fn slow_peer_case(sse: bool) {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let written = count.clone();
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let running = active.clone();
|
||||
let ready = Arc::new(Notify::new());
|
||||
let started = ready.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits {
|
||||
timeout_ms: 5000,
|
||||
..Limits::default()
|
||||
},
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
move |_, reply, scope| {
|
||||
let written = written.clone();
|
||||
let active = active.clone();
|
||||
let ready = ready.clone();
|
||||
async move {
|
||||
let worker = scope
|
||||
.spawn(move |child| async move {
|
||||
child
|
||||
.blocking(move || {
|
||||
active.fetch_add(1, Ordering::SeqCst);
|
||||
let _running = Running(active);
|
||||
ready.notify_one();
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
})
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
if sse {
|
||||
reply.sse_start(vec![]).await.unwrap();
|
||||
} else {
|
||||
reply.start(200, vec![]).await.unwrap();
|
||||
}
|
||||
let bytes = vec![0; 65536];
|
||||
let event = http::Event {
|
||||
event: String::new(),
|
||||
data: "a".repeat(65528),
|
||||
id: None,
|
||||
retry_ms: None,
|
||||
};
|
||||
for _ in 0..10000 {
|
||||
let result = if sse {
|
||||
reply.sse_send(&event).await
|
||||
} else {
|
||||
reply.write(&bytes).await
|
||||
};
|
||||
result.map_err(|error| error.to_string())?;
|
||||
written.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
worker.wait().await.unwrap().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let mut socket =
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", listener.port().unwrap() as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
use tokio::io::AsyncWriteExt;
|
||||
socket
|
||||
.write_all(b"GET / HTTP/1.1\r\nHost: fixture.test\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
started.notified().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(count.load(Ordering::SeqCst) < 10000);
|
||||
server.stop().await.unwrap();
|
||||
assert_eq!(running.load(Ordering::SeqCst), 0);
|
||||
listener.close().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_header_and_idle_deadlines_are_distinct_and_faults_truncate() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for mode in ["headers", "idle", "fault", "missing"] {
|
||||
let gate = Arc::new(Semaphore::new(0));
|
||||
let failed = gate.clone();
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits {
|
||||
timeout_ms: 100,
|
||||
..Limits::default()
|
||||
},
|
||||
&http::LiveLimits {
|
||||
idle_ms: 60,
|
||||
heartbeat_ms: 20,
|
||||
chunk_bytes: 64,
|
||||
},
|
||||
&child,
|
||||
move |_, reply, _| {
|
||||
let gate = failed.clone();
|
||||
async move {
|
||||
if mode == "missing" {
|
||||
return Ok(());
|
||||
}
|
||||
if mode == "headers" {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
reply.start(200, vec![]).await.unwrap();
|
||||
if mode == "idle" {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
reply.write(b"partial").await.unwrap();
|
||||
let _permit = gate.acquire().await.unwrap();
|
||||
Err("located handler failure".into())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
match mode {
|
||||
"headers" => assert_eq!(response.status(), 504),
|
||||
"missing" => assert_eq!(response.status(), 500),
|
||||
_ => {
|
||||
assert_eq!(response.status(), 200);
|
||||
let mut body = response.into_body();
|
||||
if mode == "fault" {
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b"partial"[..]
|
||||
);
|
||||
gate.add_permits(1);
|
||||
}
|
||||
assert!(body.collect().await.is_err());
|
||||
}
|
||||
}
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
mod lifecycle;
|
||||
mod performance;
|
||||
mod transport;
|
||||
|
||||
async fn client(
|
||||
port: i64,
|
||||
scope: &Scope<'_>,
|
||||
) -> (
|
||||
hyper::client::conn::http1::SendRequest<Empty<Bytes>>,
|
||||
dever_runtime::task::Task<()>,
|
||||
) {
|
||||
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(socket))
|
||||
.await
|
||||
.unwrap();
|
||||
let driver = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _ = connection.await;
|
||||
})
|
||||
.unwrap();
|
||||
(sender, driver)
|
||||
}
|
||||
|
||||
fn message(method: &str) -> hyper::Request<Empty<Bytes>> {
|
||||
hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri("/")
|
||||
.header("Host", "fixture.test")
|
||||
.body(Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct Running(Arc<AtomicUsize>);
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_encoding_preserves_lines_ids_and_exact_limit() {
|
||||
let event = http::Event {
|
||||
event: "change".into(),
|
||||
data: "一\r\n二\r\n\r".into(),
|
||||
id: Some("".into()),
|
||||
retry_ms: Some(0),
|
||||
};
|
||||
let expected = "event: change\nid: \nretry: 0\ndata: 一\ndata: 二\ndata: \ndata: \n\n";
|
||||
assert_eq!(event.encode(expected.len()).unwrap(), expected);
|
||||
assert!(event.encode(expected.len() - 1).is_err());
|
||||
for event in [
|
||||
http::Event {
|
||||
event: "bad\n".into(),
|
||||
..event.clone()
|
||||
},
|
||||
http::Event {
|
||||
id: Some("bad\0".into()),
|
||||
..event.clone()
|
||||
},
|
||||
http::Event {
|
||||
retry_ms: Some(-1),
|
||||
..event.clone()
|
||||
},
|
||||
] {
|
||||
assert!(event.encode(1024).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_chunk_arrives_before_handler_finishes_and_outlives_head_deadline() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let gate = Arc::new(Semaphore::new(0));
|
||||
let waiting = gate.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
let limits = Limits {
|
||||
timeout_ms: 100,
|
||||
..Limits::default()
|
||||
};
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&limits,
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
move |_, reply, _| {
|
||||
let gate = waiting.clone();
|
||||
async move {
|
||||
reply
|
||||
.start(200, vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
reply
|
||||
.write(b"first")
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _permit = gate.acquire().await.unwrap();
|
||||
reply
|
||||
.write(b"last")
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut sender, driver) = client(port, scope).await;
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let mut body = response.into_body();
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b"first"[..]
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(180)).await;
|
||||
gate.add_permits(1);
|
||||
assert_eq!(body.collect().await.unwrap().to_bytes(), b"last"[..]);
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_heartbeats_keep_session_alive_and_stop_drains_handler() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let running = Arc::new(AtomicUsize::new(0));
|
||||
let active = running.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits {
|
||||
timeout_ms: 80,
|
||||
..Limits::default()
|
||||
},
|
||||
&http::LiveLimits {
|
||||
chunk_bytes: 64,
|
||||
idle_ms: 100,
|
||||
heartbeat_ms: 20,
|
||||
},
|
||||
&child,
|
||||
move |_, reply, _| {
|
||||
let active = active.clone();
|
||||
async move {
|
||||
active.fetch_add(1, Ordering::SeqCst);
|
||||
let _running = Running(active);
|
||||
reply
|
||||
.sse_start(vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
std::future::pending::<Result<(), String>>().await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
assert_eq!(response.headers()["content-type"], "text/event-stream");
|
||||
assert_eq!(response.headers()["cache-control"], "no-cache");
|
||||
let mut body = response.into_body();
|
||||
for _ in 0..8 {
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b":\n\n"[..]
|
||||
);
|
||||
}
|
||||
assert_eq!(running.load(Ordering::SeqCst), 1);
|
||||
server.stop().await.unwrap();
|
||||
assert_eq!(running.load(Ordering::SeqCst), 0);
|
||||
drop(body);
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reply_operations_preserve_started_response_and_close_aliases() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let (sent, mut saved) = tokio::sync::mpsc::channel(1);
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&http::LiveLimits {
|
||||
chunk_bytes: 32,
|
||||
..http::LiveLimits::default()
|
||||
},
|
||||
&child,
|
||||
move |_, reply, _| {
|
||||
let sent = sent.clone();
|
||||
async move {
|
||||
assert!(reply.write(b"before").await.is_err());
|
||||
assert!(reply.start(204, vec![]).await.is_err());
|
||||
reply.sse_start(vec![]).await.unwrap();
|
||||
assert!(reply.start(200, vec![]).await.is_err());
|
||||
assert!(reply.write(b"ordinary").await.is_err());
|
||||
assert!(
|
||||
reply
|
||||
.sse_send(&http::Event {
|
||||
event: "".into(),
|
||||
data: "x".repeat(33),
|
||||
id: None,
|
||||
retry_ms: None
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
reply
|
||||
.sse_send(&http::Event {
|
||||
event: "".into(),
|
||||
data: "ok".into(),
|
||||
id: None,
|
||||
retry_ms: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
reply.finish().await.unwrap();
|
||||
assert!(reply.finish().await.is_err());
|
||||
assert!(
|
||||
reply
|
||||
.sse_send(&http::Event {
|
||||
event: "".into(),
|
||||
data: "late".into(),
|
||||
id: None,
|
||||
retry_ms: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
sent.send(reply).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"data: ok\n\n"[..]
|
||||
);
|
||||
let alias = saved.recv().await.unwrap();
|
||||
assert!(alias.start(200, vec![]).await.is_err());
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
use futures_util::stream;
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::{body::Frame, service::service_fn};
|
||||
use std::{convert::Infallible, time::Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
async fn independent_server(
|
||||
data: Arc<str>,
|
||||
iterations: usize,
|
||||
limit: usize,
|
||||
) -> (i64, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = i64::from(listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
socket.set_nodelay(true).unwrap();
|
||||
let service = service_fn(move |_: hyper::Request<hyper::body::Incoming>| {
|
||||
let data = data.clone();
|
||||
async move {
|
||||
let (sender, receiver) = mpsc::channel::<Bytes>(1);
|
||||
let producer = tokio::spawn(async move {
|
||||
for _ in 0..iterations {
|
||||
assert!(!data.contains(['\r', '\n']));
|
||||
assert!(data.len().checked_add(8).unwrap() <= limit);
|
||||
let encoded = format!("data: {data}\n\n");
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
sender.send(Bytes::from(encoded)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let frames = stream::unfold(
|
||||
(receiver, Some(producer)),
|
||||
|(mut receiver, mut producer)| async move {
|
||||
match receiver.recv().await {
|
||||
Some(bytes) => Some((
|
||||
Ok::<_, Infallible>(Frame::data(bytes)),
|
||||
(receiver, producer),
|
||||
)),
|
||||
None => {
|
||||
producer.take().unwrap().await.unwrap();
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Ok::<_, Infallible>(
|
||||
hyper::Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "text/event-stream")
|
||||
.header("cache-control", "no-cache")
|
||||
.body(StreamBody::new(frames))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(socket), service)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
(port, server)
|
||||
}
|
||||
|
||||
async fn sample(
|
||||
sender: &mut hyper::client::conn::http1::SendRequest<Empty<Bytes>>,
|
||||
expected: &[u8],
|
||||
iterations: usize,
|
||||
) -> (u128, usize) {
|
||||
let started = Instant::now();
|
||||
let (response, chunks) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let response = sender.send_request(message("GET")).await.unwrap();
|
||||
let (response, mut body) = response.into_parts();
|
||||
let mut chunks = Vec::with_capacity(iterations);
|
||||
while let Some(frame) = body.frame().await {
|
||||
chunks.push(frame.unwrap().into_data().unwrap());
|
||||
}
|
||||
(response, chunks)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.headers["content-type"], "text/event-stream");
|
||||
let total = chunks.iter().map(Bytes::len).sum::<usize>();
|
||||
assert_eq!(total, expected.len() * iterations);
|
||||
assert!(
|
||||
chunks
|
||||
.iter()
|
||||
.flat_map(|bytes| bytes.iter().copied())
|
||||
.eq(expected.iter().copied().cycle().take(total))
|
||||
);
|
||||
(elapsed, total)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn sse_send_cost() {
|
||||
let runtime = Runtime::new(dever_runtime::task::Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
eprintln!(
|
||||
"sse_send: 15 fixed rounds; one async worker; persistent HTTP/1 loopback; \
|
||||
both implementations encode each single-line data event and use a one-chunk queue; \
|
||||
rust_independent uses format!, Bytes and Hyper/Tokio directly, without Dever's SSE encoder/send; \
|
||||
timed request through complete body includes network and bounded send; \
|
||||
setup, exact byte verification and teardown excluded; \
|
||||
successful-stream workload only, not lifecycle/heartbeat parity"
|
||||
);
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,bytes");
|
||||
for (size, iterations) in [(64, 4096), (65_536, 128), (1_048_576, 16)] {
|
||||
let event = Arc::new(http::Event {
|
||||
event: String::new(),
|
||||
data: "a".repeat(size),
|
||||
id: None,
|
||||
retry_ms: None,
|
||||
});
|
||||
let expected = format!("data: {}\n\n", event.data);
|
||||
let limit = expected.len();
|
||||
let encoded = event.encode(limit).unwrap();
|
||||
println!(
|
||||
"sse_capacity,{size},{},{}",
|
||||
encoded.len(),
|
||||
encoded.capacity()
|
||||
);
|
||||
assert_eq!(encoded, expected);
|
||||
drop(encoded);
|
||||
let (independent_port, independent) = independent_server(
|
||||
Arc::from(event.data.as_str()),
|
||||
iterations,
|
||||
limit,
|
||||
)
|
||||
.await;
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits {
|
||||
timeout_ms: 5000,
|
||||
..Limits::default()
|
||||
},
|
||||
&http::LiveLimits {
|
||||
chunk_bytes: limit as i64,
|
||||
heartbeat_ms: 30000,
|
||||
idle_ms: 60000,
|
||||
},
|
||||
&child,
|
||||
move |_, reply, _| {
|
||||
let event = event.clone();
|
||||
async move {
|
||||
reply.sse_start(vec![]).await.unwrap();
|
||||
for _ in 0..iterations {
|
||||
reply.sse_send(&event).await.unwrap();
|
||||
}
|
||||
reply.finish().await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let (mut dever_client, dever_driver) =
|
||||
client(listener.port().unwrap(), scope).await;
|
||||
let (mut independent_client, independent_driver) =
|
||||
client(independent_port, scope).await;
|
||||
sample(&mut dever_client, expected.as_bytes(), iterations).await;
|
||||
sample(&mut independent_client, expected.as_bytes(), iterations).await;
|
||||
for round in 0..15 {
|
||||
let mut order = ["dever", "rust_independent", "control"];
|
||||
order.rotate_left(round % 3);
|
||||
for label in order {
|
||||
let sender = if label == "rust_independent" {
|
||||
&mut independent_client
|
||||
} else {
|
||||
&mut dever_client
|
||||
};
|
||||
let (elapsed, total) = sample(sender, expected.as_bytes(), iterations).await;
|
||||
println!("sse_send_{size},{round},{label},{iterations},{elapsed},{total}");
|
||||
}
|
||||
}
|
||||
drop(dever_client);
|
||||
drop(independent_client);
|
||||
dever_driver.wait().await.unwrap();
|
||||
independent_driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
independent.await.unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use super::*;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
|
||||
#[test]
|
||||
fn http2_live_reset_isolates_sse_neighbor_and_tls_uses_same_body_contract() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for secure in [false, true] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let running = Arc::new(AtomicUsize::new(0));
|
||||
let active = running.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
let limits = Limits {
|
||||
timeout_ms: 200,
|
||||
http2: Some(http::Http2Limits::default()),
|
||||
..Limits::default()
|
||||
};
|
||||
let live = http::LiveLimits {
|
||||
heartbeat_ms: 20,
|
||||
idle_ms: 1000,
|
||||
..http::LiveLimits::default()
|
||||
};
|
||||
let route =
|
||||
move |request: Request,
|
||||
reply: http::HttpReply,
|
||||
_: Arc<Scope<'static>>| {
|
||||
let running = active.clone();
|
||||
async move {
|
||||
if request.target == "/ordinary" {
|
||||
return reply
|
||||
.respond(Response {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: b"ok".to_vec().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
running.fetch_add(1, Ordering::SeqCst);
|
||||
let _running = Running(running);
|
||||
reply.sse_start(vec![]).await.unwrap();
|
||||
std::future::pending::<Result<(), String>>().await
|
||||
}
|
||||
};
|
||||
if secure {
|
||||
let identity = dever_runtime::tls::server(
|
||||
super::super::tls::CERTIFICATE,
|
||||
super::super::tls::KEY,
|
||||
)
|
||||
.unwrap();
|
||||
http::serve_live_tls(
|
||||
&incoming, &limits, &live, &child, &identity, route,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
http::serve_live(&incoming, &limits, &live, &child, route).await
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut sender, driver) = if secure {
|
||||
let trust = dever_runtime::tls::client(super::super::tls::ROOT).unwrap();
|
||||
let socket = trust.connect("localhost", socket, true).await.unwrap();
|
||||
let (sender, connection) =
|
||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake::<_, Empty<Bytes>>(TokioIo::new(socket))
|
||||
.await
|
||||
.unwrap();
|
||||
(
|
||||
sender,
|
||||
scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _ = connection.await;
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
} else {
|
||||
let (sender, connection) =
|
||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake::<_, Empty<Bytes>>(TokioIo::new(socket))
|
||||
.await
|
||||
.unwrap();
|
||||
(
|
||||
sender,
|
||||
scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _ = connection.await;
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
let request = |path| {
|
||||
hyper::Request::builder()
|
||||
.uri(format!(
|
||||
"{}://localhost:{port}/{path}",
|
||||
if secure { "https" } else { "http" }
|
||||
))
|
||||
.body(Empty::new())
|
||||
.unwrap()
|
||||
};
|
||||
let mut first = sender
|
||||
.send_request(request("first"))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_body();
|
||||
let mut second = sender
|
||||
.send_request(request("second"))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_body();
|
||||
assert_eq!(
|
||||
first.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b":\n\n"[..]
|
||||
);
|
||||
assert_eq!(running.load(Ordering::SeqCst), 2);
|
||||
drop(first);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while running.load(Ordering::SeqCst) != 1 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second.frame().await.unwrap().unwrap().into_data().unwrap(),
|
||||
b":\n\n"[..]
|
||||
);
|
||||
// 活动 SSE 响应超过连接空闲期限仍可继续传输。
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let ordinary = sender.send_request(request("ordinary")).await.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"ok"[..]
|
||||
);
|
||||
drop(second);
|
||||
drop(sender);
|
||||
driver.wait().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
assert_eq!(running.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use dever_runtime::{
|
||||
http::{self, Header, Limits, Request, Response},
|
||||
net,
|
||||
task::{Runtime, Scope},
|
||||
};
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
net::TcpStream,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod http2;
|
||||
mod lifecycle;
|
||||
mod limits;
|
||||
mod live;
|
||||
mod pool;
|
||||
mod streaming;
|
||||
mod tls;
|
||||
mod websocket;
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(dever_runtime::task::Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn request(target: &str, body: &[u8]) -> Request {
|
||||
Request {
|
||||
method: "POST".into(),
|
||||
target: target.into(),
|
||||
headers: vec![Header {
|
||||
name: "Host".into(),
|
||||
value: b"fixture.test".to_vec().into(),
|
||||
}],
|
||||
body: body.to_vec().into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn route(request: Request, _: Arc<Scope<'static>>) -> Result<Response, String> {
|
||||
if request.target == "/fail" {
|
||||
return Err("owned fixture failure".into());
|
||||
}
|
||||
if request.target == "/wait" {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
Ok(Response {
|
||||
status: 200,
|
||||
headers: vec![
|
||||
Header {
|
||||
name: "x-data".into(),
|
||||
value: vec![0x80].into(),
|
||||
},
|
||||
Header {
|
||||
name: "x-data".into(),
|
||||
value: b"second".to_vec().into(),
|
||||
},
|
||||
],
|
||||
body: request.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn connect(port: i64) -> BufReader<TcpStream> {
|
||||
let stream = TcpStream::connect(("127.0.0.1", port as u16)).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
BufReader::new(stream)
|
||||
}
|
||||
|
||||
fn read_response(stream: &mut BufReader<TcpStream>, head: bool) -> (u16, Vec<u8>) {
|
||||
let mut line = Vec::new();
|
||||
stream.read_until(b'\n', &mut line).unwrap();
|
||||
let status = std::str::from_utf8(&line)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut size = 0;
|
||||
loop {
|
||||
line.clear();
|
||||
assert!(stream.read_until(b'\n', &mut line).unwrap() > 0);
|
||||
if line == b"\r\n" {
|
||||
break;
|
||||
}
|
||||
if line.to_ascii_lowercase().starts_with(b"content-length:") {
|
||||
size = std::str::from_utf8(&line[15..])
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let mut body = vec![0; if head { 0 } else { size }];
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_and_independent_peer_cover_keepalive_chunking_and_request_isolation() {
|
||||
runtime().run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope.spawn(move |child| async move { http::serve::<_, _, false>(&incoming, &Limits::default(), &child, route).await }).unwrap();
|
||||
let response = http::send("127.0.0.1", port, request("/echo", b"owned body"), &Limits::default()).await.unwrap();
|
||||
assert_eq!(response.body.as_ref(), b"owned body");
|
||||
let headers = response.headers.iter().filter(|value| value.name == "x-data").map(|value| value.value.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(headers, vec![vec![0x80], b"second".to_vec()]);
|
||||
scope.blocking(move || {
|
||||
let mut stream = connect(port);
|
||||
for message in [
|
||||
&b"GET /echo HTTP/1.1\r\nHost: fixture.test\r\n\r\n"[..],
|
||||
&b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 0\r\n\r\n"[..],
|
||||
&b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"[..],
|
||||
] {
|
||||
stream.get_mut().write_all(message).unwrap();
|
||||
assert_eq!(read_response(&mut stream, false), (200, vec![]));
|
||||
}
|
||||
for (path, status) in [("/echo", 200), ("/fail", 500), ("/echo", 200)] {
|
||||
write!(stream.get_mut(), "POST {path} HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 3\r\n\r\nabc").unwrap();
|
||||
let (actual, body) = read_response(&mut stream, false);
|
||||
assert_eq!(actual, status);
|
||||
if status == 200 { assert_eq!(body, b"abc"); }
|
||||
}
|
||||
stream.get_mut().write_all(b"HEAD /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 3\r\n\r\nabc").unwrap();
|
||||
assert_eq!(read_response(&mut stream, true), (200, vec![]));
|
||||
stream.get_mut().write_all(b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n").unwrap();
|
||||
assert_eq!(read_response(&mut stream, false), (200, b"onetwo".to_vec()));
|
||||
let mut tail = Vec::new();
|
||||
stream.read_to_end(&mut tail).unwrap();
|
||||
assert!(tail.is_empty());
|
||||
}).await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::*;
|
||||
use dever_runtime::{http::PoolLimits, tls};
|
||||
|
||||
fn message() -> Request {
|
||||
Request {
|
||||
method: "POST".into(),
|
||||
target: "/echo".into(),
|
||||
headers: vec![],
|
||||
body: b"pooled".to_vec().into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_reject_invalid_ports_before_connecting() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for host in ["127.0.0.1", "[::1]", "localhost"] {
|
||||
for port in ["99999", "65536", "abc", "-1", ""] {
|
||||
for scheme in ["http", "https"] {
|
||||
assert!(
|
||||
http::client(
|
||||
&format!("{scheme}://{host}:{port}"),
|
||||
&tls::system(),
|
||||
&Limits::default(),
|
||||
&PoolLimits::default(),
|
||||
scope
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for scheme in ["ws", "wss"] {
|
||||
let error = http::websocket::open(
|
||||
&format!("{scheme}://{host}:{port}/"),
|
||||
&tls::system(),
|
||||
&http::websocket::Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("port") || error.contains("invalid WebSocket URL"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for suffix in ["", ":80", ":443", ":65535"] {
|
||||
let client = http::client(
|
||||
&format!("http://{host}{suffix}"),
|
||||
&tls::system(),
|
||||
&Limits::default(),
|
||||
&PoolLimits::default(),
|
||||
scope,
|
||||
)
|
||||
.unwrap();
|
||||
client.close().await.unwrap();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_bounds_http2_concurrency_and_cancels_active_requests() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let limits = Limits {
|
||||
connections: 1,
|
||||
http2: Some(http::Http2Limits {
|
||||
streams: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let peak = Arc::new(AtomicUsize::new(0));
|
||||
let peak_read = peak.clone();
|
||||
let incoming = listener.clone();
|
||||
let server_limits = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve::<_, _, false>(
|
||||
&incoming,
|
||||
&server_limits,
|
||||
&child,
|
||||
move |request, _| {
|
||||
let active = active.clone();
|
||||
let peak = peak.clone();
|
||||
async move {
|
||||
let count = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(count, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
Ok(Response {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: request.body,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let client = http::client(
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&tls::system(),
|
||||
&limits,
|
||||
&PoolLimits::default(),
|
||||
scope,
|
||||
)
|
||||
.unwrap();
|
||||
let replies =
|
||||
futures_util::future::join_all((0..8).map(|_| client.request(message()))).await;
|
||||
for response in replies {
|
||||
assert_eq!(response.unwrap().body.as_ref(), b"pooled");
|
||||
}
|
||||
assert_eq!(peak_read.load(Ordering::SeqCst), 2);
|
||||
let alias = client.clone();
|
||||
let pending = scope
|
||||
.spawn_leaf(move || async move { alias.request(message()).await })
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
client.close().await.unwrap();
|
||||
assert!(pending.wait().await.unwrap().is_err());
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_http1_http2_tls_reuse_close_and_scope_ownership() {
|
||||
for protocol in [false, true] {
|
||||
for secure in [false, true] {
|
||||
let escaped = runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let limits = Limits {
|
||||
connections: 1,
|
||||
http2: protocol.then(Default::default),
|
||||
..Limits::default()
|
||||
};
|
||||
let incoming = listener.clone();
|
||||
let server_limits = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
if secure {
|
||||
let config =
|
||||
tls::server(super::tls::CERTIFICATE, super::tls::KEY).unwrap();
|
||||
http::serve_tls::<_, _, false>(
|
||||
&incoming,
|
||||
&server_limits,
|
||||
&child,
|
||||
&config,
|
||||
route,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
http::serve::<_, _, false>(&incoming, &server_limits, &child, route)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let origin = format!(
|
||||
"{}://localhost:{port}",
|
||||
if secure { "https" } else { "http" }
|
||||
);
|
||||
let config = tls::client(super::tls::ROOT).unwrap();
|
||||
let client =
|
||||
http::client(&origin, &config, &limits, &PoolLimits::default(), scope)
|
||||
.unwrap();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
client.request(message()).await.unwrap().body.as_ref(),
|
||||
b"pooled"
|
||||
);
|
||||
}
|
||||
let mut changed = message();
|
||||
changed.headers.push(Header {
|
||||
name: "Host".into(),
|
||||
value: b"other.test".to_vec().into(),
|
||||
});
|
||||
assert!(client.request(changed).await.is_err());
|
||||
client.close().await.unwrap();
|
||||
client.close().await.unwrap();
|
||||
assert!(client.request(message()).await.is_err());
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
http::client(&origin, &config, &limits, &PoolLimits::default(), scope).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
assert!(
|
||||
escaped
|
||||
.request(message())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("closed")
|
||||
);
|
||||
escaped.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_reuses_one_tcp_connection_and_closes_it_before_returning() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let peer = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut stream = BufReader::new(stream);
|
||||
for _ in 0..3 {
|
||||
let mut size = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(stream.read_line(&mut line).unwrap() > 0);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
size = length.trim().parse().unwrap();
|
||||
}
|
||||
}
|
||||
let mut body = vec![0; size];
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
assert_eq!(body, b"pooled");
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = http::client(
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&tls::system(),
|
||||
&Limits::default(),
|
||||
&PoolLimits::default(),
|
||||
scope,
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
client.request(message()).await.unwrap().body.as_ref(),
|
||||
b"ok"
|
||||
);
|
||||
}
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
peer.join().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn headers_return_before_body_and_download_ignores_buffered_total_limit() {
|
||||
let (opened, permit) = std::sync::mpsc::channel();
|
||||
let (port, server) = peer(move |listener| {
|
||||
let mut socket = accept(&listener);
|
||||
headers(&mut socket);
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 100003\r\nX-Test: one\r\nX-Test: two\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
permit.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
socket.get_mut().write_all(&vec![b'x'; 100003]).unwrap();
|
||||
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
body_bytes: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits {
|
||||
chunk_bytes: 7,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let response = client.open(message(), event).await.unwrap();
|
||||
assert_eq!(
|
||||
response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|header| header.name == "x-test")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
opened.send(()).unwrap();
|
||||
let body = collect(&response.body, 7).await;
|
||||
assert_eq!(body, vec![b'x'; 100003]);
|
||||
assert!(response.body.next().await.unwrap().is_none());
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_body_holds_slot_and_idle_clock_starts_after_eof() {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut socket = accept(&listener);
|
||||
for _ in 0..2 {
|
||||
headers(&mut socket);
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nabcdef")
|
||||
.unwrap();
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
|
||||
assert!(start.elapsed() < Duration::from_secs(1));
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
connections: 1,
|
||||
timeout_ms: 80,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits {
|
||||
idle_ms: 20,
|
||||
chunk_bytes: 2,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let response = client.open(message(), event).await.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.open(message(), event)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("timed out")
|
||||
);
|
||||
assert_eq!(collect(&response.body, 2).await, b"abcdef");
|
||||
assert_eq!(
|
||||
client.request(message()).await.unwrap().body.as_ref(),
|
||||
b"abcdef"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_close_of_buffered_frame_closes_http1_and_next_request_reconnects() {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut first = accept(&listener);
|
||||
headers(&mut first);
|
||||
first
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nabcdef")
|
||||
.unwrap();
|
||||
assert_eq!(first.read(&mut [0]).unwrap(), 0);
|
||||
let mut second = accept(&listener);
|
||||
headers(&mut second);
|
||||
second
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.unwrap();
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
connections: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits::default(),
|
||||
);
|
||||
let response = client.open(message(), event).await.unwrap();
|
||||
let alias = response.body.clone();
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
response.body.close();
|
||||
assert!(alias.next().await.unwrap().is_none());
|
||||
assert_eq!(
|
||||
client.request(message()).await.unwrap().body.as_ref(),
|
||||
b"ok"
|
||||
);
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_timeout_and_protocol_failure_emit_one_error_then_eof() {
|
||||
for body in [
|
||||
b"".as_slice(),
|
||||
b"3\r\nabc\r\n0\r\nX-End: bad\r\n\r\n",
|
||||
b"invalid\r\n",
|
||||
] {
|
||||
let (port, server) = peer(move |listener| {
|
||||
let mut socket = accept(&listener);
|
||||
headers(&mut socket);
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
.unwrap();
|
||||
socket.get_mut().write_all(body).unwrap();
|
||||
let _ = socket.read(&mut [0]);
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits::default(),
|
||||
&PoolLimits {
|
||||
read_ms: 40,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let response = client.open(message(), event).await.unwrap();
|
||||
let mut failures = 0;
|
||||
while let Some(item) = response.body.next().await.unwrap() {
|
||||
failures += usize::from(item.is_err());
|
||||
}
|
||||
assert_eq!(failures, 1);
|
||||
assert!(response.body.next().await.unwrap().is_none());
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_consumer_closes_body_and_pool_close_wakes_body_and_slot_waiters() {
|
||||
for cancel in [true, false] {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut socket = accept(&listener);
|
||||
headers(&mut socket);
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\n")
|
||||
.unwrap();
|
||||
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
connections: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits::default(),
|
||||
);
|
||||
let response = client.open(message(), event).await.unwrap();
|
||||
let alias = response.body.clone();
|
||||
if cancel {
|
||||
let consume = async {
|
||||
let _owner = alias.close_on_drop();
|
||||
alias.next().await
|
||||
};
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), consume)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(response.body.next().await.unwrap().is_none());
|
||||
} else {
|
||||
let (item, waiting, ()) =
|
||||
tokio::join!(response.body.next(), client.open(message(), event), async {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
client.close().await.unwrap();
|
||||
});
|
||||
assert!(item.unwrap().unwrap().unwrap_err().contains("closed"));
|
||||
assert!(waiting.unwrap_err().contains("closed"));
|
||||
assert!(response.body.next().await.unwrap().is_none());
|
||||
}
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! 独立回环协议对端验证客户端按需拉取与连接所有权。
|
||||
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use dever_runtime::{
|
||||
async_stream::AsyncStream,
|
||||
http::{HttpClient, PoolLimits, StreamRequest},
|
||||
io::Error,
|
||||
tls,
|
||||
};
|
||||
use std::net::TcpListener;
|
||||
|
||||
mod download;
|
||||
mod protocol;
|
||||
mod upload;
|
||||
|
||||
type Event = Result<Bytes, Error>;
|
||||
|
||||
fn event(value: Event) -> Event {
|
||||
value
|
||||
}
|
||||
|
||||
fn message() -> Request {
|
||||
Request {
|
||||
method: "POST".into(),
|
||||
target: "/stream".into(),
|
||||
headers: vec![],
|
||||
body: bytes::Bytes::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(scope: &Scope<'_>, port: u16, limits: &Limits, pool: &PoolLimits) -> HttpClient {
|
||||
http::client(
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&tls::system(),
|
||||
limits,
|
||||
pool,
|
||||
scope,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn peer(work: impl FnOnce(TcpListener) + Send + 'static) -> (u16, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, std::thread::spawn(move || work(listener)))
|
||||
}
|
||||
|
||||
fn accept(listener: &TcpListener) -> BufReader<TcpStream> {
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let socket = loop {
|
||||
match listener.accept() {
|
||||
Ok((socket, _)) => break socket,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"peer accept timed out"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
};
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
socket
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
BufReader::new(socket)
|
||||
}
|
||||
|
||||
fn headers(socket: &mut BufReader<TcpStream>) -> String {
|
||||
let mut headers = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(socket.read_line(&mut line).unwrap() > 0);
|
||||
if line == "\r\n" {
|
||||
return headers;
|
||||
}
|
||||
headers.push_str(&line);
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect(body: &AsyncStream<Event>, chunk: usize) -> Vec<u8> {
|
||||
let _guard = body.close_on_drop();
|
||||
let mut bytes = vec![];
|
||||
while let Some(event) = body.next().await.unwrap() {
|
||||
let part = event.unwrap();
|
||||
assert!(!part.is_empty() && part.len() <= chunk);
|
||||
bytes.extend_from_slice(&part);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn http1_http2_tls_streams_roundtrip_and_buffered_limits_remain_enforced() {
|
||||
for http2 in [false, true] {
|
||||
for secure in [false, true] {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let limits = Limits {
|
||||
connections: 1,
|
||||
http2: http2.then(Default::default),
|
||||
..Default::default()
|
||||
};
|
||||
let incoming = listener.clone();
|
||||
let protocol = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
if secure {
|
||||
let tls = tls::server(
|
||||
super::super::tls::CERTIFICATE,
|
||||
super::super::tls::KEY,
|
||||
)
|
||||
.unwrap();
|
||||
http::serve_tls::<_, _, false>(
|
||||
&incoming, &protocol, &child, &tls, route,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
http::serve::<_, _, false>(&incoming, &protocol, &child, route)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let origin = format!(
|
||||
"{}://localhost:{port}",
|
||||
if secure { "https" } else { "http" }
|
||||
);
|
||||
let trust = tls::client(super::super::tls::ROOT).unwrap();
|
||||
let client = http::client(
|
||||
&origin,
|
||||
&trust,
|
||||
&Limits {
|
||||
body_bytes: 1,
|
||||
..limits
|
||||
},
|
||||
&PoolLimits {
|
||||
chunk_bytes: 3,
|
||||
..Default::default()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.unwrap();
|
||||
let body = AsyncStream::values([
|
||||
Ok(Bytes::from_static(b"abc")),
|
||||
Ok(Bytes::from_static(b"def")),
|
||||
]);
|
||||
let request = StreamRequest {
|
||||
method: "POST".into(),
|
||||
target: "/echo".into(),
|
||||
headers: vec![],
|
||||
body,
|
||||
};
|
||||
let response = client.open_stream(request, event, event).await.unwrap();
|
||||
assert_eq!(collect(&response.body, 3).await, b"abcdef");
|
||||
let mut request = message();
|
||||
request.body = b"ab".to_vec().into();
|
||||
assert!(
|
||||
client
|
||||
.open(request.clone(), event)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("byte limit")
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.request(request)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("byte limit")
|
||||
);
|
||||
client.close().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http2_closing_one_response_preserves_neighbor_and_releases_only_its_slot() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let limits = Limits {
|
||||
connections: 1,
|
||||
http2: Some(http::Http2Limits {
|
||||
streams: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let incoming = listener.clone();
|
||||
let protocol = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&protocol,
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
|request: Request, reply: http::HttpReply, _| async move {
|
||||
if request.target == "/ordinary" {
|
||||
return reply
|
||||
.respond(Response {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: b"ok".to_vec().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
reply
|
||||
.start(200, vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
reply
|
||||
.write(b"first")
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
reply
|
||||
.write(b"last")
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let client = client(
|
||||
scope,
|
||||
port as u16,
|
||||
&limits,
|
||||
&PoolLimits {
|
||||
idle_ms: 20,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let first = client.open(message(), event).await.unwrap();
|
||||
let second = client.open(message(), event).await.unwrap();
|
||||
assert_eq!(
|
||||
first.body.next().await.unwrap().unwrap().unwrap(),
|
||||
b"first"[..]
|
||||
);
|
||||
assert_eq!(
|
||||
second.body.next().await.unwrap().unwrap().unwrap(),
|
||||
b"first"[..]
|
||||
);
|
||||
first.body.close();
|
||||
let mut ordinary = message();
|
||||
ordinary.target = "/ordinary".into();
|
||||
assert_eq!(client.request(ordinary).await.unwrap().body.as_ref(), b"ok");
|
||||
assert_eq!(collect(&second.body, 65536).await, b"last");
|
||||
client.close().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
fn chunk(socket: &mut BufReader<TcpStream>) -> Vec<u8> {
|
||||
let mut line = String::new();
|
||||
assert!(socket.read_line(&mut line).unwrap() > 0);
|
||||
let count = usize::from_str_radix(line.trim(), 16).unwrap();
|
||||
let mut body = vec![0; count];
|
||||
socket.read_exact(&mut body).unwrap();
|
||||
let mut ending = [0; 2];
|
||||
socket.read_exact(&mut ending).unwrap();
|
||||
assert_eq!(&ending, b"\r\n");
|
||||
body
|
||||
}
|
||||
|
||||
fn request(body: AsyncStream<Event>) -> StreamRequest<Event> {
|
||||
StreamRequest {
|
||||
method: "POST".into(),
|
||||
target: "/stream".into(),
|
||||
headers: vec![],
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplex_upload_sends_first_chunk_before_source_eof_and_returns_response_early() {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut socket = accept(&listener);
|
||||
let head = headers(&mut socket).to_ascii_lowercase();
|
||||
assert!(head.contains("transfer-encoding: chunked"));
|
||||
assert!(!head.contains("content-length:"));
|
||||
assert_eq!(chunk(&mut socket), b"abc");
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n")
|
||||
.unwrap();
|
||||
assert_eq!(chunk(&mut socket), b"def");
|
||||
assert!(chunk(&mut socket).is_empty());
|
||||
socket
|
||||
.get_mut()
|
||||
.write_all(b"3\r\ndef\r\n0\r\n\r\n")
|
||||
.unwrap();
|
||||
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
body_bytes: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits {
|
||||
chunk_bytes: 3,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let proceed = Arc::new(tokio::sync::Notify::new());
|
||||
let source = proceed.clone();
|
||||
let body = AsyncStream::new(futures_util::stream::unfold(0, move |index| {
|
||||
let source = source.clone();
|
||||
async move {
|
||||
match index {
|
||||
0 => Some((Ok(Ok(Bytes::from_static(b"abc"))), 1)),
|
||||
1 => {
|
||||
source.notified().await;
|
||||
Some((Ok(Ok(Bytes::from_static(b"def"))), 2))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}));
|
||||
let alias = body.clone();
|
||||
let response = client
|
||||
.open_stream(request(body), event, event)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.body.next().await.unwrap().unwrap().unwrap(),
|
||||
b"abc"[..]
|
||||
);
|
||||
proceed.notify_one();
|
||||
assert_eq!(collect(&response.body, 3).await, b"def");
|
||||
assert!(alias.next().await.unwrap().is_none());
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_rejects_oversized_chunks_source_errors_and_stalled_reads() {
|
||||
for mode in 0..3 {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut socket = accept(&listener);
|
||||
let mut request = Vec::new();
|
||||
socket.read_to_end(&mut request).unwrap();
|
||||
assert!(!request.windows(8).any(|window| window == b"oversize"));
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(
|
||||
scope,
|
||||
port,
|
||||
&Limits {
|
||||
timeout_ms: 1000,
|
||||
..Default::default()
|
||||
},
|
||||
&PoolLimits {
|
||||
chunk_bytes: 3,
|
||||
read_ms: 40,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = match mode {
|
||||
0 => AsyncStream::values([Ok(Bytes::from_static(b"oversize"))]),
|
||||
1 => AsyncStream::values([Err("source failed".into())]),
|
||||
_ => AsyncStream::new(futures_util::stream::pending()),
|
||||
};
|
||||
let alias = body.clone();
|
||||
assert!(
|
||||
client
|
||||
.open_stream(request(body), event, event)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(alias.next().await.unwrap().is_none());
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_request_before_headers_closes_upload_source_and_socket() {
|
||||
let (port, server) = peer(|listener| {
|
||||
let mut socket = accept(&listener);
|
||||
let mut bytes = vec![];
|
||||
socket.read_to_end(&mut bytes).unwrap();
|
||||
});
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let client = client(scope, port, &Limits::default(), &PoolLimits::default());
|
||||
let body = AsyncStream::<Event>::new(futures_util::stream::pending());
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(40),
|
||||
client.open_stream(request(body.clone()), event, event)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(1), body.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(body.next().await.unwrap().is_none());
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
|
||||
pub(super) const ROOT: &[u8] = include_bytes!("../../../dever-tests/fixtures/tls/root.pem");
|
||||
pub(super) const CERTIFICATE: &[u8] =
|
||||
include_bytes!("../../../dever-tests/fixtures/tls/server.pem");
|
||||
pub(super) const KEY: &[u8] = include_bytes!("../../../dever-tests/fixtures/tls/server-key.pem");
|
||||
|
||||
#[test]
|
||||
fn tls_verifies_ca_hostname_alpn_and_keeps_bad_handshakes_isolated() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for http2 in [false, true] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
let tls = dever_runtime::tls::server(CERTIFICATE, KEY).unwrap();
|
||||
let limits = Limits {
|
||||
http2: http2.then(http::Http2Limits::default),
|
||||
timeout_ms: 1000,
|
||||
..Limits::default()
|
||||
};
|
||||
http::serve_tls::<_, _, false>(&incoming, &limits, &child, &tls, route)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let custom = dever_runtime::tls::client(ROOT).unwrap();
|
||||
for (tls, host, protocol) in [
|
||||
(dever_runtime::tls::system(), "localhost", http2),
|
||||
(custom.clone(), "wrong.test", http2),
|
||||
(custom.clone(), "localhost", !http2),
|
||||
] {
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(tls.connect(host, stream, protocol).await.is_err());
|
||||
}
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
let stream = custom.connect("localhost", stream, http2).await.unwrap();
|
||||
let message = hyper::Request::builder()
|
||||
.method("POST")
|
||||
.uri(if http2 {
|
||||
"https://localhost/echo"
|
||||
} else {
|
||||
"/echo"
|
||||
})
|
||||
.header("Host", "localhost")
|
||||
.body(Full::new(bytes::Bytes::from_static(b"verified TLS")))
|
||||
.unwrap();
|
||||
let response = if http2 {
|
||||
let (mut sender, connection) =
|
||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake(TokioIo::new(stream))
|
||||
.await
|
||||
.unwrap();
|
||||
let driver = scope.spawn_leaf(move || connection).unwrap();
|
||||
let response = sender.send_request(message).await.unwrap();
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
drop(sender);
|
||||
driver.stop().await.unwrap();
|
||||
body
|
||||
} else {
|
||||
let (mut sender, connection) =
|
||||
hyper::client::conn::http1::handshake(TokioIo::new(stream))
|
||||
.await
|
||||
.unwrap();
|
||||
let driver = scope.spawn_leaf(move || connection).unwrap();
|
||||
let response = sender.send_request(message).await.unwrap();
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
drop(sender);
|
||||
driver.stop().await.unwrap();
|
||||
body
|
||||
};
|
||||
assert_eq!(response, b"verified TLS"[..]);
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_configuration_rejects_invalid_inputs_without_exposing_them() {
|
||||
assert!(
|
||||
dever_runtime::tls::client(b"private-input")
|
||||
.unwrap_err()
|
||||
.contains("empty")
|
||||
);
|
||||
let error = dever_runtime::tls::server(CERTIFICATE, b"private-key-input").unwrap_err();
|
||||
assert!(!error.contains("private-key-input"));
|
||||
assert!(dever_runtime::tls::server(ROOT, KEY).is_err());
|
||||
let mut keys = KEY.to_vec();
|
||||
keys.extend_from_slice(KEY);
|
||||
assert!(
|
||||
dever_runtime::tls::server(CERTIFICATE, &keys)
|
||||
.unwrap_err()
|
||||
.contains("one private key")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::Transport;
|
||||
use std::{io, time::Duration};
|
||||
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn vectored_write_preserves_parts_and_stall_timeout() {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let (writer, mut reader) = tokio::io::duplex(8);
|
||||
let mut transport = Transport::new(writer, Duration::from_millis(10));
|
||||
assert!(transport.is_write_vectored());
|
||||
let parts = [io::IoSlice::new(b"head"), io::IoSlice::new(b"body")];
|
||||
assert_eq!(transport.write_vectored(&parts).await.unwrap(), 8);
|
||||
let error =
|
||||
tokio::time::timeout(Duration::from_secs(1), transport.write_vectored(&parts))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
|
||||
let mut received = [0; 8];
|
||||
reader.read_exact(&mut received).await.unwrap();
|
||||
assert_eq!(&received, b"headbody");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectored_write_preserves_partial_progress_and_large_bodies() {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
for length in [0, 1, 31, 4095, 4096, 4097, 65536] {
|
||||
let (writer, mut reader) = tokio::io::duplex(17);
|
||||
let mut transport = Transport::new(writer, Duration::from_secs(2));
|
||||
let header = b"header";
|
||||
let body: Vec<u8> = (0..length).map(|index| (index % 251) as u8).collect();
|
||||
let expected = [header.as_slice(), body.as_slice()].concat();
|
||||
let send = async {
|
||||
let mut parts = [io::IoSlice::new(header), io::IoSlice::new(&body)];
|
||||
let mut remaining = &mut parts[..];
|
||||
while !remaining.is_empty() {
|
||||
let count = transport.write_vectored(remaining).await.unwrap();
|
||||
assert!(count > 0);
|
||||
io::IoSlice::advance_slices(&mut remaining, count);
|
||||
}
|
||||
transport.shutdown().await.unwrap();
|
||||
};
|
||||
let receive = async {
|
||||
let mut received = Vec::new();
|
||||
reader.read_to_end(&mut received).await.unwrap();
|
||||
assert_eq!(received, expected);
|
||||
};
|
||||
tokio::join!(send, receive);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn pooled_socket_preserves_vectored_writes_and_idle_expiry() {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin = Arc::new(
|
||||
Origin::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(),
|
||||
);
|
||||
let limits = crate::http::Limits {
|
||||
timeout_ms: 100,
|
||||
..Default::default()
|
||||
}
|
||||
.check()
|
||||
.unwrap();
|
||||
let mut connector = Connector::new(
|
||||
origin.clone(),
|
||||
&crate::tls::system(),
|
||||
&limits,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
let mut socket = connector
|
||||
.call(origin.uri.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
let (mut peer, _) = listener.accept().await.unwrap();
|
||||
assert!(socket.is_write_vectored());
|
||||
let body = vec![7; 65536];
|
||||
let send = async {
|
||||
let mut parts = [io::IoSlice::new(b"head"), io::IoSlice::new(&body)];
|
||||
let mut remaining = &mut parts[..];
|
||||
while !remaining.is_empty() {
|
||||
let count = socket.write_vectored(remaining).await.unwrap();
|
||||
assert_ne!(count, 0);
|
||||
io::IoSlice::advance_slices(&mut remaining, count);
|
||||
}
|
||||
};
|
||||
let receive = async {
|
||||
let mut received = vec![0; 4 + body.len()];
|
||||
peer.read_exact(&mut received).await.unwrap();
|
||||
assert_eq!(&received[..4], b"head");
|
||||
assert_eq!(&received[4..], body);
|
||||
};
|
||||
tokio::join!(send, receive);
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert_eq!(
|
||||
socket
|
||||
.write_vectored(&[io::IoSlice::new(b"expired")])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use super::*;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[test]
|
||||
fn websocket_client_scope_ends_all_aliases_and_timeout_stream_fails_once() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let port = listener.port().unwrap();
|
||||
let owner = scope
|
||||
.spawn(move |child| async move {
|
||||
ws::connect("127.0.0.1", port, "/", &limits(), &child)
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
let escaped = owner.wait().await.unwrap();
|
||||
assert!(escaped.send(Message::Text("closed".into())).await.is_err());
|
||||
let socket = ws::connect(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
"/",
|
||||
&ws::Limits {
|
||||
idle_ms: 25,
|
||||
..limits()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stream = socket.messages(|message| message);
|
||||
let alias = stream.clone();
|
||||
let failure = stream.next().await.unwrap().unwrap().unwrap_err();
|
||||
assert!(failure.contains("timed out"));
|
||||
assert!(alias.next().await.unwrap().is_none());
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_listener_drain_keeps_session_and_parent_stop_cleans_handler() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, mut server) = server(scope).await;
|
||||
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
listener.close().unwrap();
|
||||
tokio::select! {
|
||||
_ = &mut server => panic!("server must retain its upgraded session"),
|
||||
() = tokio::time::sleep(Duration::from_millis(25)) => (),
|
||||
}
|
||||
socket
|
||||
.send(Message::Text("after listener close".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Text("after listener close".into()))
|
||||
);
|
||||
socket.close(1000, "").await.unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let running = active.clone();
|
||||
let (sender, receiver) = oneshot::channel::<WebSocket>();
|
||||
let sender = Arc::new(std::sync::Mutex::new(Some(sender)));
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
move |_, reply, scope| {
|
||||
let sender = sender.clone();
|
||||
let running = running.clone();
|
||||
async move {
|
||||
let socket = ws::accept(&reply, &limits(), &scope).await.unwrap();
|
||||
running.fetch_add(1, Ordering::SeqCst);
|
||||
let _running = Running(running);
|
||||
let _ = sender.lock().unwrap().take().unwrap().send(socket.clone());
|
||||
let _ = socket.receive().await;
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let escaped = receiver.await.unwrap();
|
||||
server.stop().await.unwrap();
|
||||
assert_eq!(active.load(Ordering::SeqCst), 0);
|
||||
assert!(escaped.send(Message::Text("gone".into())).await.is_err());
|
||||
assert!(socket.receive().await.is_err());
|
||||
listener.close().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct Running(Arc<AtomicUsize>);
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_owner_stop_wakes_receive_in_another_task() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let port = listener.port().unwrap();
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let owner = scope
|
||||
.spawn(move |child| async move {
|
||||
let socket = ws::connect("127.0.0.1", port, "/", &limits(), &child)
|
||||
.await
|
||||
.unwrap();
|
||||
sender.send(socket).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
let socket = receiver.await.unwrap();
|
||||
let (started, ready) = oneshot::channel();
|
||||
let reader = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let receiving = socket.receive();
|
||||
tokio::pin!(receiving);
|
||||
assert!(futures_util::poll!(&mut receiving).is_pending());
|
||||
started.send(()).unwrap();
|
||||
tokio::time::timeout(Duration::from_millis(200), receiving)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err()
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
owner.stop().await.unwrap();
|
||||
assert!(reader.wait().await.unwrap().contains("WebSocket is closed"));
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_closing_message_stream_preserves_independent_socket_alias() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let stream = socket.messages(|message| message);
|
||||
stream.close();
|
||||
assert!(stream.next().await.unwrap().is_none());
|
||||
socket
|
||||
.send(Message::Binary(vec![1, 2].into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Binary(vec![1, 2].into()))
|
||||
);
|
||||
socket.close(1000, "").await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_cancelled_writer_closes_only_after_it_owns_the_direction() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (started, receiving) = oneshot::channel();
|
||||
let (release, released) = oneshot::channel();
|
||||
let peer = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let wire = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
wire.get_ref().readable().await.unwrap();
|
||||
let _ = started.send(());
|
||||
let _ = released.await;
|
||||
})
|
||||
.unwrap();
|
||||
let socket = ws::connect(
|
||||
"127.0.0.1",
|
||||
i64::from(port),
|
||||
"/",
|
||||
&ws::Limits {
|
||||
message_bytes: 16 * 1024 * 1024,
|
||||
write_ms: 1000,
|
||||
..limits()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let sender = socket.clone();
|
||||
let mut writing = scope
|
||||
.spawn_leaf(move || async move {
|
||||
sender
|
||||
.send(Message::Binary(vec![1; 16 * 1024 * 1024].into()))
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
receiving.await.unwrap();
|
||||
let waiting = socket.clone();
|
||||
let waiter = scope
|
||||
.spawn_leaf(
|
||||
move || async move { waiting.send(Message::Text("queued".into())).await },
|
||||
)
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
waiter.stop().await.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), &mut writing)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
writing.stop().await.unwrap();
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
release.send(()).unwrap();
|
||||
peer.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_close_keeps_the_handshake_when_a_reader_is_waiting() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (replied, reply) = oneshot::channel();
|
||||
let peer = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut wire = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
assert!(matches!(
|
||||
wire.next().await,
|
||||
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_)))
|
||||
));
|
||||
wire.flush().await.unwrap();
|
||||
replied.send(()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
let socket = ws::connect("127.0.0.1", i64::from(port), "/", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let receiving = socket.receive();
|
||||
tokio::pin!(receiving);
|
||||
assert!(futures_util::poll!(&mut receiving).is_pending());
|
||||
let closing = socket.close(1000, "");
|
||||
tokio::pin!(closing);
|
||||
assert!(futures_util::poll!(&mut closing).is_pending());
|
||||
reply.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
let received = receiving.await;
|
||||
closing.await.unwrap();
|
||||
assert!(received.unwrap_err().contains("WebSocket is closing"));
|
||||
peer.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_close_deadline_releases_all_aliases() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (release, released) = oneshot::channel();
|
||||
let peer = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let _wire = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
let _ = released.await;
|
||||
})
|
||||
.unwrap();
|
||||
let socket = ws::connect(
|
||||
"127.0.0.1",
|
||||
i64::from(port),
|
||||
"/",
|
||||
&ws::Limits {
|
||||
write_ms: 50,
|
||||
..limits()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let alias = socket.clone();
|
||||
let receiving = alias.receive();
|
||||
tokio::pin!(receiving);
|
||||
assert!(futures_util::poll!(&mut receiving).is_pending());
|
||||
let (closed, received) = tokio::join!(socket.close(1000, ""), receiving);
|
||||
assert!(closed.unwrap_err().contains("timed out"));
|
||||
assert!(received.unwrap_err().contains("WebSocket is closing"));
|
||||
assert!(
|
||||
alias
|
||||
.send(Message::Text("closed".into()))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("WebSocket is closed")
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
peer.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::*;
|
||||
use dever_runtime::http::websocket::{self as ws, Message, WebSocket};
|
||||
|
||||
mod lifecycle;
|
||||
mod protocol;
|
||||
mod shared;
|
||||
#[path = "../../../performance/websocket/shared/mod.rs"]
|
||||
mod shared_reference;
|
||||
|
||||
fn limits() -> ws::Limits {
|
||||
ws::Limits {
|
||||
idle_ms: 1000,
|
||||
write_ms: 1000,
|
||||
..ws::Limits::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn echo(
|
||||
_: Request,
|
||||
reply: http::HttpReply,
|
||||
scope: Arc<Scope<'static>>,
|
||||
) -> Result<(), String> {
|
||||
let socket = ws::accept(&reply, &limits(), &scope)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
while let Ok(Some(message)) = socket.receive().await {
|
||||
if matches!(message, Message::Text(_) | Message::Binary(_)) {
|
||||
socket
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn server(
|
||||
scope: &Scope<'_>,
|
||||
) -> (
|
||||
net::Listener,
|
||||
dever_runtime::task::Task<Result<(), http::ServeError>>,
|
||||
) {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let incoming = listener.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
echo,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
(listener, task)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_live_upgrade_echo_control_frames_and_close() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let socket = ws::connect(
|
||||
"127.0.0.1",
|
||||
listener.port().unwrap(),
|
||||
"/echo?key=value",
|
||||
&limits(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for message in [
|
||||
Message::Text("你好".into()),
|
||||
Message::Binary(vec![0, 255, 17].into()),
|
||||
] {
|
||||
socket.send(message.clone()).await.unwrap();
|
||||
assert_eq!(socket.receive().await.unwrap(), Some(message));
|
||||
}
|
||||
socket
|
||||
.send(Message::Ping(b"ping".to_vec().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Pong(b"ping".to_vec().into()))
|
||||
);
|
||||
assert!(
|
||||
socket
|
||||
.send(Message::Ping(vec![0; 126].into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(socket.close(1005, "").await.is_err());
|
||||
assert!(socket.close(1000, &"界".repeat(42)).await.is_err());
|
||||
socket
|
||||
.send(Message::Text("still open".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Text("still open".into()))
|
||||
);
|
||||
socket.close(1000, "结束").await.unwrap();
|
||||
assert!(socket.receive().await.unwrap().is_none());
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_aliases_send_whole_messages_while_receiving() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let writers = futures_util::future::join_all((0u8..4).map(|writer| {
|
||||
let socket = &socket;
|
||||
async move {
|
||||
for sequence in 0u8..16 {
|
||||
let mut bytes = vec![writer; 65536];
|
||||
bytes[1] = sequence;
|
||||
socket.send(Message::Binary(bytes.into())).await.unwrap();
|
||||
}
|
||||
}
|
||||
}));
|
||||
let receiving = async {
|
||||
let mut next = [0u8; 4];
|
||||
for _ in 0..64 {
|
||||
let Some(Message::Binary(bytes)) = socket.receive().await.unwrap() else {
|
||||
panic!("expected a complete binary message");
|
||||
};
|
||||
assert_eq!(bytes.len(), 65536);
|
||||
let writer = bytes[0];
|
||||
assert_eq!(bytes[1], next[usize::from(writer)]);
|
||||
assert!(bytes[2..].iter().all(|byte| *byte == writer));
|
||||
next[usize::from(writer)] += 1;
|
||||
}
|
||||
assert_eq!(next, [16; 4]);
|
||||
};
|
||||
tokio::join!(writers, receiving);
|
||||
socket.close(1000, "").await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_wss_uses_supplied_ca_and_dns_with_no_insecure_fallback() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
let tls = dever_runtime::tls::server(tls::CERTIFICATE, tls::KEY).unwrap();
|
||||
http::serve_live_tls(
|
||||
&incoming,
|
||||
&Limits::default(),
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
&tls,
|
||||
echo,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let url = format!("wss://localhost:{port}/echo");
|
||||
assert!(
|
||||
ws::open(&url, &dever_runtime::tls::system(), &limits(), scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let trusted = dever_runtime::tls::client(tls::ROOT).unwrap();
|
||||
let socket = ws::open(&url, &trusted, &limits(), scope).await.unwrap();
|
||||
socket
|
||||
.send(Message::Text("verified TLS".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Text("verified TLS".into()))
|
||||
);
|
||||
socket.close(1000, "").await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_invalid_inputs_fail_before_connecting() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for limit in [
|
||||
ws::Limits {
|
||||
message_bytes: 0,
|
||||
..limits()
|
||||
},
|
||||
ws::Limits {
|
||||
idle_ms: 0,
|
||||
..limits()
|
||||
},
|
||||
ws::Limits {
|
||||
write_ms: -1,
|
||||
..limits()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
ws::connect("127.0.0.1", 0, "/", &limit, scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for url in [
|
||||
"http://localhost/",
|
||||
"ws://user:password@localhost/",
|
||||
"ws://localhost/#fragment",
|
||||
"ws://localhost:99999/",
|
||||
] {
|
||||
assert!(
|
||||
ws::open(url, &dever_runtime::tls::system(), &limits(), scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for target in ["http://localhost/", "*", "/#fragment"] {
|
||||
assert!(
|
||||
ws::connect("127.0.0.1", 0, target, &limits(), scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use super::*;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::{
|
||||
Message as Frame,
|
||||
protocol::frame::{
|
||||
Frame as RawFrame,
|
||||
coding::{Data, OpCode},
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release WebSocket handshake performance probe"]
|
||||
fn websocket_upgrade_cost() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (listener, serving) = server(scope).await;
|
||||
let port = listener.port().unwrap();
|
||||
println!("\ncase,sample,iterations,elapsed_ns");
|
||||
for sample in 0..16 {
|
||||
let started = std::time::Instant::now();
|
||||
for _ in 0..20 {
|
||||
let socket = ws::connect("127.0.0.1", port, "/echo", &limits(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
socket.send(Message::Text("check".into())).await.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Text("check".into()))
|
||||
);
|
||||
socket.close(1000, "").await.unwrap();
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
if sample != 0 {
|
||||
println!("websocket_upgrade,{},20,{elapsed}", sample - 1);
|
||||
}
|
||||
}
|
||||
listener.close().unwrap();
|
||||
serving.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_client_limits_handshake_without_limiting_later_frames() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for header_bytes in [60000, 66000] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let peer = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let handshake = tokio_tungstenite::accept_hdr_async(
|
||||
stream,
|
||||
#[expect(clippy::result_large_err, reason = "Tungstenite 的握手回调要求直接返回 HTTP 错误响应")]
|
||||
move |_: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
response.headers_mut().insert(
|
||||
"x-padding",
|
||||
"x".repeat(header_bytes).parse().unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if header_bytes == 60000 {
|
||||
let mut wire = handshake.unwrap();
|
||||
wire.send(Frame::Binary(vec![17; 70000].into())).await.unwrap();
|
||||
assert!(matches!(wire.next().await, Some(Ok(Frame::Close(_)))));
|
||||
wire.flush().await.unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let connection = ws::connect("127.0.0.1", i64::from(port), "/", &limits(), scope).await;
|
||||
if header_bytes == 60000 {
|
||||
let socket = connection.unwrap();
|
||||
assert_eq!(socket.receive().await.unwrap(), Some(Message::Binary(vec![17; 70000].into())));
|
||||
socket.close(1000, "").await.unwrap();
|
||||
} else {
|
||||
assert!(connection.is_err());
|
||||
}
|
||||
peer.wait().await.unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_client_reassembles_fragments_and_rejects_cumulative_limit_and_utf8() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for invalid in [false, true] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let peer = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut wire = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
wire.send(Frame::Frame(RawFrame::message(
|
||||
vec![0xe4, 0xb8],
|
||||
OpCode::Data(Data::Text),
|
||||
false,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
wire.send(Frame::Frame(RawFrame::message(
|
||||
vec![0xad],
|
||||
OpCode::Data(Data::Continue),
|
||||
true,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
if invalid {
|
||||
wire.send(Frame::Frame(RawFrame::message(
|
||||
vec![0xff],
|
||||
OpCode::Data(Data::Text),
|
||||
true,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
wire.send(Frame::Frame(RawFrame::message(
|
||||
b"12345".to_vec(),
|
||||
OpCode::Data(Data::Text),
|
||||
false,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
wire.send(Frame::Frame(RawFrame::message(
|
||||
b"6789".to_vec(),
|
||||
OpCode::Data(Data::Continue),
|
||||
true,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let _ = wire.next().await;
|
||||
})
|
||||
.unwrap();
|
||||
let socket = ws::connect(
|
||||
"127.0.0.1",
|
||||
i64::from(port),
|
||||
"/",
|
||||
&ws::Limits {
|
||||
message_bytes: 8,
|
||||
..limits()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
socket.receive().await.unwrap(),
|
||||
Some(Message::Text("中".into()))
|
||||
);
|
||||
assert!(socket.receive().await.is_err());
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
peer.wait().await.unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_accept_failure_can_send_http_error_and_http2_cannot_upgrade() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for http2 in [false, true] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let limits = Limits {
|
||||
http2: http2.then(http::Http2Limits::default),
|
||||
..Limits::default()
|
||||
};
|
||||
let server_limits = limits.clone();
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
http::serve_live(
|
||||
&incoming,
|
||||
&server_limits,
|
||||
&http::LiveLimits::default(),
|
||||
&child,
|
||||
|_, reply, scope| async move {
|
||||
assert!(
|
||||
ws::accept(&reply, &super::limits(), &scope).await.is_err()
|
||||
);
|
||||
reply
|
||||
.respond(Response {
|
||||
status: 400,
|
||||
headers: vec![],
|
||||
body: b"not a websocket".to_vec().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let response = http::send("127.0.0.1", port, request("/", &[]), &limits)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 400);
|
||||
assert_eq!(response.body.as_ref(), b"not a websocket");
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_upgrade_preserves_prefetched_frames_and_rejects_unmasked_clients() {
|
||||
runtime().run(async |scope| {
|
||||
let (listener, server) = server(scope).await;
|
||||
let port = listener.port().unwrap();
|
||||
scope.blocking(move || {
|
||||
let mut peer = TcpStream::connect(("127.0.0.1", port as u16)).unwrap();
|
||||
peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
peer.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
let mut request = b"GET /echo HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n".to_vec();
|
||||
request.extend([0x81, 0x81, 1, 2, 3, 4, b'x' ^ 1]);
|
||||
peer.write_all(&request).unwrap();
|
||||
let mut peer = BufReader::new(peer);
|
||||
let mut line = String::new();
|
||||
peer.read_line(&mut line).unwrap();
|
||||
assert!(line.starts_with("HTTP/1.1 101"), "{line}");
|
||||
loop {
|
||||
line.clear();
|
||||
assert!(peer.read_line(&mut line).unwrap() > 0);
|
||||
if line == "\r\n" { break; }
|
||||
}
|
||||
let mut echo = [0; 3];
|
||||
peer.read_exact(&mut echo).unwrap();
|
||||
assert_eq!(echo, [0x81, 1, b'x']);
|
||||
peer.get_mut().write_all(&[0x81, 1, b'y']).unwrap();
|
||||
assert_eq!(peer.read(&mut echo).unwrap(), 0);
|
||||
}).await.unwrap();
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use super::shared_reference::{Limits, Owner, Socket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::time::Duration;
|
||||
use tokio::{
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::oneshot,
|
||||
};
|
||||
use tokio_tungstenite::{
|
||||
WebSocketStream,
|
||||
tungstenite::{Message, protocol::WebSocketConfig},
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits {
|
||||
message: 16 * 1024 * 1024,
|
||||
idle: Duration::from_secs(2),
|
||||
write: Duration::from_secs(2),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pair(limits: Limits) -> (Socket, Owner, WebSocketStream<TcpStream>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let config = WebSocketConfig::default()
|
||||
.max_message_size(Some(limits.message))
|
||||
.max_frame_size(Some(limits.message))
|
||||
.write_buffer_size(0);
|
||||
let client = async {
|
||||
let stream = TcpStream::connect(address).await.unwrap();
|
||||
stream.set_nodelay(true).unwrap();
|
||||
tokio_tungstenite::client_async_with_config(
|
||||
format!("ws://{address}/"),
|
||||
stream,
|
||||
Some(config),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let server = async {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
stream.set_nodelay(true).unwrap();
|
||||
tokio_tungstenite::accept_async_with_config(stream, Some(config))
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let (client, server) = tokio::join!(client, server);
|
||||
let (socket, owner) = Socket::new(client, limits);
|
||||
(socket, owner, server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_rust_reference_owner_exit_wakes_reader_and_releases_tcp() {
|
||||
let (socket, owner, mut peer) = pair(limits()).await;
|
||||
let alias = socket.clone();
|
||||
let reading = alias.receive();
|
||||
tokio::pin!(reading);
|
||||
assert!(futures_util::poll!(&mut reading).is_pending());
|
||||
drop(owner);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), reading)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), peer.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_rust_reference_full_duplex_aliases_preserve_messages() {
|
||||
let (socket, _owner, mut peer) = pair(limits()).await;
|
||||
let server = tokio::spawn(async move {
|
||||
while let Some(message) = peer.next().await {
|
||||
let message = message.unwrap();
|
||||
if message.is_close() {
|
||||
peer.flush().await.unwrap();
|
||||
break;
|
||||
}
|
||||
peer.send(message).await.unwrap();
|
||||
}
|
||||
});
|
||||
let sender = |writer: u8| {
|
||||
let socket = socket.clone();
|
||||
async move {
|
||||
for sequence in 0..16 {
|
||||
let mut bytes = vec![writer; 65536];
|
||||
bytes[1] = sequence;
|
||||
socket.send(Message::Binary(bytes.into())).await.unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let receiver = async {
|
||||
let mut sequences = [0_u8; 2];
|
||||
for _ in 0..32 {
|
||||
let Message::Binary(bytes) = socket.receive().await.unwrap().unwrap() else {
|
||||
panic!("binary expected")
|
||||
};
|
||||
let writer = usize::from(bytes[0]);
|
||||
assert_eq!(bytes[1], sequences[writer]);
|
||||
assert_eq!(bytes.len(), 65536);
|
||||
assert!(bytes[2..].iter().all(|byte| *byte == writer as u8));
|
||||
sequences[writer] += 1;
|
||||
}
|
||||
assert_eq!(sequences, [16, 16]);
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::join!(sender(0), sender(1), receiver);
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
socket.close(1000, "").await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_rust_reference_close_has_priority_over_readable_reply() {
|
||||
let (socket, _owner, mut peer) = pair(limits()).await;
|
||||
let (sent, ready) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
assert!(peer.next().await.unwrap().unwrap().is_close());
|
||||
peer.flush().await.unwrap();
|
||||
sent.send(()).unwrap();
|
||||
});
|
||||
let reading = socket.receive();
|
||||
tokio::pin!(reading);
|
||||
assert!(futures_util::poll!(&mut reading).is_pending());
|
||||
let closing = socket.close(1000, "");
|
||||
tokio::pin!(closing);
|
||||
assert!(futures_util::poll!(&mut closing).is_pending());
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
assert!(reading.await.is_err());
|
||||
closing.await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_rust_reference_cancelled_writer_only_closes_after_lock() {
|
||||
let (socket, _owner, _peer) = pair(limits()).await;
|
||||
let mut writing = Box::pin(socket.send(Message::Binary(vec![1; limits().message].into())));
|
||||
assert!(futures_util::poll!(&mut writing).is_pending());
|
||||
let mut queued = Box::pin(socket.send(Message::Text("queued".into())));
|
||||
assert!(futures_util::poll!(&mut queued).is_pending());
|
||||
drop(queued);
|
||||
assert!(futures_util::poll!(&mut writing).is_pending());
|
||||
drop(writing);
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_rust_reference_limits_ping_and_idle_close() {
|
||||
let (socket, _owner, mut peer) = pair(Limits {
|
||||
message: 128,
|
||||
idle: Duration::from_millis(25),
|
||||
..limits()
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
socket
|
||||
.send(Message::Binary(vec![0; 129].into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
socket
|
||||
.send(Message::Ping(vec![0; 126].into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
peer.send(Message::Ping(vec![7].into())).await.unwrap();
|
||||
assert!(socket.receive().await.unwrap().unwrap().is_ping());
|
||||
assert_eq!(
|
||||
peer.next().await.unwrap().unwrap(),
|
||||
Message::Pong(vec![7].into())
|
||||
);
|
||||
assert!(socket.receive().await.unwrap_err().contains("timed out"));
|
||||
assert!(socket.send(Message::Text("closed".into())).await.is_err());
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claim_orders_pending_and_unrecovered_expired_jobs_together() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default()).unwrap().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
|
||||
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<299) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,2,50,CASE WHEN number=299 THEN 0 ELSE 100 END,0,'old',0,0 FROM rows").await.unwrap();
|
||||
let mut request = submission("pending");
|
||||
request.at = 50;
|
||||
let pending = job::enqueue(&mut executor, scope, request, 1000).await.unwrap().into_text();
|
||||
let first = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
// 首轮只恢复 256 项;仍为 running 的过期任务也必须参与最早时间排序。
|
||||
assert_eq!(first.id, "000299");
|
||||
assert_eq!(first.attempt, 2);
|
||||
assert_ne!(first.token, "old");
|
||||
let second = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(second.id, pending);
|
||||
let third = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(third.id, "000000");
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_cleanup_is_bounded_without_hiding_eligible_work() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default()).unwrap().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
|
||||
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<300) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM rows").await.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(claim.id, "000300");
|
||||
assert_eq!(claim.attempt, 2);
|
||||
let counts = executor.query(scope, "SELECT (SELECT COUNT(*) FROM _dever_job WHERE state='dead'),(SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old')", vec![], 1, |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))).await.unwrap();
|
||||
assert_eq!(counts, vec![(256,44)]);
|
||||
job::complete(&mut executor, scope, &claim, 1001).await.unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_execution_decode_preserves_strict_fields() {
|
||||
use dever_runtime::wire;
|
||||
for source in [
|
||||
r#"{"kind":"System","tenant":null,"tenant":1}"#,
|
||||
r#"{"kind":"System","kind":"System","tenant":null}"#,
|
||||
r#"{"kind":"System","tenant":null,"extra":0}"#,
|
||||
r#"{"kind":"System","tenant":"1"}"#,
|
||||
r#"{"kind":"User","provider":"local"}"#,
|
||||
] {
|
||||
assert!(wire::decode::<Execution>(source).is_err(), "{source}");
|
||||
assert!(wire::decode_fixed::<Execution>(source).is_err(), "{source}");
|
||||
}
|
||||
for source in [
|
||||
r#"{"kind":"System","tenant":null}"#,
|
||||
r#"{"kind":"System","tenant":42}"#,
|
||||
] {
|
||||
let original: Execution = wire::decode(source).unwrap();
|
||||
let fixed: Execution = wire::decode_fixed(source).unwrap();
|
||||
assert_eq!(original.tenant(), fixed.tenant());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_queue_keeps_execution_tenants_in_distinct_idempotency_domains() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for tenant in [None, Some(1), Some(2)] {
|
||||
let identity = Execution::System { tenant };
|
||||
let mut request = submission("one-business-key");
|
||||
request.execution = &identity;
|
||||
let first = job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let mut request = submission("one-business-key");
|
||||
request.execution = &identity;
|
||||
let second = job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_eq!(first, second);
|
||||
assert!(!ids.contains(&first));
|
||||
ids.push(first);
|
||||
}
|
||||
let mut tenants = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let claim = job::claim(&mut executor, scope, 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let identity: Execution = dever_runtime::wire::decode(&claim.execution).unwrap();
|
||||
assert_eq!(identity.tenant().unwrap_or(0), claim.execution_tenant);
|
||||
tenants.push(claim.execution_tenant);
|
||||
job::complete(&mut executor, scope, &claim, 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
tenants.sort_unstable();
|
||||
assert_eq!(tenants, [0, 1, 2]);
|
||||
let identity = Execution::System { tenant: Some(0) };
|
||||
let mut request = submission("invalid");
|
||||
request.execution = &identity;
|
||||
assert!(
|
||||
job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn expired_ack_is_rejected_after_waiting_for_the_write_lock() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("locked"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(
|
||||
job::claim(&mut owner, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let claim_copy = claim.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, &claim_copy, 1001).await
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
// 租约必须包含 SQL 锁外的等待时间,不能复用等待前的时间快照。
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert!(
|
||||
task.wait().await.unwrap().is_err(),
|
||||
"expired lease acknowledged after lock wait"
|
||||
);
|
||||
let next = job::claim(&mut owner, scope, 1300, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_lease_starts_after_its_write_lock_wait() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("claim"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::claim(&mut waiting, &child, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
let claim = task.wait().await.unwrap();
|
||||
assert!(
|
||||
claim.lease_until >= 1300,
|
||||
"lease lost its duration while waiting for the write lock: {}",
|
||||
claim.lease_until
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_waiting_ack_leaves_the_claim_recoverable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("cancelled"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(
|
||||
job::claim(&mut owner, scope, 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let claim_copy = claim.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, &claim_copy, 1001).await
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
task.cancel();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert!(matches!(
|
||||
task.wait().await,
|
||||
Err(dever_runtime::task::Error::Cancelled)
|
||||
));
|
||||
let next = job::claim(&mut owner, scope, 3000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_ack_rolls_back_the_callers_business_transaction() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.schema(
|
||||
scope,
|
||||
"business",
|
||||
"CREATE TABLE business(id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("atomic"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(scope, "INSERT INTO business(id) VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &claim, claim.lease_until)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(executor.finish(scope, owner, true).await.is_err());
|
||||
let rows = executor
|
||||
.query(scope, "SELECT count(*) FROM business", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![0]);
|
||||
let next = job::claim(&mut executor, scope, claim.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use dever_runtime::{
|
||||
database::{Database, Options, Parameter},
|
||||
job::{self, Execution, Policy, Submission},
|
||||
task::{Limits, Runtime},
|
||||
wire::json::Json,
|
||||
};
|
||||
mod cleanup;
|
||||
mod identity;
|
||||
mod lease;
|
||||
mod performance;
|
||||
mod schedule;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-job-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options {
|
||||
driver: "sqlite".into(),
|
||||
path: "queue.db".into(),
|
||||
url: None,
|
||||
tls: None,
|
||||
tenant_directory: None,
|
||||
tenant_database_prefix: None,
|
||||
min_connections: 1,
|
||||
max_connections: 4,
|
||||
max_page_size: 100,
|
||||
wait_timeout_ms: 1000,
|
||||
io_timeout_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
fn submission(key: &str) -> Submission<'_> {
|
||||
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
|
||||
Submission {
|
||||
target: "demo.account.publish",
|
||||
payload: Some(&PAYLOAD),
|
||||
schema: "schema-1",
|
||||
execution: &Execution::System { tenant: None },
|
||||
key,
|
||||
policy: Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_queue_keeps_idempotency_transactions_and_rejects_stale_leases() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = job::enqueue(&mut executor, scope, submission("one"), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_eq!(
|
||||
job::enqueue(&mut executor, scope, submission("one"), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
id
|
||||
);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, 950, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let first = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first.id, id);
|
||||
assert_eq!(first.attempt, 1);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, first.lease_until - 50, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let second = job::claim(&mut executor, scope, first.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(second.id, id);
|
||||
assert_eq!(second.attempt, 2);
|
||||
assert_ne!(first.token, second.token);
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &first, 1101)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(&mut executor, scope, &second, 1101)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &second, 1102)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let next = job::enqueue(&mut executor, scope, submission("one"), 1102)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(next.into_text(), id);
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("rolled back"), 1102)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key=?1",
|
||||
vec![Parameter::Text("rolled back".into())],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![0]
|
||||
);
|
||||
drop(executor);
|
||||
database.close();
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let persisted = job::claim(&mut executor, scope, 1103, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(persisted.id, id);
|
||||
assert!(matches!(
|
||||
dever_runtime::wire::decode::<Execution>(&persisted.execution).unwrap(),
|
||||
Execution::System { tenant: None }
|
||||
));
|
||||
job::block(&mut executor, scope, &persisted, 1104)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, 1204, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_and_exhaustion_survive_a_lost_worker() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("retry"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let first = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
retry_base_ms: 10,
|
||||
retry_max_ms: 40,
|
||||
..Default::default()
|
||||
};
|
||||
job::fail(&mut executor, scope, &first, 1001, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let available = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT available_at FROM _dever_job",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()[0];
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, available - 50, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let last = job::claim(&mut executor, scope, available, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(last.attempt, 2);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, last.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT state FROM _dever_job", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
vec!["dead"]
|
||||
);
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &last, 1112)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for (attempt, delay) in [(1, 10), (2, 20), (3, 40), (100, 40)] {
|
||||
assert_eq!(config.delay(attempt), delay);
|
||||
}
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 60000
|
||||
}],
|
||||
4,
|
||||
true,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50
|
||||
}],
|
||||
2,
|
||||
true,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
}],
|
||||
4,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 60000,
|
||||
}],
|
||||
4,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 0,
|
||||
timeout_ms: 60000
|
||||
}],
|
||||
4,
|
||||
false
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 阶段计时使用预借出的单连接;独立 Rust 仅共享表结构和返回字段,不调用 Dever 运行库。
|
||||
//! 原有生成程序基准另外覆盖连接借还、业务事务和完整分派,不能用这里替代它。
|
||||
|
||||
mod native;
|
||||
mod sql;
|
||||
use super::*;
|
||||
use deadpool_sqlite::rusqlite::Connection;
|
||||
use dever_runtime::{database::Executor, job::Claim, task::Scope};
|
||||
use std::{
|
||||
hint::black_box,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Phase {
|
||||
Enqueue,
|
||||
Duplicate,
|
||||
Claim,
|
||||
Complete,
|
||||
Cycle,
|
||||
}
|
||||
|
||||
async fn clear(executor: &mut Executor, scope: &Scope<'_>) {
|
||||
executor
|
||||
.execute(scope, "DELETE FROM _dever_job", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn measure_dever(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
phase: Phase,
|
||||
keys: &[String],
|
||||
payload: &Json,
|
||||
) -> (u128, u64) {
|
||||
clear(executor, scope).await;
|
||||
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
|
||||
for key in keys {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
job::enqueue(executor, scope, value, 1000).await.unwrap();
|
||||
}
|
||||
}
|
||||
let mut claims = Vec::new();
|
||||
if matches!(phase, Phase::Complete) {
|
||||
for _ in keys {
|
||||
claims.push(
|
||||
job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
checksum += black_box(
|
||||
job::enqueue(executor, scope, value, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
)
|
||||
.len() as u64;
|
||||
}
|
||||
Phase::Claim => {
|
||||
let claim = job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
Phase::Complete => {
|
||||
job::complete(executor, scope, &claims[index], 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
checksum += 1;
|
||||
}
|
||||
Phase::Cycle => {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
let id = job::enqueue(executor, scope, value, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let claim = job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(id, claim.id);
|
||||
job::complete(executor, scope, &claim, 1000).await.unwrap();
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
let state = state(phase);
|
||||
let count = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
|
||||
vec![Parameter::Text(state.into())],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()[0];
|
||||
assert_eq!(count, keys.len() as i64);
|
||||
(elapsed, checksum)
|
||||
}
|
||||
|
||||
fn state(phase: Phase) -> &'static str {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => "pending",
|
||||
Phase::Claim => "running",
|
||||
Phase::Complete | Phase::Cycle => "completed",
|
||||
}
|
||||
}
|
||||
|
||||
async fn measure_native(
|
||||
native: &mut native::Native,
|
||||
phase: Phase,
|
||||
keys: &[String],
|
||||
payload: &str,
|
||||
) -> (u128, u64) {
|
||||
native
|
||||
.call(|connection, _| connection.execute("DELETE FROM _dever_job", []))
|
||||
.await
|
||||
.unwrap();
|
||||
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
|
||||
for key in keys {
|
||||
native.enqueue(key, payload).await;
|
||||
}
|
||||
}
|
||||
let mut claims: Vec<Claim> = Vec::new();
|
||||
if matches!(phase, Phase::Complete) {
|
||||
for _ in keys {
|
||||
claims.push(native.claim().await.unwrap());
|
||||
}
|
||||
}
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => {
|
||||
checksum += black_box(native.enqueue(key, payload).await).len() as u64
|
||||
}
|
||||
Phase::Claim => checksum += black_box(native.claim().await.unwrap()).id.len() as u64,
|
||||
Phase::Complete => {
|
||||
native.complete(&claims[index]).await;
|
||||
checksum += 1;
|
||||
}
|
||||
Phase::Cycle => {
|
||||
let id = native.enqueue(key, payload).await;
|
||||
let claim = native.claim().await.unwrap();
|
||||
assert_eq!(id, claim.id);
|
||||
native.complete(&claim).await;
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
let count: i64 = native
|
||||
.call(move |connection, _| {
|
||||
connection.query_row(
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
|
||||
[state(phase)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, keys.len() as i64);
|
||||
(elapsed, checksum)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release-only owned SQLite phase timing; invoke explicitly"]
|
||||
fn independent_sqlite_phases() {
|
||||
let directory = Directory::new();
|
||||
let native_runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.max_blocking_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let connection = Connection::open(directory.0.join("native.db")).unwrap();
|
||||
connection.busy_timeout(Duration::from_secs(10)).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;",
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute_batch(job::schema::SQL.sqlite).unwrap();
|
||||
let mut native = native::Native::new(connection);
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
let mut options = options();
|
||||
options.max_connections = 1;
|
||||
options.io_timeout_ms = 10000;
|
||||
let database = runtime
|
||||
.run(async |scope| Database::open(&directory.0, options, scope).await)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut executor = runtime
|
||||
.run(async |scope| {
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
})
|
||||
.unwrap();
|
||||
println!("case,sample,implementation,iterations,nanoseconds,checksum");
|
||||
for (name, phase, count, payload) in [
|
||||
(
|
||||
"enqueue",
|
||||
Phase::Enqueue,
|
||||
1000,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"enqueue_64k",
|
||||
Phase::Enqueue,
|
||||
64,
|
||||
Json::parse(&format!("\"{}\"", "x".repeat(65536))).unwrap(),
|
||||
),
|
||||
(
|
||||
"duplicate",
|
||||
Phase::Duplicate,
|
||||
1000,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"claim",
|
||||
Phase::Claim,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"complete",
|
||||
Phase::Complete,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"cycle",
|
||||
Phase::Cycle,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
] {
|
||||
let keys: Vec<_> = (0..count).map(|index| format!("key-{index:04}")).collect();
|
||||
for sample in 0..16 {
|
||||
let mut order = [("dever", true), ("rust", false), ("control", true)];
|
||||
order.rotate_left(sample % 3);
|
||||
let mut expected = None;
|
||||
for (label, generated) in order {
|
||||
let (elapsed, checksum) = if generated {
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
measure_dever(&mut executor, scope, phase, &keys, &payload).await
|
||||
})
|
||||
.unwrap()
|
||||
} else {
|
||||
native_runtime.block_on(measure_native(
|
||||
&mut native,
|
||||
phase,
|
||||
&keys,
|
||||
payload.as_str(),
|
||||
))
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
assert_eq!(checksum, expected);
|
||||
}
|
||||
expected = Some(checksum);
|
||||
if sample > 0 {
|
||||
println!("{name},{},{label},{count},{elapsed},{checksum}", sample - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
native_runtime.block_on(native.close());
|
||||
drop(executor);
|
||||
database.close();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 独立 Rust 对照仅使用 Tokio/rusqlite;连接移交、取消、期限和退出等待不调用 Dever。
|
||||
|
||||
use deadpool_sqlite::rusqlite::{Connection, Result};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for Cancel {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Native {
|
||||
connection: Option<Connection>,
|
||||
workers: Arc<Semaphore>,
|
||||
tracker: TaskTracker,
|
||||
}
|
||||
|
||||
impl Native {
|
||||
pub(super) fn new(connection: Connection) -> Self {
|
||||
Self {
|
||||
connection: Some(connection),
|
||||
workers: Arc::new(Semaphore::new(1)),
|
||||
tracker: TaskTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn call<Value: Send + 'static>(
|
||||
&mut self,
|
||||
operation: impl FnOnce(&mut Connection, &dyn Fn() -> Result<()>) -> Result<Value>
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> Result<Value> {
|
||||
let mut connection = self.connection.take().unwrap();
|
||||
let cancelled = Cancel(Arc::new(AtomicBool::new(false)));
|
||||
let signal = cancelled.0.clone();
|
||||
let deadline = Instant::now() + TIMEOUT;
|
||||
let workers = self.workers.clone();
|
||||
let tracker = self.tracker.clone();
|
||||
let work = async move {
|
||||
let worker = workers.acquire_owned().await.unwrap();
|
||||
let tracking = tracker.token();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _worker = worker;
|
||||
let check = || {
|
||||
if signal.load(Ordering::Acquire) || Instant::now() >= deadline {
|
||||
Err(deadpool_sqlite::rusqlite::Error::InvalidQuery)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let outcome = (|| {
|
||||
check()?;
|
||||
let progress = signal.clone();
|
||||
connection.progress_handler(
|
||||
1000,
|
||||
Some(move || {
|
||||
progress.load(Ordering::Acquire) || Instant::now() >= deadline
|
||||
}),
|
||||
)?;
|
||||
let result = operation(&mut connection, &check);
|
||||
connection.progress_handler(0, None::<fn() -> bool>)?;
|
||||
check()?;
|
||||
result
|
||||
})();
|
||||
(connection, outcome, tracking)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let (connection, result, _tracking) = tokio::time::timeout(TIMEOUT, work).await.unwrap();
|
||||
self.connection = Some(connection);
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn transaction<Value: Send + 'static>(
|
||||
&mut self,
|
||||
operation: impl FnOnce(&Connection) -> Result<Value> + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
self.call(move |connection, check| {
|
||||
let transaction = connection.transaction_with_behavior(
|
||||
deadpool_sqlite::rusqlite::TransactionBehavior::Immediate,
|
||||
)?;
|
||||
check()?;
|
||||
let result = operation(&transaction)?;
|
||||
check()?;
|
||||
transaction.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn close(self) {
|
||||
self.tracker.close();
|
||||
self.tracker.wait().await;
|
||||
drop(self.connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 同一表合同的独立 SQL 实现;不复用生产队列函数,也不新增生产解析或调度层。
|
||||
|
||||
use super::native::Native;
|
||||
use deadpool_sqlite::rusqlite::{OptionalExtension, params};
|
||||
use dever_runtime::job::{Claim, Policy};
|
||||
use std::{fmt::Write, time::Instant};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Execution {
|
||||
kind: &'static str,
|
||||
tenant: Option<i64>,
|
||||
}
|
||||
|
||||
pub(super) fn identifier() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::getrandom(&mut bytes).unwrap();
|
||||
let mut value = String::with_capacity(32);
|
||||
write!(value, "{:032x}", u128::from_be_bytes(bytes)).unwrap();
|
||||
value
|
||||
}
|
||||
|
||||
impl Native {
|
||||
pub(super) async fn enqueue(&mut self, key: &str, payload: &str) -> String {
|
||||
assert!(!key.is_empty() && key.len() <= 1024);
|
||||
let id = identifier();
|
||||
let (key, payload, target, schema) = (
|
||||
key.to_owned(),
|
||||
payload.to_owned(),
|
||||
"demo.account.publish".to_owned(),
|
||||
"schema-1".to_owned(),
|
||||
);
|
||||
let execution = serde_json::to_string(&Execution {
|
||||
kind: "System",
|
||||
tenant: None,
|
||||
})
|
||||
.unwrap();
|
||||
self.transaction(move |connection| {
|
||||
let changed = connection.prepare_cached("INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,retries,timeout_ms,available_at,created_at,execution_tenant) VALUES (?1,?2,?3,?4,?5,?6,'pending',?7,?8,?9,?10,?11) ON CONFLICT(execution_tenant,target,task_key) WHERE state IN ('pending','running') DO NOTHING")?.execute(params![id,target,payload,schema,execution,key,2,50,1000,1000,0])?;
|
||||
if changed == 1 {
|
||||
Ok(id)
|
||||
} else {
|
||||
connection.prepare_cached("SELECT id FROM _dever_job WHERE execution_tenant=?1 AND target=?2 AND task_key=?3 AND state IN ('pending','running')")?.query_row(params![0,target,key], |row| row.get(0))
|
||||
}
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn claim(&mut self) -> Option<Claim> {
|
||||
let started = Instant::now();
|
||||
let token = identifier();
|
||||
self.transaction(move |connection| {
|
||||
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
|
||||
let lease_until = now.checked_add(60000).unwrap();
|
||||
connection.prepare_cached("UPDATE _dever_job SET state=CASE WHEN attempt >= retries THEN 'dead' ELSE 'pending' END, token=NULL,lease_until=NULL,error='lease expired' WHERE id IN (SELECT id FROM _dever_job WHERE state='running' AND lease_until <= ?1 ORDER BY lease_until,id LIMIT 256)")?.execute([now])?;
|
||||
let value = connection.prepare_cached(
|
||||
"SELECT id,target,substr(payload,1,16777217),schema,substr(execution,1,16777217),retries,timeout_ms,attempt,execution_tenant FROM _dever_job
|
||||
WHERE id=(SELECT id FROM (
|
||||
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='pending' AND attempt < retries AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
|
||||
UNION ALL
|
||||
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='running' AND attempt < retries AND lease_until <= ?1 AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
|
||||
) ORDER BY available_at,id LIMIT 1)"
|
||||
)?.query_row([now], |row| Ok(Claim {
|
||||
id: row.get(0)?, target: row.get(1)?, payload: row.get(2)?, schema: row.get(3)?, execution: row.get(4)?,
|
||||
policy: Policy { retries: row.get(5)?, timeout_ms: row.get(6)? }, attempt: row.get::<_,i64>(7)? + 1,
|
||||
token, lease_until, execution_tenant: row.get(8)?,
|
||||
})).optional()?;
|
||||
if let Some(value) = &value {
|
||||
connection.prepare_cached("UPDATE _dever_job SET state='running',attempt=?1,token=?2,lease_until=?3,error=CASE WHEN state='running' THEN 'lease expired' ELSE error END WHERE id=?4")?.execute(params![value.attempt,value.token,lease_until,value.id])?;
|
||||
}
|
||||
Ok(value)
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn complete(&mut self, claim: &Claim) {
|
||||
let (id, token) = (claim.id.clone(), claim.token.clone());
|
||||
let started = Instant::now();
|
||||
self.transaction(move |connection| {
|
||||
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
|
||||
let changed = connection.prepare_cached("UPDATE _dever_job SET state=?1,available_at=?2,error=?3,token=NULL,lease_until=NULL WHERE id=?4 AND state='running' AND token=?5 AND lease_until > ?6")?.execute(params!["completed",now,None::<&str>,id,token,now])?;
|
||||
assert_eq!(changed, 1);
|
||||
Ok(())
|
||||
}).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use super::*;
|
||||
use crate::job::{Failure, heartbeat, queue};
|
||||
use std::{
|
||||
future::pending,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn committed_handler_wins_renewal_race_while_scope_drains_children() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
case.executor(scope)
|
||||
.await
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "commit-race", 300).await);
|
||||
let value = claim.clone();
|
||||
let owned = case.clone();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&value,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (entered, ready) = tokio::sync::oneshot::channel();
|
||||
let descendant = child
|
||||
.spawn(move |grandchild| async move {
|
||||
grandchild
|
||||
.blocking(move || {
|
||||
let _finished = Finished(observed);
|
||||
entered.send(()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
drop(descendant);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
};
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed' AND attempt=1"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
let mut executor = case.executor(scope).await;
|
||||
assert!(
|
||||
job::claim(
|
||||
&mut executor,
|
||||
scope,
|
||||
crate::database::now().unwrap() + 1000,
|
||||
300
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async fn claimed(case: &Case, scope: &Scope<'_>, key: &str, lease: i64) -> Claim {
|
||||
let mut executor = case.executor(scope).await;
|
||||
let now = crate::database::now().unwrap();
|
||||
let mut request = submission(key);
|
||||
request.policy.timeout_ms = 3000;
|
||||
job::enqueue(&mut executor, scope, request, now)
|
||||
.await
|
||||
.unwrap();
|
||||
job::claim(&mut executor, scope, now, lease)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn heartbeat_keeps_business_transaction_alive_across_multiple_leases() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "long-transaction", 200).await);
|
||||
let value = claim.clone();
|
||||
let owned = case.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.query_one("SELECT pg_sleep(0.7)", &[])
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&value,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 200,
|
||||
..Default::default()
|
||||
};
|
||||
let (result, ()) = tokio::join!(
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(450)).await;
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, crate::database::now().unwrap(), 200)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
},
|
||||
);
|
||||
result.unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn lost_fence_cancels_long_handler_and_joins_children_before_returning() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "lost-fence", 300).await);
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let (entered, ready) = tokio::sync::oneshot::channel();
|
||||
let owned = case.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (2)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant = child
|
||||
.spawn(move |_| async move {
|
||||
let _finished = Finished(observed);
|
||||
entered.send(()).unwrap();
|
||||
pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
descendant.wait().await.map_err(|_| Failure::Handler)?;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
};
|
||||
let (result, ()) = tokio::join!(
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
|
||||
async {
|
||||
ready.await.unwrap();
|
||||
executor
|
||||
.execute(scope, "UPDATE _dever_job SET token='other-worker'", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
},
|
||||
);
|
||||
assert!(matches!(result, Err(Failure::LeaseLost)));
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT id FROM business").await, 1);
|
||||
assert!(
|
||||
job::complete(
|
||||
&mut executor,
|
||||
scope,
|
||||
&claim,
|
||||
crate::database::now().unwrap()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE token='other-worker' AND state='running'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn renewal_checks_expiry_after_waiting_for_row_lock() {
|
||||
fixture(async |case, scope| {
|
||||
let claim = claimed(case, scope, "row-wait", 100).await;
|
||||
let mut owner = case.executor(scope).await;
|
||||
let mut waiting = case.executor(scope).await;
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(claim.id.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = crate::database::now().unwrap();
|
||||
let (result, ()) = tokio::join!(
|
||||
queue::renew(&mut waiting, scope, &claim, before, 1000),
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT lease_until FROM _dever_job")
|
||||
.await,
|
||||
claim.lease_until
|
||||
);
|
||||
let replacement = job::claim(&mut waiting, scope, crate::database::now().unwrap(), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(replacement.token, claim.token);
|
||||
assert!(
|
||||
queue::renew(
|
||||
&mut waiting,
|
||||
scope,
|
||||
&claim,
|
||||
crate::database::now().unwrap(),
|
||||
1000
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(
|
||||
&mut waiting,
|
||||
scope,
|
||||
&replacement,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn locked_claim(case: &Case, scope: &Scope<'_>) -> (Executor, Executor, Claim, bool) {
|
||||
let mut owner = case.executor(scope).await;
|
||||
let waiting = case.executor(scope).await;
|
||||
job::enqueue(&mut owner, scope, submission("locked"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = job::claim(&mut owner, scope, NOW, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(claim.id.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(owner, waiting, claim, transaction)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn lease_expiration_after_row_lock_wait_rolls_back_business_writes() {
|
||||
fixture(async |case, scope| {
|
||||
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
|
||||
waiting
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let business = waiting.begin(scope).await.unwrap();
|
||||
waiting
|
||||
.execute(scope, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let (result, ()) =
|
||||
tokio::join!(job::complete(&mut waiting, scope, &claim, NOW + 1), async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"lease checked before the row lock was acquired"
|
||||
);
|
||||
assert!(waiting.finish(scope, business, true).await.is_err());
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
let retry = job::claim(&mut waiting, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(retry.token, claim.token);
|
||||
assert!(
|
||||
job::complete(&mut waiting, scope, &claim, NOW + 1001)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(&mut waiting, scope, &retry, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn claim_clock_includes_table_lock_wait() {
|
||||
fixture(async |case, scope| {
|
||||
let mut owner = case.executor(scope).await;
|
||||
let mut waiting = case.executor(scope).await;
|
||||
job::enqueue(&mut owner, scope, submission("wait"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.initialize(scope, "LOCK TABLE _dever_job IN ACCESS EXCLUSIVE MODE")
|
||||
.await
|
||||
.unwrap();
|
||||
let (result, ()) = tokio::join!(job::claim(&mut waiting, scope, NOW, 100), async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},);
|
||||
assert!(result.unwrap().unwrap().lease_until >= NOW + 300);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn cancellation_discards_waiting_transaction_and_preserves_recovery() {
|
||||
fixture(async |case, scope| {
|
||||
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
|
||||
let claim = Arc::new(claim);
|
||||
let copy = claim.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
started.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, ©, NOW + 1).await
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(task.stop().await.unwrap().is_none());
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
let mut replacement = case.executor(scope).await;
|
||||
let next = job::claim(&mut replacement, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
job::complete(&mut replacement, scope, &next, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Database, Error, Executor, Options, Parameter, Registry},
|
||||
job::{self, Claim, Execution, Policy, Submission},
|
||||
task::{Runtime, Scope},
|
||||
wire::json::Json,
|
||||
};
|
||||
use futures_util::FutureExt;
|
||||
use std::{
|
||||
panic::AssertUnwindSafe,
|
||||
path::PathBuf,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod heartbeat;
|
||||
mod lease;
|
||||
mod queue;
|
||||
mod schedule;
|
||||
mod workers;
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Case {
|
||||
database: Database,
|
||||
schema: String,
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl Case {
|
||||
async fn registry(&self, scope: &Scope<'_>) -> Arc<Registry> {
|
||||
Registry::load(
|
||||
&self.directory.join("setting.json"),
|
||||
&[Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database: Some("default"),
|
||||
table: job::schema::TABLE,
|
||||
schema: job::schema::SQL,
|
||||
plan: None,
|
||||
references: &[],
|
||||
}],
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn executor(&self, scope: &Scope<'_>) -> Executor {
|
||||
let mut executor = self.database.executor().await.unwrap();
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT set_config('search_path',$1,false)",
|
||||
vec![Parameter::Text(self.schema.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
}
|
||||
|
||||
async fn count(&self, scope: &Scope<'_>, sql: &'static str) -> i64 {
|
||||
self.executor(scope)
|
||||
.await
|
||||
.query(scope, sql, vec![], 1, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap()[0]
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&Case, &Scope<'_>)) {
|
||||
let path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
|
||||
let document = Document::load(&path).expect("cannot read PostgreSQL test settings");
|
||||
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
|
||||
let value = root
|
||||
.get("database")
|
||||
.and_then(|database| database.get("postgres_test"))
|
||||
.expect("database.postgres_test must select an isolated test database");
|
||||
let mut options: Options = serde_json::from_value(value.clone())
|
||||
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
|
||||
assert_eq!(options.driver, "postgres");
|
||||
options.max_connections = 8;
|
||||
options.wait_timeout_ms = 1000;
|
||||
options.io_timeout_ms = 5000;
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(std::path::Path::new("."), options, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = format!("dever_job_test_{}", job::identifier().unwrap());
|
||||
let directory = std::env::temp_dir().join(&schema);
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let case = Case {
|
||||
database,
|
||||
schema,
|
||||
directory,
|
||||
};
|
||||
let mut owner = case.database.executor().await.unwrap();
|
||||
owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("CREATE SCHEMA {}", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = AssertUnwindSafe(async {
|
||||
use std::{
|
||||
io::Write,
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt},
|
||||
};
|
||||
std::fs::set_permissions(&case.directory, std::fs::Permissions::from_mode(0o700))
|
||||
.unwrap();
|
||||
let mut connection = value.clone();
|
||||
connection["max_connections"] = 8.into();
|
||||
let url = connection["url"].as_str().unwrap();
|
||||
assert!(
|
||||
url.parse::<tokio_postgres::Config>()
|
||||
.unwrap()
|
||||
.get_options()
|
||||
.is_none()
|
||||
);
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
connection["url"] =
|
||||
format!("{url}{separator}options=-csearch_path%3D{}", case.schema).into();
|
||||
let mut settings = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(case.directory.join("setting.json"))
|
||||
.unwrap();
|
||||
settings
|
||||
.write_all(
|
||||
&serde_json::to_vec(
|
||||
&serde_json::json!({"database":{"default":connection}}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(executor);
|
||||
tokio::time::timeout(Duration::from_secs(30), check(&case, scope))
|
||||
.await
|
||||
.expect("PostgreSQL Job test deadline exceeded");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let cleanup = owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("DROP SCHEMA {} CASCADE", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await;
|
||||
case.database.close();
|
||||
std::fs::remove_dir_all(&case.directory).unwrap();
|
||||
cleanup.expect("PostgreSQL Job test schema cleanup failed");
|
||||
if let Err(panic) = outcome {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn submission(key: &str) -> Submission<'_> {
|
||||
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
|
||||
Submission {
|
||||
target: "demo.account.publish",
|
||||
payload: Some(&PAYLOAD),
|
||||
schema: "schema-1",
|
||||
execution: &Execution::System { tenant: None },
|
||||
key,
|
||||
policy: Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
at: NOW,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn identity_idempotency_retry_and_terminal_keys() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
let identity = Execution::User {
|
||||
provider: "session".into(),
|
||||
subject: "user:7".into(),
|
||||
session: "test-session".into(),
|
||||
site: "admin".into(),
|
||||
tenant: None,
|
||||
tenant_id: None,
|
||||
permission: "demo.account.admin.publish".into(),
|
||||
};
|
||||
let mut first = submission("same");
|
||||
first.execution = &identity;
|
||||
let id = job::enqueue(&mut executor, scope, first, NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let mut duplicate = submission("same");
|
||||
let payload = Json::parse(r#"{"number":999}"#).unwrap();
|
||||
duplicate.payload = Some(&payload);
|
||||
assert_eq!(
|
||||
job::enqueue(&mut executor, scope, duplicate, NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
id
|
||||
);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW - 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let claim = job::claim(&mut executor, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, id);
|
||||
assert_eq!(claim.payload, r#"{"number":1}"#);
|
||||
assert_eq!(claim.schema, "schema-1");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&claim.execution).unwrap(),
|
||||
serde_json::to_value(&identity).unwrap()
|
||||
);
|
||||
let config = job::Config::default();
|
||||
job::fail(&mut executor, scope, &claim, NOW + 1, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 2, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let retry = job::claim(&mut executor, scope, NOW + 1 + config.delay(1) + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(retry.id, id);
|
||||
assert_eq!(retry.attempt, 2);
|
||||
assert_ne!(retry.token, claim.token);
|
||||
job::fail(
|
||||
&mut executor,
|
||||
scope,
|
||||
&retry,
|
||||
retry.lease_until - 500,
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 1_000_000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
1
|
||||
);
|
||||
let replacement = job::enqueue(&mut executor, scope, submission("same"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_ne!(replacement, id);
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("rollback"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key='rollback'"
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert!(Json::parse("{").is_err());
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key='invalid'"
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn concurrent_enqueue_claim_and_skip_locked_keep_single_ownership() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
let mut right = case.executor(scope).await;
|
||||
let (left_id, right_id) = tokio::join!(
|
||||
job::enqueue(&mut left, scope, submission("same"), NOW),
|
||||
job::enqueue(&mut right, scope, submission("same"), NOW),
|
||||
);
|
||||
assert_eq!(left_id.unwrap().into_text(), right_id.unwrap().into_text());
|
||||
let (first, second) = tokio::join!(
|
||||
job::claim(&mut left, scope, NOW, 10_000),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
);
|
||||
assert_eq!(
|
||||
usize::from(first.unwrap().is_some()) + usize::from(second.unwrap().is_some()),
|
||||
1
|
||||
);
|
||||
let locked = job::enqueue(&mut left, scope, submission("locked"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let free = job::enqueue(&mut right, scope, submission("free"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let owner = left.begin(scope).await.unwrap();
|
||||
left.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(locked)],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, free);
|
||||
left.finish(scope, owner, true).await.unwrap();
|
||||
job::block(&mut right, scope, &claim, NOW + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='blocked'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn expired_claims_recover_or_exhaust_and_keep_token_fencing() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
for key in ["first", "second", "third"] {
|
||||
job::enqueue(&mut executor, scope, submission(key), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let mut originals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
originals.push(
|
||||
job::claim(&mut executor, scope, NOW, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let recovered = job::claim(&mut executor, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.attempt, 2);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
|
||||
)
|
||||
.await,
|
||||
2
|
||||
);
|
||||
for original in &originals {
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, original, NOW + 1001)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
job::complete(&mut executor, scope, &recovered, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
let exhausted = job::claim(&mut executor, scope, NOW + 1001, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let next = job::claim(&mut executor, scope, NOW + 2000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(next.id, exhausted.id);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
1
|
||||
);
|
||||
job::complete(&mut executor, scope, &next, NOW + 2001)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 10_000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn expired_cleanup_is_bounded_and_workers_skip_each_others_locks() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
left.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,2,50,0,0,'old',0,0 FROM generate_series(0,599) AS number").await.unwrap();
|
||||
let mut right = case.executor(scope).await;
|
||||
let owner = left.begin(scope).await.unwrap();
|
||||
let first = job::claim(&mut left, scope, NOW, 10_000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let second = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first.id, "000000");
|
||||
assert_eq!(second.id, "000257");
|
||||
left.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
|
||||
)
|
||||
.await,
|
||||
512
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
|
||||
)
|
||||
.await,
|
||||
86
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token<>'old'"
|
||||
)
|
||||
.await,
|
||||
2
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn exhausted_cleanup_does_not_hide_eligible_expired_work() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM generate_series(0,300) AS number").await.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, "000300");
|
||||
assert_eq!(claim.attempt, 2);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
|
||||
)
|
||||
.await,
|
||||
44
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn concurrent_cron_deduplicates_and_bounds_catchup() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
let mut right = case.executor(scope).await;
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
let (first, second) = tokio::join!(
|
||||
job::tick(&mut left, scope, &entry, None, NOW),
|
||||
job::tick(&mut right, scope, &entry, None, NOW)
|
||||
);
|
||||
assert_eq!(first.unwrap() + second.unwrap(), 1);
|
||||
let claim = job::claim(&mut left, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.payload, "null");
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<Execution>(&claim.execution).unwrap(),
|
||||
Execution::System { tenant: None }
|
||||
));
|
||||
job::complete(&mut left, scope, &claim, NOW + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut right, scope, &entry, None, NOW)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut left, scope, &entry, None, NOW + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut right, scope, &entry, None, NOW + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
44
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut left, scope, &entry, None, 0).await.unwrap(),
|
||||
0
|
||||
);
|
||||
let changed = job::Schedule {
|
||||
cron: "*/2 * * * *",
|
||||
..entry
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut right, scope, &changed, None, NOW)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
|
||||
.await,
|
||||
301
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn failed_scheduled_enqueue_rolls_back_cursor_and_occurrence() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor.initialize(scope, "CREATE FUNCTION reject_scheduled_job() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test-only failure''; END'; CREATE TRIGGER reject_scheduled_job BEFORE INSERT ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_scheduled_job();").await.unwrap();
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut executor, scope, &entry, None, NOW)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_schedule")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
executor
|
||||
.initialize(scope, "DROP TRIGGER reject_scheduled_job ON _dever_job")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, NOW)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
future::pending,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn config() -> job::Config {
|
||||
job::Config {
|
||||
workers: 2,
|
||||
poll_ms: 10,
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue(case: &Case, scope: &Scope<'_>) -> Policy {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut value = submission("worker");
|
||||
value.policy.timeout_ms = 3000;
|
||||
let policy = value.policy;
|
||||
job::enqueue(&mut executor, scope, value, crate::database::now().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn short_lease_workers_renew_and_complete_once_without_replaying_handler() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
let completed = Arc::new(tokio::sync::Notify::new());
|
||||
let signal = completed.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, claim, child| {
|
||||
let (case, signal) = (owned.clone(), signal.clone());
|
||||
async move {
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.query_one("SELECT pg_sleep(0.8)", &[])
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&claim,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
signal.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), completed.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
workers.stop.cancel();
|
||||
workers.task.wait().await.unwrap().unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn failed_renewal_cancels_transaction_and_preserves_claim_for_expiry() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
case.executor(scope).await.initialize(scope, "CREATE FUNCTION reject_renewal() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN IF OLD.state=''running'' AND NEW.state=''running'' AND OLD.token=NEW.token AND OLD.lease_until<>NEW.lease_until THEN RAISE EXCEPTION ''test renewal failure''; END IF; RETURN NEW; END'; CREATE TRIGGER reject_renewal BEFORE UPDATE ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_renewal()").await.unwrap();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, _, child| {
|
||||
let (case, observed) = (owned.clone(), observed.clone());
|
||||
async move {
|
||||
let _finished = Finished(observed);
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
pending::<()>().await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !finished.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
workers.stop.cancel();
|
||||
workers.task.wait().await.unwrap().unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND error IS NULL"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn cancelling_worker_drains_handler_and_pending_renewal_before_reclaim() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let ready = entered.clone();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, _, child| {
|
||||
let (case, observed, entered) = (owned.clone(), observed.clone(), entered.clone());
|
||||
async move {
|
||||
let _finished = Finished(observed);
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
entered.notify_one();
|
||||
pending::<()>().await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ready.notified().await;
|
||||
let mut owner = case.executor(scope).await;
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let token = owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT token FROM _dever_job FOR UPDATE",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert!(!finished.load(Ordering::SeqCst));
|
||||
assert!(!matches!(workers.task.stop().await.unwrap(), Some(Ok(()))));
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
let next = job::claim(
|
||||
&mut owner,
|
||||
scope,
|
||||
crate::database::now().unwrap() + 1000,
|
||||
300,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(next.token, token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
use dever_runtime::time::cron::Cron;
|
||||
|
||||
#[test]
|
||||
fn utc_cron_validates_ranges_steps_and_calendar_or() {
|
||||
let cron = Cron::parse("*/15 9-17/2 1,15 * 1-5").unwrap();
|
||||
assert!(cron.matches(30, 11, 8, 10, 1));
|
||||
assert!(cron.matches(45, 9, 15, 10, 0));
|
||||
assert!(!cron.matches(45, 9, 8, 10, 0));
|
||||
assert!(!cron.matches(10, 9, 15, 10, 0));
|
||||
for source in [
|
||||
"* * * *",
|
||||
"60 * * * *",
|
||||
"* 24 * * *",
|
||||
"* * 0 * *",
|
||||
"* * * 13 *",
|
||||
"* * * * 7",
|
||||
"*/0 * * * *",
|
||||
"* * * * 5-1",
|
||||
"* * * * -1",
|
||||
] {
|
||||
assert!(Cron::parse(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_cursor_and_completed_occurrences_are_durable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.job.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let claim = job::claim(&mut executor, scope, 600000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
job::complete(&mut executor, scope, &claim, 600001)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600020)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
44
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let changed = job::Schedule {
|
||||
cron: "*/2 * * * *",
|
||||
..entry
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut executor, scope, &changed, None, 600000)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(executor);
|
||||
drop(database);
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use dever_runtime::wire::{document, json::Json};
|
||||
|
||||
#[path = "json/encode.rs"]
|
||||
mod encode;
|
||||
#[path = "json/input.rs"]
|
||||
mod input;
|
||||
|
||||
#[test]
|
||||
fn fixed_wire_keeps_syntax_and_byte_limits_without_a_second_parse() {
|
||||
use dever_runtime::wire;
|
||||
for source in [r#""\ud800""#, r#""\udc00""#, r#""x" null"#] {
|
||||
assert!(wire::decode_fixed::<String>(source).is_err(), "{source}");
|
||||
}
|
||||
let source = format!("\"{}\"", "x".repeat(document::MAX_BYTES - 2));
|
||||
assert_eq!(
|
||||
wire::decode_fixed::<String>(&source).unwrap().len(),
|
||||
document::MAX_BYTES - 2
|
||||
);
|
||||
assert!(wire::decode_fixed::<String>(&(source + " ")).is_err());
|
||||
let overhead = wire::response_fixed(&"").unwrap().len();
|
||||
let value = "x".repeat(document::MAX_BYTES - overhead);
|
||||
assert_eq!(
|
||||
wire::response_fixed(&value).unwrap().len(),
|
||||
document::MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
wire::response_fixed_bytes(&value).unwrap().len(),
|
||||
document::MAX_BYTES
|
||||
);
|
||||
let oversized = value + "x";
|
||||
assert!(wire::response_fixed(&oversized).is_err());
|
||||
assert!(wire::response_fixed_bytes(&oversized).is_err());
|
||||
let value = "雪\"\\\n";
|
||||
assert_eq!(wire::response_fixed(&value), wire::response(&value));
|
||||
assert_eq!(
|
||||
wire::response_fixed_bytes(&value).unwrap(),
|
||||
wire::response(&value).unwrap().into_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_keeps_numbers_order_and_node_ids() {
|
||||
let source = r#"{"b":1e+03,"a":[null,"雪",-0]}"#;
|
||||
let document = document::parse(source).unwrap();
|
||||
assert_eq!(document.root, 5);
|
||||
assert_eq!(document::stringify(&document).unwrap(), source);
|
||||
assert_eq!(document::property(&document, document.root, "b"), Some(0));
|
||||
assert_eq!(document::element(&document, 4, 1), Some(2));
|
||||
for source in ["{}", "[{},[]]", r#"{"":{}}"#] {
|
||||
let parsed = document::parse(source).unwrap();
|
||||
assert_eq!(document::stringify(&parsed).unwrap(), source);
|
||||
assert_eq!(document::property(&parsed, parsed.root, "missing"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_duplicates_surrogates_and_trailing_content() {
|
||||
for source in [
|
||||
r#"{"name":1,"name":2}"#,
|
||||
r#""\ud800""#,
|
||||
r#"[1,]"#,
|
||||
"null false",
|
||||
r#""\udc00""#,
|
||||
r#""\ud800\u0061""#,
|
||||
r#""\u00""#,
|
||||
"\"雪\n\"",
|
||||
] {
|
||||
assert!(document::parse(source).is_err(), "{source}");
|
||||
}
|
||||
let duplicate = r#"{"雪":0,"\u96ea":[}"#;
|
||||
let error = document::parse(duplicate).unwrap_err();
|
||||
assert_eq!(error.message, "duplicate JSON object key");
|
||||
assert_eq!(
|
||||
error.position,
|
||||
duplicate[..duplicate.rfind(':').unwrap()].chars().count()
|
||||
);
|
||||
assert!(
|
||||
Json::parse(duplicate)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("duplicate JSON object key")
|
||||
);
|
||||
assert_eq!(
|
||||
document::parse(r#""\ud83d\ude42""#).unwrap().nodes,
|
||||
vec![document::Value::String("🙂".into())]
|
||||
);
|
||||
for text in ["", "plain text", "雪🙂", "雪\"\\\n\0🙂", "\u{08}\u{0c}\r\t"] {
|
||||
let source = serde_json::to_string(text).unwrap();
|
||||
let parsed = document::parse(&source).unwrap();
|
||||
assert_eq!(parsed.nodes, vec![document::Value::String(text.into())]);
|
||||
assert_eq!(document::stringify(&parsed).unwrap(), source);
|
||||
assert_eq!(Json::parse(&source).unwrap().as_text().unwrap(), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_string_escaping_matches_serde_for_unicode_and_control_boundaries() {
|
||||
let encode = |text: String| {
|
||||
let expected = serde_json::to_string(&text).unwrap();
|
||||
let value = document::Document {
|
||||
root: 0,
|
||||
nodes: vec![document::Value::String(text)],
|
||||
};
|
||||
assert_eq!(document::stringify(&value).unwrap(), expected);
|
||||
assert_eq!(document::parse(&expected).unwrap().nodes, value.nodes);
|
||||
};
|
||||
encode(
|
||||
(0..=u32::from(char::MAX))
|
||||
.filter_map(char::from_u32)
|
||||
.collect(),
|
||||
);
|
||||
encode(
|
||||
(128..=u32::from(char::MAX))
|
||||
.filter_map(char::from_u32)
|
||||
.collect(),
|
||||
);
|
||||
for character in (0..32).map(char::from).chain(['"', '\\']) {
|
||||
for text in [
|
||||
format!("雪🙂{character}𐀀a{character}"),
|
||||
format!("{character}{character}雪🙂"),
|
||||
] {
|
||||
encode(text);
|
||||
}
|
||||
}
|
||||
encode(String::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_property_update_and_scalar_reads_preserve_wire_text() {
|
||||
let value = Json::parse(r#"{"a":1,"b":[true]}"#).unwrap();
|
||||
assert_eq!(value.property("a").unwrap().unwrap().as_integer(), Ok(1));
|
||||
assert_eq!(value.property("missing").unwrap(), None);
|
||||
let updated = value
|
||||
.set_property("a", &Json::parse("1.000e2").unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(updated.as_str(), r#"{"a":1.000e2,"b":[true]}"#);
|
||||
assert_eq!(
|
||||
Json::parse(r#""hello""#).unwrap().as_text(),
|
||||
Ok("hello".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_enforces_depth_and_value_budgets() {
|
||||
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
|
||||
assert!(document::parse(&nested).is_ok());
|
||||
let too_deep = "[".repeat(document::MAX_DEPTH + 1) + &"]".repeat(document::MAX_DEPTH + 1);
|
||||
assert!(document::parse(&too_deep).is_err());
|
||||
let allowed = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 2));
|
||||
assert!(document::parse(&allowed).is_ok());
|
||||
let many = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1));
|
||||
assert!(document::parse(&many).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_escape_reports_a_codepoint_position_without_panicking() {
|
||||
for source in [r#""\雪""#, r#""\u0雪雪""#] {
|
||||
assert!(document::parse(source).is_err());
|
||||
}
|
||||
assert_eq!(document::parse("[\"雪\",]").unwrap_err().position, 5);
|
||||
for source in ["\"plain", "\"雪🙂", "{\"雪🙂"] {
|
||||
let error = document::parse(source).unwrap_err();
|
||||
assert_eq!(error.message, "unterminated JSON string");
|
||||
assert_eq!(error.position, source.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_documents_reject_forward_references() {
|
||||
let document = document::Document {
|
||||
root: 1,
|
||||
nodes: vec![document::Value::Null, document::Value::Array(vec![1])],
|
||||
};
|
||||
assert!(document::stringify(&document).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_document_numbers_preserve_json_lexical_rules() {
|
||||
let encode = |raw: &str| {
|
||||
document::stringify(&document::Document {
|
||||
root: 0,
|
||||
nodes: vec![document::Value::Number(raw.into())],
|
||||
})
|
||||
};
|
||||
for raw in [
|
||||
"0",
|
||||
"-0",
|
||||
"1.00",
|
||||
"-0.125E+03",
|
||||
"1e9999",
|
||||
"1234567890123456789012345678901234e-12",
|
||||
] {
|
||||
assert_eq!(encode(raw).unwrap(), raw);
|
||||
}
|
||||
for raw in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
"01",
|
||||
"-01",
|
||||
"0x1",
|
||||
"NaN",
|
||||
"1.",
|
||||
".1",
|
||||
"1e",
|
||||
"1e+",
|
||||
"1_0",
|
||||
" 1",
|
||||
"1 ",
|
||||
"1\n",
|
||||
"null",
|
||||
"[]",
|
||||
"1true",
|
||||
"1雪",
|
||||
"0],\"x\":true",
|
||||
] {
|
||||
let error = encode(raw).unwrap_err();
|
||||
assert_eq!(error.node, 0, "{raw}");
|
||||
assert_eq!(error.message, "invalid JSON number", "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_and_array_encoding_reuses_the_same_budgeted_parser() {
|
||||
assert_eq!(Json::boolean(true).unwrap().as_str(), "true");
|
||||
assert_eq!(Json::integer(42).unwrap().as_str(), "42");
|
||||
assert_eq!(Json::string("雪").unwrap().as_str(), "\"雪\"");
|
||||
assert!(Json::float(f64::NAN).is_err());
|
||||
let array = Json::array(&[Json::integer(1).unwrap(), Json::boolean(false).unwrap()]).unwrap();
|
||||
assert_eq!(array.as_str(), "[1,false]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_document_keeps_the_256_container_contract() {
|
||||
let nested = "[".repeat(256) + &"]".repeat(256);
|
||||
let parsed = document::parse_with_depth(&nested, document::DOCUMENT_MAX_DEPTH).unwrap();
|
||||
assert_eq!(
|
||||
document::stringify_with_depth(&parsed, document::DOCUMENT_MAX_DEPTH).unwrap(),
|
||||
nested
|
||||
);
|
||||
assert!(document::stringify_with_depth(&parsed, 255).is_err());
|
||||
let too_deep = "[".repeat(257) + &"]".repeat(257);
|
||||
assert!(document::parse_with_depth(&too_deep, document::DOCUMENT_MAX_DEPTH).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_properties_preserve_subtree_bytes_and_distinguish_null_from_missing() {
|
||||
let value = Json::parse(r#"{"payload": [ 1e9999, -0, "\u96ea" ], "empty": null}"#).unwrap();
|
||||
assert_eq!(
|
||||
value.property("payload").unwrap().unwrap().as_str(),
|
||||
r#"[ 1e9999, -0, "\u96ea" ]"#
|
||||
);
|
||||
assert_eq!(value.property("empty").unwrap().unwrap().as_str(), "null");
|
||||
assert_eq!(value.property("missing").unwrap(), None);
|
||||
assert!(Json::integer(1).unwrap().property("key").is_err());
|
||||
assert!(
|
||||
Json::parse("[]")
|
||||
.unwrap()
|
||||
.set_property("key", &value)
|
||||
.is_err()
|
||||
);
|
||||
let updated = value
|
||||
.set_property("empty", &Json::boolean(true).unwrap())
|
||||
.unwrap();
|
||||
assert!(updated.as_str().contains(r#"[ 1e9999, -0, "\u96ea" ]"#));
|
||||
assert_eq!(value.property("empty").unwrap().unwrap().as_str(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_keys_compare_decoded_text_without_changing_subtree_spelling() {
|
||||
let source =
|
||||
r#" {"\u0061": [ 1e9999, -0 ], "雪\"\\\n": "\u96ea", "\ud83d\ude42": null, "last": true} "#;
|
||||
let value = Json::parse(source).unwrap();
|
||||
for (key, expected) in [
|
||||
("a", Some("[ 1e9999, -0 ]")),
|
||||
("雪\"\\\n", Some(r#""\u96ea""#)),
|
||||
("🙂", Some("null")),
|
||||
("last", Some("true")),
|
||||
(r"\u0061", None),
|
||||
("missing", None),
|
||||
] {
|
||||
assert_eq!(
|
||||
value.property(key).unwrap().as_ref().map(Json::as_str),
|
||||
expected,
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
assert_eq!(value.as_str(), source);
|
||||
for source in [r#"{"a":1,"\u0061":2}"#, r#"{"a":1,"tail":[}"#] {
|
||||
assert!(Json::parse(source).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_wire_checks_duplicate_names_and_all_three_budgets() {
|
||||
use dever_runtime::wire;
|
||||
for source in [r#"{"a":1,"\u0061":2}"#, r#"[{"a":1,"a":2}]"#, r#""\ud800""#] {
|
||||
assert!(Json::parse(source).is_err(), "{source}");
|
||||
assert!(
|
||||
wire::decode::<serde_json::Value>(source).is_err(),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
let maximum = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 2));
|
||||
assert!(Json::parse(&maximum).is_ok());
|
||||
assert!(wire::decode::<Vec<i64>>(&maximum).is_ok());
|
||||
let excessive = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1));
|
||||
assert!(Json::parse(&excessive).is_err());
|
||||
assert!(wire::decode::<Vec<i64>>(&excessive).is_err());
|
||||
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
|
||||
assert!(Json::parse(&nested).is_ok());
|
||||
assert!(Json::parse(&format!("[{nested}]")).is_err());
|
||||
assert!(wire::response(&serde_json::from_str::<serde_json::Value>(&nested).unwrap()).is_err());
|
||||
assert!(Json::string(&"x".repeat(document::MAX_BYTES - 2)).is_ok());
|
||||
assert!(Json::string(&"x".repeat(document::MAX_BYTES - 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_document_shared_subgraphs_are_bounded_before_expansion() {
|
||||
let mut graph = document::Document {
|
||||
root: 0,
|
||||
nodes: vec![document::Value::Null],
|
||||
};
|
||||
for index in 1..40 {
|
||||
graph.nodes.push(document::Value::Array(vec![
|
||||
index as i64 - 1,
|
||||
index as i64 - 1,
|
||||
]));
|
||||
graph.root = index;
|
||||
}
|
||||
let error = document::stringify(&graph).unwrap_err();
|
||||
assert!(error.node < 20, "{error}");
|
||||
assert!(error.message.contains("65536"), "{error}");
|
||||
let oversized = document::Document {
|
||||
root: 1,
|
||||
nodes: vec![
|
||||
document::Value::String("x".repeat(document::MAX_BYTES / 2)),
|
||||
document::Value::Array(vec![0, 0]),
|
||||
],
|
||||
};
|
||||
assert!(
|
||||
document::stringify(&oversized)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("16 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_field_does_not_accumulate_obsolete_document_nodes() {
|
||||
let source = format!(
|
||||
r#"{{"count":0,"values":[{}0]}}"#,
|
||||
"0,".repeat(document::MAX_VALUES - 4)
|
||||
);
|
||||
let original = Json::parse(&source).unwrap();
|
||||
let updated = original
|
||||
.set_property("count", &Json::integer(1).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
updated.property("count").unwrap().unwrap().as_integer(),
|
||||
Ok(1)
|
||||
);
|
||||
assert_eq!(
|
||||
original.property("count").unwrap().unwrap().as_integer(),
|
||||
Ok(0)
|
||||
);
|
||||
assert!(
|
||||
updated
|
||||
.set_property("extra", &Json::null().unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_record_encoding_keeps_byte_limits_and_escaping() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Record<'value> {
|
||||
text: &'value str,
|
||||
}
|
||||
for text in ["", "雪\"\\\n"] {
|
||||
let value = Record { text };
|
||||
assert_eq!(Json::encode_proven(&value, 2, 1), Json::encode(&value));
|
||||
}
|
||||
let text = "x".repeat(document::MAX_BYTES - r#"{"text":""}"#.len());
|
||||
assert_eq!(
|
||||
Json::encode(&Record { text: &text })
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.len(),
|
||||
document::MAX_BYTES
|
||||
);
|
||||
assert!(
|
||||
Json::encode(&Record {
|
||||
text: &(text + "x")
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_wire_is_an_exact_string_not_a_floating_point_value() {
|
||||
use dever_runtime::{value::decimal::Decimal, wire};
|
||||
let value = wire::decode::<Decimal>(r#""1234567890123456789012345678901234""#).unwrap();
|
||||
assert_eq!(
|
||||
Json::encode(&value).unwrap().as_str(),
|
||||
r#""1234567890123456789012345678901234""#
|
||||
);
|
||||
assert!(wire::decode::<Decimal>("1.25").is_err());
|
||||
assert!(wire::decode::<Decimal>(r#""NaN""#).is_err());
|
||||
assert!(wire::decode::<Decimal>(r#""12345678901234567890123456789012345""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_nullable_lists_preserve_extremes_and_normalized_output() {
|
||||
use dever_runtime::{value::decimal::Decimal, wire};
|
||||
let values = [
|
||||
"-0.00",
|
||||
"1.2300",
|
||||
"1E-6176",
|
||||
"9.999999999999999999999999999999999E6144",
|
||||
];
|
||||
let mut numbers = values
|
||||
.map(|value| Some(Decimal::parse(value).unwrap()))
|
||||
.to_vec();
|
||||
numbers.push(None);
|
||||
let encoded = Json::encode(&numbers).unwrap();
|
||||
let expected = [
|
||||
Some("0"),
|
||||
Some("1.23"),
|
||||
Some("1E-6176"),
|
||||
Some("9.999999999999999999999999999999999E+6144"),
|
||||
None,
|
||||
];
|
||||
assert_eq!(encoded.as_str(), serde_json::to_string(&expected).unwrap());
|
||||
assert_eq!(
|
||||
wire::decode::<Vec<Option<Decimal>>>(encoded.as_str()).unwrap(),
|
||||
numbers
|
||||
);
|
||||
assert!(wire::decode::<Vec<Decimal>>(encoded.as_str()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_deserialization_preserves_subtree_text_and_enforces_bounds() {
|
||||
let source = r#" {"huge": 1e9999, "text": "\u96ea", "array": [ 1, 2 ]} "#;
|
||||
let value: Json = serde_json::from_str(source).unwrap();
|
||||
assert_eq!(value.as_str(), source.trim());
|
||||
assert_eq!(Json::parse(source).unwrap().as_str(), source);
|
||||
let parsed = Json::parse(source.trim()).unwrap();
|
||||
assert_eq!(value, parsed);
|
||||
for value in [value, parsed] {
|
||||
assert_eq!(format!("{value:?}"), format!("Json({:?})", source.trim()));
|
||||
assert_eq!(
|
||||
Json::encode_with(|output| output.json(&value))
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
source.trim()
|
||||
);
|
||||
assert_eq!(value.clone().into_text(), source.trim());
|
||||
let child = value.property("array").unwrap().unwrap();
|
||||
assert_eq!(
|
||||
Json::encode_with(|output| output.json(&child))
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"[ 1, 2 ]"
|
||||
);
|
||||
let updated = value.set_property("array", &child).unwrap();
|
||||
assert_eq!(updated.property("array").unwrap().as_ref(), Some(&child));
|
||||
assert_eq!(
|
||||
updated.property("huge").unwrap().unwrap().as_str(),
|
||||
"1e9999"
|
||||
);
|
||||
}
|
||||
for source in [
|
||||
r#"{"a":1,"\u0061":2}"#.to_owned(),
|
||||
r#"["\ud800"]"#.to_owned(),
|
||||
"[".repeat(65) + &"]".repeat(65),
|
||||
format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1)),
|
||||
format!("\"{}\"", "x".repeat(document::MAX_BYTES - 1)),
|
||||
] {
|
||||
assert!(serde_json::from_str::<Json>(&source).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use dever_runtime::wire::{self, document, json::Json, serde_json};
|
||||
|
||||
#[test]
|
||||
fn proven_fragments_keep_exact_counts_when_nested() {
|
||||
for count in [0, 1, document::MAX_VALUES - 2, document::MAX_VALUES - 1] {
|
||||
let values = vec![7i64; count];
|
||||
let proven = Json::encode_proven(&values, count + 1, 1).unwrap();
|
||||
let parsed = Json::parse(proven.as_str()).unwrap();
|
||||
assert_eq!(Json::array(&[proven]), Json::array(&[parsed]));
|
||||
}
|
||||
let value = Json::encode_proven(&(), 1, 0).unwrap();
|
||||
let mut wrapped = value;
|
||||
for _ in 0..document::MAX_DEPTH {
|
||||
wrapped = Json::array(&[wrapped]).unwrap();
|
||||
}
|
||||
assert!(Json::array(&[wrapped]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_output_counts_values_and_response_envelope() {
|
||||
let maximum = vec![0i64; document::MAX_VALUES - 1];
|
||||
let encoded = Json::encode(&maximum).unwrap();
|
||||
assert_eq!(encoded.as_str(), serde_json::to_string(&maximum).unwrap());
|
||||
assert!(Json::encode(&vec![0i64; document::MAX_VALUES]).is_err());
|
||||
assert!(wire::response(&vec![0i64; document::MAX_VALUES - 4]).is_ok());
|
||||
assert!(wire::response(&vec![0i64; document::MAX_VALUES - 3]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_fragments_keep_the_envelope_and_its_budgets() {
|
||||
for source in ["null", "[1e9999,-0]", r#"{"text":"\u96ea"}"#] {
|
||||
let value = Json::parse(source).unwrap();
|
||||
assert_eq!(
|
||||
wire::response_with(|output| output.json(&value)).unwrap(),
|
||||
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{source}}}")
|
||||
);
|
||||
}
|
||||
for count in [document::MAX_VALUES - 4, document::MAX_VALUES - 3] {
|
||||
let values = vec![0; count];
|
||||
let value = Json::encode(&values).unwrap();
|
||||
let output = wire::response_with(|output| output.json(&value));
|
||||
assert_eq!(output.is_ok(), count == document::MAX_VALUES - 4);
|
||||
assert_eq!(output.is_ok(), wire::response(&values).is_ok());
|
||||
}
|
||||
let prefix = wire::response_with(|output| output.value(""))
|
||||
.unwrap()
|
||||
.len();
|
||||
let value = Json::string(&"x".repeat(document::MAX_BYTES - prefix)).unwrap();
|
||||
assert_eq!(
|
||||
wire::response_with(|output| output.json(&value))
|
||||
.unwrap()
|
||||
.len(),
|
||||
document::MAX_BYTES
|
||||
);
|
||||
let value = Json::string(&"x".repeat(document::MAX_BYTES - prefix + 1)).unwrap();
|
||||
assert!(wire::response_with(|output| output.json(&value)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_subtrees_consume_the_outer_depth_and_value_budgets() {
|
||||
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
|
||||
let typed: serde_json::Value = serde_json::from_str(&nested).unwrap();
|
||||
assert_eq!(Json::encode(&typed).unwrap().as_str(), nested);
|
||||
assert!(Json::encode(&vec![typed]).is_err());
|
||||
for raw in [
|
||||
Json::parse(&nested).unwrap(),
|
||||
serde_json::from_str(&nested).unwrap(),
|
||||
] {
|
||||
assert_eq!(
|
||||
Json::encode_with(|output| output.json(&raw))
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
nested
|
||||
);
|
||||
assert!(Json::array(&[raw]).is_err());
|
||||
}
|
||||
let maximum = Json::encode(&vec![0i64; document::MAX_VALUES - 1]).unwrap();
|
||||
assert!(Json::encode_with(|output| output.json(&maximum)).is_ok());
|
||||
assert!(Json::array(&[maximum]).is_err());
|
||||
let allowed = Json::encode(&vec![0i64; document::MAX_VALUES - 2]).unwrap();
|
||||
assert!(Json::array(&[allowed]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_output_keeps_spelling_and_rejects_invalid_object_keys() {
|
||||
let source = r#" {"雪": [1e9999, -0, "\u96ea"]} "#;
|
||||
let raw = Json::parse(source).unwrap();
|
||||
assert_eq!(
|
||||
Json::array(&[raw]).unwrap().as_str(),
|
||||
format!("[{}]", source.trim())
|
||||
);
|
||||
let duplicate =
|
||||
serde_json::value::RawValue::from_string(r#"{"a":1,"\u0061":2}"#.into()).unwrap();
|
||||
assert!(Json::encode(&duplicate).is_err());
|
||||
let surrogate = serde_json::value::RawValue::from_string(r#""\ud800""#.into()).unwrap();
|
||||
assert!(Json::encode(&surrogate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_arrays_match_serde_for_empty_and_mixed_inputs() {
|
||||
let sources = [" null ", " [1e9999,-0] ", r#" {"雪":"\u96ea"} "#, " [] "];
|
||||
for count in [0, 1, 4, 16] {
|
||||
let sources: Vec<_> = sources.into_iter().cycle().take(count).collect();
|
||||
let reference: Vec<Box<serde_json::value::RawValue>> = sources
|
||||
.iter()
|
||||
.map(|source| serde_json::from_str(source).unwrap())
|
||||
.collect();
|
||||
for values in [
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| Json::parse(source).unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
reference
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Json::from_validated_raw)
|
||||
.collect(),
|
||||
] {
|
||||
let encoded = Json::array(&values).unwrap();
|
||||
assert_eq!(encoded.as_str(), serde_json::to_string(&reference).unwrap());
|
||||
assert_eq!(
|
||||
Json::array(std::slice::from_ref(&encoded))
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
format!("[{}]", encoded.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_raw_transfers_its_original_allocation_into_text() {
|
||||
let source = r#"{"huge":1e9999,"text":"\u96ea"}"#;
|
||||
let raw: Box<serde_json::value::RawValue> = serde_json::from_str(source).unwrap();
|
||||
let allocation = raw.get().as_ptr();
|
||||
let value = Json::from_validated_raw(raw);
|
||||
assert_eq!(value.as_str().as_ptr(), allocation);
|
||||
let text = value.into_text();
|
||||
assert_eq!(text.as_ptr(), allocation);
|
||||
assert_eq!(text, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_arrays_enforce_combined_value_and_separator_budgets() {
|
||||
let mut values = vec![Json::null().unwrap(); document::MAX_VALUES - 1];
|
||||
assert!(Json::array(&values).is_ok());
|
||||
values.push(Json::null().unwrap());
|
||||
assert!(Json::array(&values).is_err());
|
||||
for extra in [0, 1] {
|
||||
let values = [
|
||||
Json::integer(0).unwrap(),
|
||||
Json::string(&"x".repeat(document::MAX_BYTES - 6 + extra)).unwrap(),
|
||||
];
|
||||
let encoded = Json::array(&values);
|
||||
if extra == 0 {
|
||||
let encoded = encoded.unwrap();
|
||||
assert_eq!(encoded.as_str().len(), document::MAX_BYTES);
|
||||
assert!(Json::array(&[encoded]).is_err());
|
||||
} else {
|
||||
assert!(encoded.is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_output_keeps_byte_limits_and_counts_empty_containers() {
|
||||
let allowed = vec!["x".repeat(document::MAX_BYTES - 4)];
|
||||
assert_eq!(
|
||||
Json::encode(&allowed).unwrap().as_str().len(),
|
||||
document::MAX_BYTES
|
||||
);
|
||||
assert!(Json::encode(&vec!["x".repeat(document::MAX_BYTES - 3)]).is_err());
|
||||
assert!(Json::encode(&vec![Vec::<i64>::new(); document::MAX_VALUES - 1]).is_ok());
|
||||
assert!(Json::encode(&vec![Vec::<i64>::new(); document::MAX_VALUES]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_fragments_combine_with_serde_fields_and_keep_reusable_budgets() {
|
||||
let fragment = Json::parse(" \n{\"雪\":[1e9999,-0,\"\\u96ea\"]}\t ").unwrap();
|
||||
let nested = Json::encode_with(|output| {
|
||||
output.object(|output| {
|
||||
output.field_encoded("\"a\\\"雪\":", |output| output.json(&fragment))?;
|
||||
output.field("b", false, |output| output.value(&vec![Some("雪\n"), None]))
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
nested.as_str(),
|
||||
"{\"a\\\"雪\":{\"雪\":[1e9999,-0,\"\\u96ea\"]},\"b\":[\"雪\\n\",null]}"
|
||||
);
|
||||
assert_eq!(
|
||||
Json::array(std::slice::from_ref(&nested)).unwrap().as_str(),
|
||||
format!("[{}]", nested.as_str())
|
||||
);
|
||||
assert_eq!(fragment.as_str(), " \n{\"雪\":[1e9999,-0,\"\\u96ea\"]}\t ");
|
||||
let allowed =
|
||||
Json::parse(&("[".repeat(document::MAX_DEPTH - 1) + &"]".repeat(document::MAX_DEPTH - 1)))
|
||||
.unwrap();
|
||||
let full = Json::array(&[allowed]).unwrap();
|
||||
assert!(Json::array(&[full]).is_err());
|
||||
let full =
|
||||
Json::encode_with(|output| output.value(&vec![0; document::MAX_VALUES - 1])).unwrap();
|
||||
assert!(Json::array(&[full]).is_err());
|
||||
let allowed = Json::string(&"x".repeat(document::MAX_BYTES - 4)).unwrap();
|
||||
let full = Json::array(&[allowed]).unwrap();
|
||||
assert_eq!(full.as_str().len(), document::MAX_BYTES);
|
||||
assert!(Json::array(&[full]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use dever_runtime::wire::{self, document};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit strict-Unicode versus syntax-only performance diagnostic"]
|
||||
fn escaped_string_validation_cost() {
|
||||
use serde::{Deserializer, de::IgnoredAny};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
let source = serde_json::to_string(&"雪\"\\\n".repeat(1024)).unwrap();
|
||||
println!("case,sample,implementation,iterations,nanoseconds,checksum");
|
||||
for sample in 0..15 {
|
||||
for strict in if sample % 2 == 0 {
|
||||
[true, false]
|
||||
} else {
|
||||
[false, true]
|
||||
} {
|
||||
let started = Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let source = black_box(source.as_str());
|
||||
if strict {
|
||||
let mut decoder = serde_json::Deserializer::from_str(source);
|
||||
decoder.deserialize_str(IgnoredAny).unwrap();
|
||||
decoder.end().unwrap();
|
||||
} else {
|
||||
black_box(
|
||||
serde_json::from_str::<&serde_json::value::RawValue>(source).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
let label = if strict {
|
||||
"rust_unicode"
|
||||
} else {
|
||||
"rust_syntax_only"
|
||||
};
|
||||
println!(
|
||||
"escaped_string,{sample},{label},1000,{elapsed},{}",
|
||||
source.len() * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_text_preserves_unicode_validation_and_original_spelling() {
|
||||
use wire::json::Json;
|
||||
for unit in 0..=u16::MAX {
|
||||
let source = format!("\"\\u{unit:04x}\"");
|
||||
assert_eq!(
|
||||
Json::parse(&source).is_ok(),
|
||||
serde_json::from_str::<String>(&source).is_ok(),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
for text in [
|
||||
"",
|
||||
"雪🙂",
|
||||
"\\u1234",
|
||||
"\\\"\\\\",
|
||||
"\\\\\"\\\"",
|
||||
"\"\"雪\"",
|
||||
"\u{0}\n\t",
|
||||
] {
|
||||
let source = serde_json::to_string(text).unwrap();
|
||||
for source in [source.clone(), format!(" \n{source}\t ")] {
|
||||
let parsed = Json::parse(&source).unwrap();
|
||||
assert_eq!(parsed.as_str(), source);
|
||||
assert_eq!(parsed.as_text().unwrap(), text);
|
||||
assert_eq!(
|
||||
Json::array(&[parsed]).unwrap().into_text(),
|
||||
format!("[{}]", source.trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
for source in [
|
||||
r#"{"雪":0,"\u96ea":1}"#,
|
||||
r#"["\ud800"]"#,
|
||||
r#"{"field":"\udc00"}"#,
|
||||
r#"{"\ud800":0}"#,
|
||||
r#""\ud800\u0061""#,
|
||||
] {
|
||||
assert!(Json::parse(source).is_err(), "{source}");
|
||||
}
|
||||
for source in [
|
||||
r#""\ud83d\ude42""#,
|
||||
r#"{"value":[true,false,null,-0,1e9999]}"#,
|
||||
] {
|
||||
assert_eq!(Json::parse(source).unwrap().as_str(), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_input_keeps_array_syntax_and_limits() {
|
||||
for count in [0, 16, document::MAX_VALUES - 1] {
|
||||
let source = format!("[{}]", vec!["0"; count].join(","));
|
||||
assert_eq!(
|
||||
wire::decode_shaped::<Vec<i64>>(&source),
|
||||
wire::decode(&source)
|
||||
);
|
||||
assert_eq!(
|
||||
wire::decode_shaped::<Vec<i64>>(&source).unwrap().len(),
|
||||
count
|
||||
);
|
||||
}
|
||||
for source in [
|
||||
"[1,]",
|
||||
"[null]",
|
||||
"[true]",
|
||||
"[{}]",
|
||||
"[[1]]",
|
||||
"[1.5]",
|
||||
"[1] null",
|
||||
"[9223372036854775808]",
|
||||
"[1e9999]",
|
||||
] {
|
||||
assert!(wire::decode_shaped::<Vec<i64>>(source).is_err(), "{source}");
|
||||
}
|
||||
let source = format!("[{}]", vec!["0"; document::MAX_VALUES].join(","));
|
||||
assert!(
|
||||
wire::decode_shaped::<Vec<i64>>(&source)
|
||||
.unwrap_err()
|
||||
.contains("value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_input_has_identical_acceptance_across_the_proven_byte_budget() {
|
||||
for size in [
|
||||
document::MAX_VALUES - 1,
|
||||
document::MAX_VALUES,
|
||||
document::MAX_VALUES + 1,
|
||||
] {
|
||||
let source = format!("[\"{}\"]", "x".repeat(size - 4));
|
||||
assert_eq!(source.len(), size);
|
||||
assert_eq!(
|
||||
wire::decode_shaped::<Vec<String>>(&source),
|
||||
wire::decode(&source)
|
||||
);
|
||||
let source = format!("[\"{}\\ud800\"]", "x".repeat(size - 10));
|
||||
assert!(wire::decode_shaped::<Vec<String>>(&source).is_err());
|
||||
}
|
||||
let source = format!("[\"{}\"]", "x".repeat(document::MAX_BYTES - 4));
|
||||
assert!(wire::decode_shaped::<Vec<String>>(&source).is_ok());
|
||||
assert!(wire::decode_shaped::<Vec<String>>(&(source + " ")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_pass_budget_counts_nested_containers_and_optional_values_once() {
|
||||
use serde::Deserialize;
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Fields {
|
||||
first: Vec<Option<i64>>,
|
||||
second: Option<Vec<Option<i64>>>,
|
||||
}
|
||||
for extra in [0, 1] {
|
||||
let source = format!(
|
||||
"{{\"first\":[{}],\"second\":[null]}}",
|
||||
vec!["null"; document::MAX_VALUES - 4 + extra].join(",")
|
||||
);
|
||||
assert_eq!(wire::decode::<Fields>(&source).is_ok(), extra == 0);
|
||||
assert_eq!(wire::decode_shaped::<Fields>(&source).is_ok(), extra == 0);
|
||||
}
|
||||
let padding = " ".repeat(document::MAX_VALUES);
|
||||
for source in [
|
||||
r#"{"first":[],"second":null}"#,
|
||||
r#"{"first":[null,1],"second":[2,null]}"#,
|
||||
r#"{"first":[],"second":null,"second":[]}"#,
|
||||
r#"{"first":[],"fi\u0072st":[]}"#,
|
||||
r#"{"first":[],"unknown":{}}"#,
|
||||
] {
|
||||
let source = format!("{padding}{source}");
|
||||
let normal = wire::decode::<Fields>(&source);
|
||||
let shaped = wire::decode_shaped::<Fields>(&source);
|
||||
assert_eq!(normal.is_ok(), shaped.is_ok());
|
||||
assert_eq!(normal.ok(), shaped.ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_pass_budget_preserves_serde_numeric_decoding() {
|
||||
let padding = " ".repeat(document::MAX_VALUES);
|
||||
for source in [
|
||||
"[0,-0,-0.0,1,9223372036854775807,18446744073709551615]",
|
||||
"[0.1,1.2345678901234567,1e-308,5e-324,1.7976931348623157e308]",
|
||||
] {
|
||||
let source = format!("{padding}{source}");
|
||||
let normal = wire::decode::<Vec<f64>>(&source).unwrap();
|
||||
let shaped = wire::decode_shaped::<Vec<f64>>(&source).unwrap();
|
||||
assert_eq!(
|
||||
normal
|
||||
.iter()
|
||||
.map(|value| value.to_bits())
|
||||
.collect::<Vec<_>>(),
|
||||
shaped
|
||||
.iter()
|
||||
.map(|value| value.to_bits())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
for source in ["[1e9999]", "[null]", "[\"1\"]", "[NaN]"] {
|
||||
assert!(wire::decode_shaped::<Vec<f64>>(&format!("{padding}{source}")).is_err());
|
||||
}
|
||||
let source = format!("{padding}[\"1.2300\",null,\"-1E-6176\"]");
|
||||
assert_eq!(
|
||||
wire::decode_shaped::<Vec<Option<dever_runtime::value::decimal::Decimal>>>(&source),
|
||||
wire::decode(&source)
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct JsonFields {
|
||||
payload: wire::json::Json,
|
||||
note: Option<String>,
|
||||
nested: Option<JsonRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct JsonRecord {
|
||||
payload: Option<wire::json::Json>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn decode_json_fields(source: &str) -> Result<JsonFields, String> {
|
||||
wire::decode_json(source, |value: &JsonFields, budget| {
|
||||
budget.open()?;
|
||||
budget.value(3)?;
|
||||
budget.json(&value.payload)?;
|
||||
if let Some(nested) = &value.nested {
|
||||
budget.open()?;
|
||||
budget.value(2)?;
|
||||
if let Some(payload) = &nested.payload {
|
||||
budget.json(payload)?;
|
||||
}
|
||||
budget.close();
|
||||
}
|
||||
budget.close();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_budget_distinguishes_omitted_fields_from_explicit_null_at_limit() {
|
||||
for count in [document::MAX_VALUES - 4, document::MAX_VALUES - 2] {
|
||||
let payload = vec!["0"; count].join(",");
|
||||
for suffix in ["", ",\"note\":null", ",\"note\":null,\"nested\":null"] {
|
||||
let source = format!("{{\"payload\":[{payload}]{suffix}}}");
|
||||
let expected = wire::decode::<JsonFields>(&source);
|
||||
let actual = decode_json_fields(&source);
|
||||
assert_eq!(actual.is_ok(), expected.is_ok(), "{count}/{suffix}");
|
||||
assert_eq!(actual.ok(), expected.ok());
|
||||
}
|
||||
}
|
||||
let payload = vec!["0"; document::MAX_VALUES - 2].join(",");
|
||||
assert!(decode_json_fields(&format!("{{\"payload\":[{payload}]}}")).is_ok());
|
||||
assert!(decode_json_fields(&format!("{{\"payload\":[{payload}],\"note\":null}}")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_budget_combines_fixed_records_and_multiple_json_subtrees() {
|
||||
for extra in [0, 1] {
|
||||
let payload = vec!["0"; document::MAX_VALUES - 6 + extra].join(",");
|
||||
let source =
|
||||
format!("{{\"payload\":[{payload}],\"nested\":{{\"payload\":[0],\"count\":0}}}}");
|
||||
let actual = decode_json_fields(&source);
|
||||
assert_eq!(actual.is_ok(), extra == 0);
|
||||
assert_eq!(actual.ok(), wire::decode::<JsonFields>(&source).ok());
|
||||
}
|
||||
let payload = vec!["0"; document::MAX_VALUES - 4].join(",");
|
||||
for (field, accepted) in [("", true), (",\"payload\":null", false)] {
|
||||
let source = format!("{{\"payload\":[{payload}],\"nested\":{{\"count\":0{field}}}}}");
|
||||
assert_eq!(decode_json_fields(&source).is_ok(), accepted);
|
||||
assert_eq!(wire::decode::<JsonFields>(&source).is_ok(), accepted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_budget_counts_empty_containers_and_relative_subtree_depth() {
|
||||
for (nested, parent_depth) in [(false, 1), (true, 2)] {
|
||||
for extra in [0, 1] {
|
||||
let depth = document::MAX_DEPTH - parent_depth + extra;
|
||||
let payload = "[".repeat(depth) + &"]".repeat(depth);
|
||||
let source = if nested {
|
||||
format!("{{\"payload\":null,\"nested\":{{\"payload\":{payload},\"count\":0}}}}")
|
||||
} else {
|
||||
format!("{{\"payload\":{payload}}}")
|
||||
};
|
||||
assert_eq!(decode_json_fields(&source).is_ok(), extra == 0);
|
||||
assert_eq!(wire::decode::<JsonFields>(&source).is_ok(), extra == 0);
|
||||
}
|
||||
}
|
||||
for source in [
|
||||
r#"{"payload":null,"nested":null}"#,
|
||||
r#"{"payload":{},"nested":{"count":0}}"#,
|
||||
r#"{"payload":[],"nested":{"payload":null,"count":0}}"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_json_fields(source).unwrap(),
|
||||
wire::decode(source).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_keeps_raw_spelling_duplicate_keys_and_strict_unicode() {
|
||||
let payload = r#"{ "value": [1e9999,-0,1.00e+2,"\u96ea","\ud83d\ude42"] }"#;
|
||||
let source = format!("{{\"payload\":{payload}}}");
|
||||
let decoded = decode_json_fields(&source).unwrap();
|
||||
assert_eq!(decoded.payload.as_str(), payload);
|
||||
assert_eq!(
|
||||
wire::json::Json::array(&[decoded.payload])
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
format!("[{payload}]")
|
||||
);
|
||||
for source in [
|
||||
r#"{"payload":null,"payl\u006fad":0}"#,
|
||||
r#"{"payload":null,"note":null,"note":null}"#,
|
||||
r#"{"payload":{"雪":0,"\u96ea":1}}"#,
|
||||
r#"{"payload":"\ud800"}"#,
|
||||
r#"{"payload":null,"note":"\udc00"}"#,
|
||||
r#"{"payload":null,"nested":{"count":0,"count":1}}"#,
|
||||
r#"{"payload":null,"extra":0}"#,
|
||||
r#"{"payload":null} {}"#,
|
||||
r#"{"payload":null,}"#,
|
||||
] {
|
||||
assert!(decode_json_fields(source).is_err(), "{source}");
|
||||
assert!(wire::decode::<JsonFields>(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_limits_original_bytes_before_deserializing() {
|
||||
let prefix = r#"{"payload":null,"note":""#;
|
||||
let source = format!(
|
||||
"{prefix}雪{}\"}}",
|
||||
" ".repeat(document::MAX_BYTES - prefix.len() - 5)
|
||||
);
|
||||
assert_eq!(source.len(), document::MAX_BYTES);
|
||||
assert!(decode_json_fields(&source).is_ok());
|
||||
let oversized = source + " ";
|
||||
let mut checked = false;
|
||||
let result = wire::decode_json::<JsonFields>(&oversized, |_, _| {
|
||||
checked = true;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.unwrap_err().contains("16 MiB"));
|
||||
assert!(!checked);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use super::Json;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn document_string_cost() {
|
||||
use crate::wire::document::{self, Document, Value};
|
||||
type Decode = fn(&str) -> Document;
|
||||
let operations: [(&str, Decode); 2] = [
|
||||
("dever", |source| document::parse(source).unwrap()),
|
||||
// 独立 Serde 解码保留相同的单节点结果和字节预算。
|
||||
("rust", |source| {
|
||||
assert!(source.len() <= document::MAX_BYTES);
|
||||
Document {
|
||||
root: 0,
|
||||
nodes: vec![Value::String(serde_json::from_str(source).unwrap())],
|
||||
}
|
||||
}),
|
||||
];
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for (name, text) in [
|
||||
("short", "hello".to_owned()),
|
||||
("ascii", "a".repeat(8192)),
|
||||
("unicode", "雪🙂".repeat(1024)),
|
||||
("escaped", "雪\"\\\n".repeat(1024)),
|
||||
] {
|
||||
let source = serde_json::to_string(&text).unwrap();
|
||||
let expected = Document {
|
||||
root: 0,
|
||||
nodes: vec![Value::String(text.clone())],
|
||||
};
|
||||
for (_, operation) in operations {
|
||||
assert_eq!(operation(&source), expected);
|
||||
for _ in 0..100 {
|
||||
black_box(operation(&source));
|
||||
}
|
||||
}
|
||||
let iterations = if name == "short" { 10000 } else { 1000 };
|
||||
for sample in 0..15 {
|
||||
for offset in 0..operations.len() {
|
||||
let (implementation, operation) = operations[(sample + offset) % operations.len()];
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..iterations {
|
||||
let parsed = black_box(operation(black_box(&source)));
|
||||
let Value::String(value) = &parsed.nodes[parsed.root] else {
|
||||
panic!("expected string node");
|
||||
};
|
||||
checksum += value.len();
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, text.len() * iterations);
|
||||
println!(
|
||||
"document_string_{name},{sample},{implementation},{iterations},{elapsed},{checksum}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn encoded_fragment_cost() {
|
||||
type Encode = fn(&str, bool) -> String;
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for bytes in [64, 4096, 65536] {
|
||||
let text = "a".repeat(bytes);
|
||||
for nested in [false, true] {
|
||||
let operations: [(&str, Encode); 4] = [
|
||||
("proven", |text, nested| {
|
||||
let value = Json::encode_proven(&(text, 7i64), 3, 1).unwrap();
|
||||
if nested {
|
||||
Json::array(&[value]).unwrap().into_text()
|
||||
} else {
|
||||
value.into_text()
|
||||
}
|
||||
}),
|
||||
("shaped", |text, nested| {
|
||||
let value = Json::encode(&(text, 7i64)).unwrap();
|
||||
if nested {
|
||||
Json::array(&[value]).unwrap().into_text()
|
||||
} else {
|
||||
value.into_text()
|
||||
}
|
||||
}),
|
||||
// 一次写完用于观察融合输出的成本,不能当作中间 Json 的包装开销。
|
||||
("rust_direct", |text, nested| {
|
||||
assert!(text.len() <= (crate::wire::document::MAX_BYTES - 32) / 6);
|
||||
if nested {
|
||||
serde_json::to_string(&[(text, 7i64)]).unwrap()
|
||||
} else {
|
||||
serde_json::to_string(&(text, 7i64)).unwrap()
|
||||
}
|
||||
}),
|
||||
// 与 Dever 一样先持有编码结果,再用标准库写入外层数组。
|
||||
("rust_materialized", |text, nested| {
|
||||
assert!(text.len() <= (crate::wire::document::MAX_BYTES - 32) / 6);
|
||||
let value = serde_json::to_string(&(text, 7i64)).unwrap();
|
||||
if nested {
|
||||
let mut output = String::with_capacity(value.len() + 2);
|
||||
output.push('[');
|
||||
output.push_str(&value);
|
||||
output.push(']');
|
||||
output
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}),
|
||||
];
|
||||
let expected = operations[2].1(&text, nested);
|
||||
for (_, operation) in operations {
|
||||
assert_eq!(operation(&text, nested), expected);
|
||||
for _ in 0..100 {
|
||||
black_box(operation(&text, nested));
|
||||
}
|
||||
}
|
||||
let iterations = if bytes == 64 { 10_000 } else { 1000 };
|
||||
for sample in 0..15 {
|
||||
for offset in 0..operations.len() {
|
||||
let (name, operation) = operations[(sample + offset) % operations.len()];
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..iterations {
|
||||
checksum += black_box(operation(black_box(&text), nested)).len();
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, expected.len() * iterations);
|
||||
println!(
|
||||
"encoded_{bytes}_{nested},{sample},{name},{iterations},{elapsed},{checksum}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn command_json_decode_cost() {
|
||||
use crate::wire;
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Input {
|
||||
source: Json,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Native {
|
||||
source: Box<serde_json::value::RawValue>,
|
||||
}
|
||||
type Decoder = fn(&str) -> usize;
|
||||
let decoders: [(&str, Decoder); 3] = [
|
||||
("before", |source| {
|
||||
wire::decode::<Input>(source).unwrap().source.as_str().len()
|
||||
}),
|
||||
("after", |source| {
|
||||
wire::decode_json(source, |value: &Input, budget| {
|
||||
budget.open()?;
|
||||
budget.value(1)?;
|
||||
budget.json(&value.source)?;
|
||||
budget.close();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap()
|
||||
.source
|
||||
.as_str()
|
||||
.len()
|
||||
}),
|
||||
// 原生 RawValue 不做去重和结构预算,只提供语法解码成本下界。
|
||||
("rust_syntax_only", |source| {
|
||||
serde_json::from_str::<Native>(source)
|
||||
.unwrap()
|
||||
.source
|
||||
.get()
|
||||
.len()
|
||||
}),
|
||||
];
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for (name, text) in [
|
||||
("short", "a".repeat(64)),
|
||||
("plain_65536", "a".repeat(65536)),
|
||||
("escaped", "雪\"\\\n".repeat(1024)),
|
||||
] {
|
||||
let payload = serde_json::to_string(&text).unwrap();
|
||||
let source = format!("{{\"source\":{payload}}}");
|
||||
let iterations = if name == "short" { 10_000 } else { 1000 };
|
||||
for (_, decode) in decoders {
|
||||
for _ in 0..100 {
|
||||
assert_eq!(black_box(decode(&source)), payload.len());
|
||||
}
|
||||
}
|
||||
for sample in 0..15 {
|
||||
for offset in 0..decoders.len() {
|
||||
let (label, decode) = decoders[(sample + offset) % decoders.len()];
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..iterations {
|
||||
checksum += black_box(decode(black_box(&source)));
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, payload.len() * iterations);
|
||||
println!("command_json_{name},{sample},{label},{iterations},{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn parse_object_keys_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, suffix) in [
|
||||
("short", String::new()),
|
||||
("long", "field".repeat(32)),
|
||||
("escaped", "雪\"\\\n".repeat(16)),
|
||||
] {
|
||||
let fields = (0..256)
|
||||
.map(|index| (format!("key_{index:03}_{suffix}"), index))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let source = serde_json::to_string(&fields).unwrap();
|
||||
assert_eq!(Json::parse(&source).unwrap().as_str(), source);
|
||||
for _ in 0..100 {
|
||||
black_box(Json::parse(&source).unwrap());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
let parsed = Json::parse(black_box(&source)).unwrap();
|
||||
checksum += black_box(parsed.as_str().len());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, source.len() * 1000);
|
||||
println!("json_keys_{name},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn parse_text_json_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, text) in [
|
||||
("ascii", "a".repeat(8192)),
|
||||
("unicode", "雪🙂".repeat(1024)),
|
||||
("escaped", "雪\"\\\n".repeat(1024)),
|
||||
] {
|
||||
let source = serde_json::to_string(&text).unwrap();
|
||||
assert_eq!(Json::parse(&source).unwrap().as_text().unwrap(), text);
|
||||
for _ in 0..100 {
|
||||
black_box(Json::parse(&source).unwrap());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
let parsed = Json::parse(black_box(&source)).unwrap();
|
||||
checksum += black_box(parsed.as_str().len());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, source.len() * 1000);
|
||||
println!("json_parse_{name},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn deserialize_raw_json_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
let values = (0..256).collect::<Vec<i64>>();
|
||||
let text = serde_json::to_string(&"雪\"\\".repeat(512)).unwrap();
|
||||
let array = format!(r#"{{"numbers":{values:?},"huge":1e9999}}"#);
|
||||
let nested = "[".repeat(32) + &array + &"]".repeat(32);
|
||||
for (name, source) in [("text", text), ("array", array), ("nested", nested)] {
|
||||
let parsed: Json = serde_json::from_str(&source).unwrap();
|
||||
assert_eq!(parsed.as_str(), source);
|
||||
for _ in 0..100 {
|
||||
black_box(serde_json::from_str::<Json>(&source).unwrap());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
let parsed: Json = serde_json::from_str(black_box(&source)).unwrap();
|
||||
checksum += black_box(parsed.as_str().len());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, source.len() * 1000);
|
||||
println!("json_deserialize_{name},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn property_lookup_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, count, suffix) in [
|
||||
("small", 4, ""),
|
||||
("wide", 256, ""),
|
||||
("escaped", 256, "雪\\\""),
|
||||
] {
|
||||
let names = (0..count)
|
||||
.map(|index| format!("key_{index:03}_{suffix}"))
|
||||
.collect::<Vec<_>>();
|
||||
let fields = names
|
||||
.iter()
|
||||
.map(|key| format!("{}:1e9999", serde_json::to_string(key).unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
let source = format!("{{{}}}", fields.join(","));
|
||||
let value = Json::parse(&source).unwrap();
|
||||
let keys = [
|
||||
names[0].as_str(),
|
||||
&names[count / 2],
|
||||
&names[count - 1],
|
||||
"missing",
|
||||
];
|
||||
let lookup = || {
|
||||
keys.iter()
|
||||
.map(|key| {
|
||||
value
|
||||
.property(black_box(key))
|
||||
.unwrap()
|
||||
.map_or(0, |child| black_box(child.as_str().len()))
|
||||
})
|
||||
.sum::<usize>()
|
||||
};
|
||||
assert_eq!(lookup(), 18);
|
||||
for _ in 0..100 {
|
||||
black_box(lookup());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
checksum += black_box(lookup());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, 18000);
|
||||
println!("json_property_{name},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
assert_eq!(value.as_str(), source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use dever_runtime::{
|
||||
net::{self, Listener, Socket},
|
||||
task::{Limits, Runtime, Scope},
|
||||
};
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
task::Poll,
|
||||
};
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[path = "net/performance.rs"]
|
||||
mod performance;
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn pair(scope: &Scope<'_>) -> (Socket, Socket) {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let client = net::connect_timeout("127.0.0.1", listener.port().unwrap(), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let server = listener.accept().await.unwrap();
|
||||
(client, server)
|
||||
}
|
||||
|
||||
async fn pending(future: std::pin::Pin<&mut impl Future>) {
|
||||
let mut future = future;
|
||||
poll_fn(|context| {
|
||||
assert!(future.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_connect_bidirectional_io_and_shared_close_use_the_runtime() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
assert!((1..=65535).contains(&port));
|
||||
let client = net::connect("localhost", port, scope).await.unwrap();
|
||||
let server = listener.accept().await.unwrap();
|
||||
client.write(b"abc").await.unwrap();
|
||||
assert_eq!(server.read(2).await.unwrap(), Some(b"ab".to_vec()));
|
||||
assert_eq!(server.read(2).await.unwrap(), Some(b"c".to_vec()));
|
||||
server.write(b"reply").await.unwrap();
|
||||
assert_eq!(client.read(5).await.unwrap(), Some(b"reply".to_vec()));
|
||||
let alias = client.clone();
|
||||
alias.close().unwrap();
|
||||
assert!(client.write(b"closed").await.is_err());
|
||||
assert!(client.read(1).await.is_err());
|
||||
assert!(client.close().is_err());
|
||||
assert_eq!(server.read(1).await.unwrap(), None);
|
||||
listener.close().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_wakes_read_lock_waiters_and_pending_accepts() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (client, _server) = pair(scope).await;
|
||||
let mut first = std::pin::pin!(client.read(1));
|
||||
let mut second = std::pin::pin!(client.read(1));
|
||||
pending(first.as_mut()).await;
|
||||
pending(second.as_mut()).await;
|
||||
client.close().unwrap();
|
||||
assert!(first.await.is_err());
|
||||
assert!(second.await.is_err());
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let mut accepted = std::pin::pin!(listener.accept());
|
||||
pending(accepted.as_mut()).await;
|
||||
listener.clone().close().unwrap();
|
||||
assert!(accepted.await.is_err());
|
||||
assert!(listener.port().is_err());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_timeout_includes_lock_waiting_and_does_not_consume_future_data() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (client, server) = pair(scope).await;
|
||||
{
|
||||
let mut first = std::pin::pin!(client.read(1));
|
||||
pending(first.as_mut()).await;
|
||||
client.timeout(10).unwrap();
|
||||
assert_eq!(
|
||||
client.read(1).await.unwrap_err(),
|
||||
"network operation timed out"
|
||||
);
|
||||
}
|
||||
client.timeout(1000).unwrap();
|
||||
server.write(b"x").await.unwrap();
|
||||
assert_eq!(client.read(1).await.unwrap(), Some(b"x".to_vec()));
|
||||
assert!(client.timeout(0).is_err());
|
||||
assert!(client.read(0).await.is_err());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_partial_write_closes_but_cancelled_queued_write_preserves_socket() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (client, server) = pair(scope).await;
|
||||
let writer = client.clone();
|
||||
let first = scope
|
||||
.spawn_leaf(move || async move { writer.write(&vec![7; 32 * 1024 * 1024]).await })
|
||||
.unwrap();
|
||||
assert_eq!(server.read(1).await.unwrap(), Some(vec![7]));
|
||||
let writer = client.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let queued = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let mut write = std::pin::pin!(writer.write(b"queued"));
|
||||
pending(write.as_mut()).await;
|
||||
started.send(()).unwrap();
|
||||
write.await
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert!(queued.stop().await.unwrap().is_none());
|
||||
client.timeout(1000).unwrap();
|
||||
assert!(first.stop().await.unwrap().is_none());
|
||||
assert!(client.timeout(1000).is_err());
|
||||
server.timeout(1000).unwrap();
|
||||
while server.read(65536).await.unwrap().is_some() {}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_close_preserves_the_external_socket_and_terminal_errors_occur_once() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (client, server) = pair(scope).await;
|
||||
let chunks = client.chunks(2, std::convert::identity).unwrap();
|
||||
let mut waiting = std::pin::pin!(chunks.next());
|
||||
pending(waiting.as_mut()).await;
|
||||
chunks.close();
|
||||
assert!(waiting.await.unwrap().is_none());
|
||||
server.write(b"ab").await.unwrap();
|
||||
assert_eq!(client.read(2).await.unwrap(), Some(b"ab".to_vec()));
|
||||
let chunks = client.chunks(2, std::convert::identity).unwrap();
|
||||
client.close().unwrap();
|
||||
assert!(chunks.next().await.unwrap().unwrap().is_err());
|
||||
assert!(chunks.next().await.unwrap().is_none());
|
||||
assert!(client.chunks(2, std::convert::identity).is_err());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_stream_is_lazy_and_closing_it_preserves_the_listener() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let listener: Listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let connections = listener.connections(std::convert::identity);
|
||||
connections.close();
|
||||
let _client = net::connect_timeout("127.0.0.1", listener.port().unwrap(), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let _server = listener.accept().await.unwrap();
|
||||
let connections = listener.connections(std::convert::identity);
|
||||
listener.close().unwrap();
|
||||
assert!(connections.next().await.unwrap().unwrap().is_err());
|
||||
assert!(connections.next().await.unwrap().is_none());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_network_arguments_fail_before_waiting() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
assert!(net::listen("127.0.0.1", -1, scope).await.is_err());
|
||||
assert!(net::connect("127.0.0.1", 65536, scope).await.is_err());
|
||||
assert_eq!(
|
||||
net::connect_timeout("localhost", 80, 1000)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
"timed connect requires a numeric IP address"
|
||||
);
|
||||
assert!(net::connect_timeout("127.0.0.1", 80, 0).await.is_err());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_reads_exact_limits_and_allocation_failure_preserve_remaining_bytes() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (client, server) = pair(scope).await;
|
||||
client.timeout(1000).unwrap();
|
||||
server.write(&[0, 255, 1, 0, 2, 128, 3]).await.unwrap();
|
||||
let mut received = Vec::new();
|
||||
while received.len() != 7 {
|
||||
let bytes = client.read(3).await.unwrap().unwrap();
|
||||
assert!(!bytes.is_empty() && bytes.len() <= 3);
|
||||
received.extend(bytes);
|
||||
}
|
||||
assert_eq!(received, [0, 255, 1, 0, 2, 128, 3]);
|
||||
server.write(b"x").await.unwrap();
|
||||
assert!(client.read(i64::MAX).await.is_err());
|
||||
assert_eq!(client.read(1_048_576).await.unwrap(), Some(b"x".to_vec()));
|
||||
server.close().unwrap();
|
||||
assert_eq!(client.read(1_048_576).await.unwrap(), None);
|
||||
assert_eq!(client.read(3).await.unwrap(), None);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use super::{Limits, Runtime, Socket, net};
|
||||
use bytes::BufMut;
|
||||
use std::{io::ErrorKind, time::Instant};
|
||||
use tokio::{io::AsyncWriteExt, net::TcpStream};
|
||||
|
||||
async fn independent_read(socket: &TcpStream, limit: usize) -> Option<Vec<u8>> {
|
||||
socket.readable().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(limit).unwrap();
|
||||
loop {
|
||||
let result = socket.try_read_buf(&mut (&mut bytes).limit(limit));
|
||||
match result {
|
||||
Ok(count) => return (count != 0).then_some(bytes),
|
||||
Err(error) if error.kind() == ErrorKind::WouldBlock => {
|
||||
socket.readable().await.unwrap();
|
||||
}
|
||||
Err(error) => panic!("independent TCP read failed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sample(
|
||||
peer: &mut TcpStream,
|
||||
payload: &[u8],
|
||||
limit: usize,
|
||||
iterations: usize,
|
||||
mut read: impl AsyncFnMut() -> Option<Vec<u8>>,
|
||||
) -> (u128, usize, usize) {
|
||||
let mut elapsed = 0;
|
||||
let mut reads = 0;
|
||||
let mut total = 0;
|
||||
for _ in 0..iterations {
|
||||
peer.write_all(payload).await.unwrap();
|
||||
let mut received = 0;
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
let bytes = read().await;
|
||||
elapsed += started.elapsed().as_nanos();
|
||||
reads += 1;
|
||||
let Some(bytes) = bytes else {
|
||||
assert!(payload.is_empty());
|
||||
break;
|
||||
};
|
||||
assert!(!bytes.is_empty() && bytes.len() <= limit);
|
||||
assert!(received + bytes.len() <= payload.len());
|
||||
assert_eq!(bytes, payload[received..received + bytes.len()]);
|
||||
received += bytes.len();
|
||||
total += bytes.len();
|
||||
if received == payload.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(total, payload.len() * iterations);
|
||||
(elapsed, reads, total)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe; http enables the independent bytes/io-util peer"]
|
||||
fn tcp_read_buffer_cost() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
eprintln!(
|
||||
"tcp_read_buffer: 15 fixed rounds; one async worker; two persistent loopback pairs; \
|
||||
peer writes, byte verification and result drops excluded from read timing; \
|
||||
rust_independent uses Tokio try_read_buf and bytes::BufMut::limit directly; \
|
||||
no shared Dever read implementation; direct peer excludes alias-close and read-lock checks; \
|
||||
actual read counts are retained; empty payload cases measure established EOF"
|
||||
);
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,reads,bytes");
|
||||
for (limit, payload_size, iterations) in [
|
||||
(64, 64, 4096),
|
||||
(65_536, 64, 2048),
|
||||
(1_048_576, 64, 512),
|
||||
(65_536, 65_536, 256),
|
||||
(1_048_576, 65_536, 128),
|
||||
(65_536, 0, 512),
|
||||
(1_048_576, 0, 128),
|
||||
] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let mut dever_peer =
|
||||
TcpStream::connect(("127.0.0.1", listener.port().unwrap() as u16))
|
||||
.await
|
||||
.unwrap();
|
||||
let socket: Socket = listener.accept().await.unwrap();
|
||||
listener.close().unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let mut independent_peer = TcpStream::connect(listener.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let independent = listener.accept().await.unwrap().0;
|
||||
independent.set_nodelay(true).unwrap();
|
||||
dever_peer.set_nodelay(true).unwrap();
|
||||
independent_peer.set_nodelay(true).unwrap();
|
||||
drop(listener);
|
||||
if payload_size == 0 {
|
||||
dever_peer.shutdown().await.unwrap();
|
||||
independent_peer.shutdown().await.unwrap();
|
||||
}
|
||||
let payload = (0..payload_size)
|
||||
.map(|position| (position % 251) as u8)
|
||||
.collect::<Vec<_>>();
|
||||
sample(&mut dever_peer, &payload, limit, 16, async || {
|
||||
socket.read(limit as i64).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
sample(&mut independent_peer, &payload, limit, 16, async || {
|
||||
independent_read(&independent, limit).await
|
||||
})
|
||||
.await;
|
||||
for round in 0..15 {
|
||||
let mut order = ["dever", "rust_independent", "control"];
|
||||
order.rotate_left(round % 3);
|
||||
for label in order {
|
||||
let (elapsed, reads, total) = if label == "rust_independent" {
|
||||
sample(&mut independent_peer, &payload, limit, iterations, async || {
|
||||
independent_read(&independent, limit).await
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
sample(&mut dever_peer, &payload, limit, iterations, async || {
|
||||
socket.read(limit as i64).await.unwrap()
|
||||
})
|
||||
.await
|
||||
};
|
||||
println!(
|
||||
"tcp_read_{limit}_{payload_size},{round},{label},{iterations},{elapsed},{reads},{total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
socket.close().unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use dever_runtime::{
|
||||
collection::parallel::{self, Error as ThreadError},
|
||||
task::{Error, Group, Limits, Runtime, Task},
|
||||
};
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc, Barrier,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
};
|
||||
|
||||
type Tasks = Group<Task<Result<(), &'static str>>, &'static str>;
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronous_workers_process_values_once_with_bounded_overlap() {
|
||||
let active = AtomicUsize::new(0);
|
||||
let maximum = AtomicUsize::new(0);
|
||||
let total = AtomicUsize::new(0);
|
||||
let barrier = Barrier::new(2);
|
||||
assert_eq!(
|
||||
parallel::each(0..32, 2, |value| {
|
||||
let count = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
maximum.fetch_max(count, Ordering::SeqCst);
|
||||
barrier.wait();
|
||||
total.fetch_add(value, Ordering::SeqCst);
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
Ok::<_, &'static str>(())
|
||||
}),
|
||||
Ok(Ok(()))
|
||||
);
|
||||
assert_eq!(maximum.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(total.load(Ordering::SeqCst), (0..32).sum::<usize>());
|
||||
assert_eq!(active.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronous_failure_stops_a_non_send_producer_without_collecting_it() {
|
||||
let pulls = Rc::new(std::cell::Cell::new(0));
|
||||
let values = (0..100).inspect(|_| pulls.set(pulls.get() + 1));
|
||||
assert_eq!(
|
||||
parallel::each(values, 1, |_| Err("first failure")),
|
||||
Ok(Err("first failure"))
|
||||
);
|
||||
assert!(pulls.get() <= 2, "{}", pulls.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronous_limits_empty_sources_and_panics_are_explicit() {
|
||||
for limit in [-1, 0, 257] {
|
||||
assert_eq!(
|
||||
parallel::each(0..0, limit, |_| Ok::<_, ()>(())),
|
||||
Err(ThreadError::Limit)
|
||||
);
|
||||
}
|
||||
assert_eq!(parallel::each(0..0, 256, |_| Ok::<_, ()>(())), Ok(Ok(())));
|
||||
assert_eq!(
|
||||
parallel::each(0..4, 1, |_| -> Result<(), ()> { panic!("worker failure") }),
|
||||
Err(ThreadError::Panicked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_waits_for_capacity_before_invoking_the_pull_factory() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let pulls = AtomicUsize::new(0);
|
||||
let (release, ready) = tokio::sync::oneshot::channel();
|
||||
let mut ready = Some(ready);
|
||||
let mut consume = std::pin::pin!(Tasks::new(1).unwrap().each(
|
||||
|| {
|
||||
let index = pulls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(Ok((index < 2).then_some(index)))
|
||||
},
|
||||
|index| {
|
||||
let ready = if index == 0 { ready.take() } else { None };
|
||||
scope.spawn_leaf(move || async move {
|
||||
if let Some(ready) = ready {
|
||||
ready.await.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
},
|
||||
));
|
||||
poll_fn(|context| {
|
||||
assert!(consume.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(pulls.load(Ordering::SeqCst), 1);
|
||||
release.send(()).unwrap();
|
||||
consume.await.unwrap().unwrap();
|
||||
assert_eq!(pulls.load(Ordering::SeqCst), 3);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_waits_for_children_while_the_source_is_pending() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let parent = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut owner = Some(Notice(dropped));
|
||||
let mut started = Some(started);
|
||||
let mut index = 0;
|
||||
Tasks::new(2)
|
||||
.unwrap()
|
||||
.each(
|
||||
|| {
|
||||
index += 1;
|
||||
let current = index;
|
||||
let signal = if current == 2 { started.take() } else { None };
|
||||
async move {
|
||||
if current == 1 {
|
||||
Ok(Some(1))
|
||||
} else {
|
||||
signal.unwrap().send(()).unwrap();
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
},
|
||||
|_| {
|
||||
let owner = owner.take().unwrap();
|
||||
child.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
std::future::pending().await
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert!(parent.stop().await.unwrap().is_none());
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_observes_child_failure_while_the_producer_is_waiting() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (pulled, ready) = tokio::sync::oneshot::channel();
|
||||
let mut pulled = Some(pulled);
|
||||
let mut ready = Some(ready);
|
||||
let mut index = 0;
|
||||
let result = Tasks::new(2)
|
||||
.unwrap()
|
||||
.each(
|
||||
|| {
|
||||
index += 1;
|
||||
let current = index;
|
||||
let signal = if current == 2 { pulled.take() } else { None };
|
||||
async move {
|
||||
if current == 1 {
|
||||
Ok(Some(1))
|
||||
} else {
|
||||
signal.unwrap().send(()).unwrap();
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
},
|
||||
|_| {
|
||||
let ready = ready.take().unwrap();
|
||||
scope.spawn_leaf(move || async move {
|
||||
ready.await.unwrap();
|
||||
Err("while pulling")
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, Ok(Err("while pulling")));
|
||||
assert_eq!(index, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct Notice(Arc<AtomicBool>);
|
||||
impl Drop for Notice {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_failure_drains_started_tasks_before_returning() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut owner = Some(Notice(dropped));
|
||||
let mut index = 0;
|
||||
let result = Tasks::new(2)
|
||||
.unwrap()
|
||||
.each(
|
||||
|| {
|
||||
index += 1;
|
||||
std::future::ready(if index == 1 {
|
||||
Ok(Some(1))
|
||||
} else {
|
||||
Err("source failed")
|
||||
})
|
||||
},
|
||||
|_| {
|
||||
let owner = owner.take().unwrap();
|
||||
scope.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
std::future::pending().await
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, Ok(Err("source failed")));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_child_cancellation_is_not_treated_as_success() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut first = true;
|
||||
let result = Tasks::new(2)
|
||||
.unwrap()
|
||||
.each(
|
||||
|| {
|
||||
let value = std::mem::take(&mut first);
|
||||
async move {
|
||||
if value {
|
||||
Ok(Some(1))
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
},
|
||||
|_| {
|
||||
let task = scope.spawn_leaf(std::future::pending).unwrap();
|
||||
task.cancel();
|
||||
Ok(task)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, Err(Error::Cancelled));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use dever_runtime::value::{bytes, decimal::Decimal, parse, text};
|
||||
|
||||
#[test]
|
||||
fn byte_sum_matches_checked_add_at_block_and_integer_boundaries() {
|
||||
for length in [0, 1, 15, 16, 4095, 4096, 4097, 8193] {
|
||||
let values = vec![255; length];
|
||||
let sum = length as i64 * 255;
|
||||
for initial in [
|
||||
i64::MIN,
|
||||
-1,
|
||||
0,
|
||||
i64::MAX - sum,
|
||||
i64::MAX - sum + i64::from(length != 0),
|
||||
i64::MAX,
|
||||
] {
|
||||
let expected = values
|
||||
.iter()
|
||||
.try_fold(initial, |total, value| total.checked_add(i64::from(*value)));
|
||||
assert_eq!(
|
||||
bytes::checked_sum(&values, initial),
|
||||
expected,
|
||||
"{length}/{initial}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(bytes::checked_sum(&[0; 8193], i64::MAX), Some(i64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_scalar_conversion_and_positions_keep_boundaries() {
|
||||
assert_eq!(text::codepoint("🙂"), Some(128578));
|
||||
assert_eq!(text::codepoint(""), None);
|
||||
assert_eq!(text::codepoint("e\u{301}"), None);
|
||||
assert_eq!(text::from_codepoint(0), Some("\0".into()));
|
||||
assert_eq!(text::from_codepoint(128578), Some("🙂".into()));
|
||||
for value in [-1, 0xd800, 0x110000] {
|
||||
assert_eq!(text::from_codepoint(value), None);
|
||||
}
|
||||
let value = "a雪🙂e\u{301}";
|
||||
assert_eq!(text::at(value, 2), Some("🙂".into()));
|
||||
assert_eq!(text::slice(value, 1, 3), Some("雪🙂".into()));
|
||||
assert_eq!(text::slice(value, 5, 5), Some("".into()));
|
||||
assert_eq!(text::slice("", 0, 0), Some("".into()));
|
||||
for (start, end) in [(-1, 1), (1, 0), (0, 6), (6, 6), (0, i64::MAX)] {
|
||||
assert_eq!(text::slice(value, start, end), None);
|
||||
}
|
||||
assert_eq!(text::at(value, i64::MAX), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_counts_scalars_and_empty_separator_has_no_synthetic_edges() {
|
||||
assert_eq!(text::index_of("a🙂雪", "雪"), Some(2));
|
||||
assert_eq!(text::index_of("雪", ""), Some(0));
|
||||
assert_eq!(text::index_of("雪", "x"), None);
|
||||
assert_eq!(text::split("a🙂", ""), ["a", "🙂"]);
|
||||
assert!(text::split("", "").is_empty());
|
||||
assert_eq!(text::split(",a,,", ","), ["", "a", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_parsing_uses_the_existing_numeric_and_special_spellings() {
|
||||
assert_eq!(parse::float("1.25e2"), Some(125.0));
|
||||
assert!(parse::float("NaN").unwrap().is_nan());
|
||||
assert_eq!(parse::float("inf"), Some(f64::INFINITY));
|
||||
assert_eq!(parse::float("-inf"), Some(f64::NEG_INFINITY));
|
||||
assert!(parse::float("-0").unwrap().is_sign_negative());
|
||||
for value in [
|
||||
"nan", "+inf", "Infinity", " 1", "1 ", "1_000", "", "+", "1e",
|
||||
] {
|
||||
assert_eq!(parse::float(value), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_text_is_finite_exact_and_normalized() {
|
||||
assert_eq!(parse::decimal("1.2300").unwrap().to_string(), "1.23");
|
||||
assert_eq!(parse::decimal("-0").unwrap().to_string(), "0");
|
||||
assert_eq!(parse::decimal("1e2"), Some(Decimal::from_int(100)));
|
||||
for value in [
|
||||
"NaN",
|
||||
"inf",
|
||||
" 1",
|
||||
"1 ",
|
||||
"1_000",
|
||||
"1e9999",
|
||||
"12345678901234567890123456789012345",
|
||||
] {
|
||||
assert_eq!(parse::decimal(value), None, "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_rounding_uses_half_even_and_rejects_invalid_quanta() {
|
||||
for (value, places, expected) in [
|
||||
("1.25", 1, "1.2"),
|
||||
("1.35", 1, "1.4"),
|
||||
("-1.25", 1, "-1.2"),
|
||||
("250", -2, "200"),
|
||||
("5e-6176", 6176, "5e-6176"),
|
||||
] {
|
||||
assert_eq!(
|
||||
Decimal::parse(value).unwrap().round(places),
|
||||
Some(Decimal::parse(expected).unwrap()),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
let value = Decimal::from_int(1);
|
||||
for places in [i64::MIN, -6112, 6177, i64::MAX] {
|
||||
assert_eq!(value.round(places), None);
|
||||
}
|
||||
assert_eq!(Decimal::parse("1e6144").unwrap().round(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_library_parser_preserves_the_strict_text_contract() {
|
||||
// 逐字节插入检查 dec 的词法边界;不能只测普通数值后删除前置扫描。
|
||||
for source in [
|
||||
"", "0", "-1.25", "+.25", "1E-6176", "1e6144", "NaN", "Infinity",
|
||||
] {
|
||||
for position in 0..=source.len() {
|
||||
for byte in 0..=127u8 {
|
||||
let mut text = source.to_owned();
|
||||
text.insert(position, char::from(byte));
|
||||
let library = Decimal::parse(&text).ok();
|
||||
assert_eq!(
|
||||
parse::decimal(&text).map(Decimal::to_be_bytes),
|
||||
library.map(Decimal::to_be_bytes),
|
||||
"{text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for text in [
|
||||
"12",
|
||||
"١٢",
|
||||
"1\u{a0}",
|
||||
"\u{2003}1",
|
||||
"-∞",
|
||||
"1.2.3",
|
||||
"1e+",
|
||||
"++1",
|
||||
] {
|
||||
assert_eq!(parse::decimal(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::{DropNotice, runtime};
|
||||
use dever_runtime::task::{Error, Timed};
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn dropped_stop_wait_keeps_started_work_tracked() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let runtime = runtime();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, blocked) = std::sync::mpsc::channel();
|
||||
let task = scope
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
{
|
||||
let mut stopped = std::pin::pin!(task.stop());
|
||||
poll_fn(|context| {
|
||||
assert!(stopped.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
assert!(!observed.load(Ordering::SeqCst));
|
||||
release.send(()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_and_unit_results_are_completions_not_expirations() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let nullable = scope.spawn_leaf(|| async { None::<i64> }).unwrap();
|
||||
assert!(matches!(
|
||||
nullable.timeout(10000).await.unwrap(),
|
||||
Timed::Ready(None)
|
||||
));
|
||||
let unit = scope.spawn_leaf(|| async {}).unwrap();
|
||||
assert!(matches!(
|
||||
unit.timeout(10000).await.unwrap(),
|
||||
Timed::Ready(())
|
||||
));
|
||||
let failed = scope
|
||||
.spawn_leaf(|| async { Err::<i64, _>("original") })
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
failed.timeout(10000).await.unwrap(),
|
||||
Timed::Ready(Err("original"))
|
||||
));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiration_and_invalid_duration_wait_for_captured_resources() {
|
||||
for duration in [-1, 0, 1] {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let owner = DropNotice(dropped);
|
||||
let pending = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
started.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let result = pending.timeout(duration).await;
|
||||
if duration > 0 {
|
||||
assert!(matches!(result, Ok(Timed::Expired(None))));
|
||||
} else {
|
||||
assert!(matches!(result, Err(Error::InvalidTimeout)));
|
||||
}
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiration_preserves_a_late_failure_from_started_blocking_work() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, blocked) = std::sync::mpsc::channel();
|
||||
let pending = scope
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
Err::<i64, _>("late failure")
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let mut timed = std::pin::pin!(pending.timeout(1));
|
||||
poll_fn(|context| {
|
||||
assert!(timed.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
poll_fn(|context| {
|
||||
assert!(timed.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
release.send(()).unwrap();
|
||||
assert!(matches!(
|
||||
timed.await.unwrap(),
|
||||
Timed::Expired(Some(Err("late failure")))
|
||||
));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_timeout_wait_drains_the_nested_task() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let parent = scope
|
||||
.spawn(move |child| async move {
|
||||
let owner = DropNotice(dropped);
|
||||
let pending = child
|
||||
.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
started.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
pending.timeout(60000).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert!(parent.stop().await.unwrap().is_none());
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use super::{DropNotice, runtime};
|
||||
use dever_runtime::task::{Error, Group, Limits, Runtime, Task};
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
type Tasks = Group<Task<Result<(), &'static str>>, &'static str>;
|
||||
|
||||
#[test]
|
||||
fn group_limits_are_checked_and_empty_groups_complete() {
|
||||
for limit in [-1, 0, 65537] {
|
||||
assert!(matches!(Tasks::new(limit), Err(Error::InvalidGroupLimit)));
|
||||
}
|
||||
runtime()
|
||||
.run(async |_| {
|
||||
Tasks::new(65536).unwrap().wait().await.unwrap().unwrap();
|
||||
Tasks::new(1).unwrap().stop().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_capacity_defers_the_next_factory() {
|
||||
let called = Arc::new(AtomicBool::new(false));
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(1).unwrap();
|
||||
let (release, ready) = tokio::sync::oneshot::channel();
|
||||
group
|
||||
.run(|| {
|
||||
scope.spawn_leaf(|| async {
|
||||
ready.await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
{
|
||||
let mut next = std::pin::pin!(group.run(|| {
|
||||
called.store(true, Ordering::SeqCst);
|
||||
scope.spawn_leaf(|| async { Ok(()) })
|
||||
}));
|
||||
poll_fn(|context| {
|
||||
assert!(next.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
assert!(!called.load(Ordering::SeqCst));
|
||||
release.send(()).unwrap();
|
||||
next.await.unwrap().unwrap();
|
||||
}
|
||||
assert!(called.load(Ordering::SeqCst));
|
||||
group.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_group_failure_cancels_and_drains_other_tasks() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(2).unwrap();
|
||||
let owner = DropNotice(dropped);
|
||||
group
|
||||
.run(|| {
|
||||
scope.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
std::future::pending::<Result<(), &'static str>>().await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
group
|
||||
.run(|| scope.spawn_leaf(|| async { Err("first failure") }))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(group.wait().await, Ok(Err("first failure")));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_never_invokes_a_waiting_factory() {
|
||||
let called = Arc::new(AtomicBool::new(false));
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(1).unwrap();
|
||||
group
|
||||
.run(|| scope.spawn_leaf(|| async { Err("first failure") }))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let result = group
|
||||
.run(|| {
|
||||
called.store(true, Ordering::SeqCst);
|
||||
scope.spawn_leaf(|| async { Ok(()) })
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result, Ok(Err("first failure")));
|
||||
assert!(!called.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
group.run(|| scope.spawn_leaf(|| async { Ok(()) })).await,
|
||||
Err(Error::Cancelled)
|
||||
);
|
||||
group.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_group_preserves_late_blocking_failures() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(1).unwrap();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, blocked) = std::sync::mpsc::channel();
|
||||
group
|
||||
.run(|| {
|
||||
scope.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
Err("late failure")
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let mut stopped = std::pin::pin!(group.stop());
|
||||
poll_fn(|context| {
|
||||
assert!(stopped.as_mut().poll(context).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(stopped.await, Ok(Err("late failure")));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_failure_drains_existing_group_work() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 1,
|
||||
})
|
||||
.unwrap();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(2).unwrap();
|
||||
let owner = DropNotice(dropped);
|
||||
group
|
||||
.run(|| {
|
||||
scope.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
std::future::pending::<Result<(), &'static str>>().await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
group.run(|| scope.spawn_leaf(|| async { Ok(()) })).await,
|
||||
Err(Error::Capacity)
|
||||
);
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
scope
|
||||
.spawn_leaf(|| async { 42 })
|
||||
.unwrap()
|
||||
.wait()
|
||||
.await
|
||||
.unwrap(),
|
||||
42
|
||||
);
|
||||
group.wait().await.unwrap().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_group_cancels_unclaimed_work() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut group = Tasks::new(2).unwrap();
|
||||
let owner = DropNotice(dropped);
|
||||
group
|
||||
.run(|| {
|
||||
scope.spawn_leaf(move || async move {
|
||||
let _owner = owner;
|
||||
std::future::pending::<Result<(), &'static str>>().await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
drop(group);
|
||||
scope.finish().await;
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
use dever_runtime::task::{Error, Limits, Runtime};
|
||||
|
||||
mod deadline;
|
||||
mod group;
|
||||
mod panic;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct DropNotice(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropNotice {
|
||||
fn drop(&mut self) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_waits_for_blocking_work_after_async_parent_is_cancelled() {
|
||||
let entered = Arc::new(AtomicBool::new(false));
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let parent_entered = entered.clone();
|
||||
let parent_finished = finished.clone();
|
||||
let parent = scope
|
||||
.spawn(move |scope| async move {
|
||||
scope
|
||||
.blocking(move || {
|
||||
parent_entered.store(true, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
parent_finished.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
while !entered.load(Ordering::SeqCst) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(parent.stop().await.unwrap().is_none());
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_errors_and_capacity_are_observable_and_slots_are_reused() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 1,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let pending = scope.spawn_leaf(std::future::pending::<()>).unwrap();
|
||||
assert!(matches!(scope.spawn_sync(|| ()), Err(Error::Capacity)));
|
||||
assert!(pending.stop().await.unwrap().is_none());
|
||||
let value = scope.spawn_sync(|| 42).unwrap().wait().await.unwrap();
|
||||
assert_eq!(value, 42);
|
||||
let failure = scope
|
||||
.spawn_leaf(|| async {
|
||||
panic!("test panic");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failure.wait().await, Err(Error::Panicked));
|
||||
let failure = scope.blocking(|| panic!("direct worker panic")).await;
|
||||
assert_eq!(failure, Err(Error::Panicked));
|
||||
assert_eq!(scope.blocking(|| 42).await.unwrap(), 42);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_function_wait_keeps_descendants_tracked_until_their_real_exit() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
runtime()
|
||||
.run(async |root| {
|
||||
let parent = root
|
||||
.spawn(move |child| async move {
|
||||
let nested = child
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
nested.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert!(parent.stop().await.unwrap().is_none());
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_queued_worker_never_executes_its_call() {
|
||||
let called = Arc::new(AtomicBool::new(false));
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, blocked) = std::sync::mpsc::channel();
|
||||
let first = scope
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
blocked
|
||||
.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let work_called = called.clone();
|
||||
let queued = scope
|
||||
.spawn_sync(move || work_called.store(true, Ordering::SeqCst))
|
||||
.unwrap();
|
||||
assert!(queued.stop().await.unwrap().is_none());
|
||||
release.send(()).unwrap();
|
||||
first.wait().await.unwrap();
|
||||
assert!(!called.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_output_can_outlive_its_task_scope() {
|
||||
let runtime = runtime();
|
||||
for direct in [false, true] {
|
||||
let value = runtime
|
||||
.run(async |scope| {
|
||||
let value = if direct {
|
||||
scope
|
||||
.blocking(|| String::from("owned output"))
|
||||
.await
|
||||
.unwrap()
|
||||
} else {
|
||||
let task = scope.spawn_sync(|| String::from("owned output")).unwrap();
|
||||
task.wait().await.unwrap()
|
||||
};
|
||||
scope.finish().await;
|
||||
assert!(matches!(
|
||||
scope.spawn_leaf(|| async {}),
|
||||
Err(Error::Cancelled)
|
||||
));
|
||||
assert_eq!(scope.blocking(|| 42).await, Err(Error::Cancelled));
|
||||
value
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, "owned output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_empty_scope_rejects_new_work() {
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
scope.finish().await;
|
||||
assert!(matches!(
|
||||
scope.spawn_leaf(|| async {}),
|
||||
Err(Error::Cancelled)
|
||||
));
|
||||
assert_eq!(scope.blocking(|| 42).await, Err(Error::Cancelled));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_panic_drains_workers_from_dropped_function_handles() {
|
||||
let runtime = runtime();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let result = runtime.run(async |root| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let _task = root
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
panic!("root failure after worker started");
|
||||
});
|
||||
assert_eq!(result, Err(Error::Panicked));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_panic_cancels_leaf_work_and_allows_the_next_entry() {
|
||||
let runtime = runtime();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
let result = runtime.run(async |root| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let _task = root
|
||||
.spawn_leaf(move || async move {
|
||||
let _owned = DropNotice(dropped);
|
||||
started.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
panic!("root failure after leaf started");
|
||||
});
|
||||
assert_eq!(result, Err(Error::Panicked));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
let result = runtime.run(async |root| {
|
||||
root.spawn_leaf(|| async { Err::<(), _>(String::from("business failure")) })
|
||||
.unwrap()
|
||||
.wait()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(result, Ok(Err(String::from("business failure"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_task_results_are_dropped_before_scope_finish_returns() {
|
||||
let runtime = runtime();
|
||||
for direct in [false, true] {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let call = move || {
|
||||
started.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
DropNotice(dropped)
|
||||
};
|
||||
let task = if direct {
|
||||
scope
|
||||
.spawn(move |child| async move { child.blocking(call).await.unwrap() })
|
||||
.unwrap()
|
||||
} else {
|
||||
scope.spawn_sync(call).unwrap()
|
||||
};
|
||||
ready.await.unwrap();
|
||||
drop(task);
|
||||
scope.finish().await;
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_tasks_drop_unclaimed_outputs_before_scope_returns() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap();
|
||||
for leaf in [false, true] {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
runtime
|
||||
.run(async |root| {
|
||||
let (completed, ready) = tokio::sync::oneshot::channel();
|
||||
let call = move || async move {
|
||||
// 唯一 worker 在本任务完成后才会运行 marker,不用睡眠猜测完成时点。
|
||||
tokio::spawn(async move {
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
DropNotice(dropped)
|
||||
};
|
||||
let task = if leaf {
|
||||
root.spawn_leaf(call).unwrap()
|
||||
} else {
|
||||
root.spawn(move |_child| call()).unwrap()
|
||||
};
|
||||
ready.await.unwrap();
|
||||
drop(task);
|
||||
root.finish().await;
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclaimed_panicking_leaf_drops_owned_values_before_scope_finishes() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let task = scope
|
||||
.spawn_leaf(move || async move {
|
||||
let _owned = DropNotice(finished);
|
||||
started.send(()).unwrap();
|
||||
panic!("unclaimed leaf panic");
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
drop(task);
|
||||
scope.finish().await;
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_panic_drains_started_workers_before_reporting_failure() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
runtime()
|
||||
.run(async |root| {
|
||||
let parent = root
|
||||
.spawn(move |child| async move {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let _worker = child
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
panic!("parent panic after worker started");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(parent.wait().await, Err(Error::Panicked));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_leaf_waits_for_its_pending_future_destructor() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
runtime()
|
||||
.run(async |root| {
|
||||
let leaf = root
|
||||
.spawn_leaf(move || async move {
|
||||
let _owned = DropNotice(finished);
|
||||
started.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert_eq!(leaf.stop().await, Ok(None));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_parent_drains_its_suspended_leaf() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
runtime()
|
||||
.run(async |root| {
|
||||
let parent = root
|
||||
.spawn(move |child| async move {
|
||||
let leaf = child
|
||||
.spawn_leaf(move || async move {
|
||||
let _owned = DropNotice(dropped);
|
||||
started.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
leaf.wait().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
assert_eq!(parent.stop().await, Ok(None));
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_completed_leaves_preserves_business_errors_and_panics() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap();
|
||||
for panicked in [false, true] {
|
||||
runtime
|
||||
.run(async |root| {
|
||||
let (completed, ready) = tokio::sync::oneshot::channel();
|
||||
let task = root
|
||||
.spawn_leaf(move || async move {
|
||||
// 唯一 worker 只能在本任务完成清理后再运行 marker,避免靠睡眠猜测完成。
|
||||
tokio::spawn(async move {
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
if panicked {
|
||||
panic!("completed leaf panic");
|
||||
}
|
||||
Err::<(), _>("completed failure")
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let result = task.stop().await;
|
||||
if panicked {
|
||||
assert_eq!(result, Err(Error::Panicked));
|
||||
} else {
|
||||
assert_eq!(result, Ok(Some(Err("completed failure"))));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpolled_leaf_inputs_are_dropped_before_scope_finishes() {
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 8,
|
||||
})
|
||||
.unwrap();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
let entered = Arc::new(AtomicBool::new(false));
|
||||
let polled = entered.clone();
|
||||
runtime
|
||||
.run(async |root| {
|
||||
// 单线程入口在这里不挂起,子任务第一次 poll 前就会收到中止。
|
||||
let input = DropNotice(dropped);
|
||||
let task = root
|
||||
.spawn_leaf(move || async move {
|
||||
entered.store(true, Ordering::SeqCst);
|
||||
let _input = input;
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
drop(task);
|
||||
root.finish().await;
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
assert!(!polled.load(Ordering::SeqCst));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_entries_drop_future_state_and_preserve_results_and_panics() {
|
||||
let runtime = runtime();
|
||||
for panicked in [false, true] {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed = dropped.clone();
|
||||
let result = runtime.run_leaf(async move {
|
||||
let _value = DropNotice(dropped);
|
||||
tokio::task::yield_now().await;
|
||||
if panicked {
|
||||
panic!("leaf entry panic");
|
||||
}
|
||||
String::from("owned result")
|
||||
});
|
||||
assert!(observed.load(Ordering::SeqCst));
|
||||
if panicked {
|
||||
assert_eq!(result, Err(Error::Panicked));
|
||||
} else {
|
||||
assert_eq!(result.unwrap(), "owned result");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use super::{Error, runtime};
|
||||
use dever_runtime::task::Scope;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
fn failed_factory(_: &Scope<'_>) -> std::future::Ready<()> {
|
||||
panic!("future construction failed");
|
||||
}
|
||||
|
||||
struct FailedDrop;
|
||||
|
||||
impl Future for FailedDrop {
|
||||
type Output = ();
|
||||
|
||||
fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
|
||||
Poll::Ready(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FailedDrop {
|
||||
fn drop(&mut self) {
|
||||
panic!("completed future destruction failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_drop(_: &Scope<'_>) -> FailedDrop {
|
||||
FailedDrop
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_catches_future_construction_and_completed_destruction_panics() {
|
||||
let runtime = runtime();
|
||||
assert_eq!(runtime.run(failed_factory), Err(Error::Panicked));
|
||||
assert_eq!(runtime.run(failed_drop), Err(Error::Panicked));
|
||||
assert_eq!(runtime.run(async |_| 42), Ok(42));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quoted_filename_preserves_semicolons_and_escaped_quotes() {
|
||||
let directory = Directory::new();
|
||||
let bytes = String::from_utf8(content(b"data"))
|
||||
.unwrap()
|
||||
.replace("filename=image.bin", r#"filename="semi;with\"quote.bin""#)
|
||||
.into_bytes();
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let mut parsed = upload::decode(
|
||||
body(&bytes, 1),
|
||||
"multipart/form-data; boundary=\"boundary\"",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let file = parsed.take("file").unwrap();
|
||||
assert_eq!(file.filename(), "semi;with\"quote.bin");
|
||||
file.close();
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn parent_symlinks_cannot_redirect_temporary_or_stored_uploads() {
|
||||
let directory = Directory::new();
|
||||
let outside = Directory::new();
|
||||
let bytes = content(b"data");
|
||||
std::os::unix::fs::symlink(&outside.0, directory.0.join("linked")).unwrap();
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
for path in [directory.0.join("linked"), directory.0.join("linked/data")] {
|
||||
assert!(
|
||||
upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&path,
|
||||
&Limits::default(),
|
||||
scope
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&outside.0).unwrap().count(), 0);
|
||||
}
|
||||
let mut parsed = upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
std::os::unix::fs::symlink(&outside.0, directory.0.join("upload")).unwrap();
|
||||
assert!(
|
||||
upload::put(parsed.take("file").unwrap(), scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&outside.0).unwrap().count(), 0);
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use bytes::Bytes;
|
||||
use dever_runtime::{
|
||||
task::{Limits as TaskLimits, Runtime},
|
||||
upload::{self, Limits},
|
||||
};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::body::Frame;
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod headers;
|
||||
mod performance;
|
||||
mod transport;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Fields {
|
||||
caption: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-upload-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn temporary_count(&self) -> usize {
|
||||
std::fs::read_dir(self.0.join("tmp")).map_or(0, |files| files.count())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn content(file: &[u8]) -> Vec<u8> {
|
||||
let mut body = b"--boundary\r\nContent-Disposition: form-data; name=caption\r\n\r\nhello\r\n--boundary\r\nContent-Disposition: form-data; name=count\r\n\r\n7\r\n--boundary\r\nContent-Disposition: form-data; name=file; filename=image.bin\r\nContent-Type: application/octet-stream\r\n\r\n".to_vec();
|
||||
body.extend_from_slice(file);
|
||||
body.extend_from_slice(b"\r\n--boundary--\r\n");
|
||||
body
|
||||
}
|
||||
|
||||
fn body(
|
||||
bytes: &[u8],
|
||||
size: usize,
|
||||
) -> impl hyper::body::Body<Data = Bytes, Error = Infallible> + Unpin + Send + 'static {
|
||||
let chunks = bytes
|
||||
.chunks(size)
|
||||
.map(|chunk| Ok(Frame::data(Bytes::copy_from_slice(chunk))))
|
||||
.collect::<Vec<_>>();
|
||||
StreamBody::new(stream::iter(chunks))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_files_preserve_false_boundaries_and_transfer_once() {
|
||||
let directory = Directory::new();
|
||||
let mut payload = vec![b'x'; 170_001];
|
||||
payload.extend_from_slice(b"\r\n--boundaryX\r\n--boundary--X\r\n--boundary-");
|
||||
let bytes = content(&payload);
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
for size in [1, 7, 16_384, 250_000] {
|
||||
let mut decoded = upload::decode(
|
||||
body(&bytes, size),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let file = decoded.take("file").unwrap();
|
||||
assert_eq!(file.filename(), "image.bin");
|
||||
assert_eq!(file.content_type(), "application/octet-stream");
|
||||
assert_eq!(file.size(), payload.len() as i64);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.finish()
|
||||
.unwrap()
|
||||
.decode::<Fields>(dever_runtime::wire::decode)
|
||||
.unwrap(),
|
||||
Fields {
|
||||
caption: "hello".into(),
|
||||
count: 7
|
||||
}
|
||||
);
|
||||
let key = upload::put(file, scope).await.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(directory.0.join("upload").join(key.to_text())).unwrap(),
|
||||
payload
|
||||
);
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_limited_bodies_remove_every_temporary_file() {
|
||||
let directory = Directory::new();
|
||||
let bytes = content(&vec![b'x'; 20_000]);
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let invalid = [
|
||||
bytes[..bytes.len() - 8].to_vec(),
|
||||
[bytes.clone(), b"epilogue".to_vec()].concat(),
|
||||
String::from_utf8(bytes.clone())
|
||||
.unwrap()
|
||||
.replace("image.bin", "../escape")
|
||||
.into_bytes(),
|
||||
];
|
||||
for bytes in invalid {
|
||||
assert!(
|
||||
upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
}
|
||||
for limits in [
|
||||
Limits {
|
||||
total_bytes: 10_000,
|
||||
file_bytes: 10_000,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
file_bytes: 10_000,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
field_bytes: 2,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
parts: 2,
|
||||
fields: 2,
|
||||
..Limits::default()
|
||||
},
|
||||
Limits {
|
||||
header_bytes: 10,
|
||||
..Limits::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&limits,
|
||||
scope
|
||||
)
|
||||
.await,
|
||||
Err(upload::Error::Limit)
|
||||
));
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_across_fields_and_files_remove_temporary_files() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
for parts in [
|
||||
[("same", false), ("same", false)],
|
||||
[("same", false), ("same", true)],
|
||||
[("same", true), ("same", false)],
|
||||
[("same", true), ("same", true)],
|
||||
[("first", true), ("second", true)],
|
||||
] {
|
||||
let mut source = String::new();
|
||||
for (name, file) in parts {
|
||||
let filename = if file { "; filename=example.txt" } else { "" };
|
||||
source.push_str(&format!(
|
||||
"--boundary\r\nContent-Disposition: form-data; name={name}{filename}\r\n\r\nvalue\r\n"
|
||||
));
|
||||
}
|
||||
source.push_str("--boundary--\r\n");
|
||||
assert!(matches!(
|
||||
upload::decode(
|
||||
body(source.as_bytes(), 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await,
|
||||
Err(upload::Error::Invalid)
|
||||
));
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_while_waiting_for_more_file_bytes_removes_the_file() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.clone();
|
||||
let mut bytes = content(&vec![b'x'; 32_768]);
|
||||
bytes.truncate(bytes.len() - 16);
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
let frames = stream::once(std::future::ready(Ok::<_, Infallible>(
|
||||
Frame::data(Bytes::from(bytes)),
|
||||
)))
|
||||
.chain(stream::pending());
|
||||
upload::decode(
|
||||
StreamBody::new(frames),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&path,
|
||||
&Limits::default(),
|
||||
&child,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while directory.temporary_count() == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
task.cancel();
|
||||
assert!(matches!(
|
||||
task.wait().await,
|
||||
Err(dever_runtime::task::Error::Cancelled) | Ok(Err(upload::Error::Cancelled))
|
||||
));
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_unconsumed_file_and_failed_storage_remove_the_temporary() {
|
||||
let directory = Directory::new();
|
||||
let bytes = content(b"data");
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let parsed = upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(directory.temporary_count(), 1);
|
||||
drop(parsed);
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
std::fs::write(directory.0.join("upload"), b"not a directory").unwrap();
|
||||
let mut parsed = upload::decode(
|
||||
body(&bytes, 7),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&Limits::default(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
upload::put(parsed.take("file").unwrap(), scope)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::*;
|
||||
use std::{collections::BTreeMap, hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn multipart_field_cost() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for count in [1, 16, 64] {
|
||||
let mut source = String::new();
|
||||
for index in 0..count {
|
||||
source.push_str(&format!(
|
||||
"--boundary\r\nContent-Disposition: form-data; name=field{index}\r\n\r\n{}\r\n",
|
||||
"a".repeat(64)
|
||||
));
|
||||
}
|
||||
source.push_str("--boundary--\r\n");
|
||||
let bytes = Bytes::from(source);
|
||||
let limits = Limits {
|
||||
fields: count,
|
||||
parts: count,
|
||||
..Limits::default()
|
||||
};
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..1000 {
|
||||
let multipart = upload::decode(
|
||||
http_body_util::Full::new(black_box(bytes.clone())),
|
||||
"multipart/form-data; boundary=boundary",
|
||||
&directory.0,
|
||||
&limits,
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let fields = multipart
|
||||
.finish()
|
||||
.unwrap()
|
||||
.decode::<BTreeMap<String, String>>(dever_runtime::wire::decode)
|
||||
.unwrap();
|
||||
checksum += black_box(fields.len());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, count * 1000);
|
||||
println!("multipart_{count},{sample},1000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::*;
|
||||
use dever_runtime::{api, http, net};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn http1_and_http2_stream_uploads_above_the_json_body_limit() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(TaskLimits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
for http2 in [false, true] {
|
||||
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
|
||||
let port = listener.port().unwrap();
|
||||
let incoming = listener.clone();
|
||||
let data = Arc::new(directory.0.clone());
|
||||
let transport = http::Limits {
|
||||
body_bytes: 1024,
|
||||
http2: http2.then(Default::default),
|
||||
..Default::default()
|
||||
};
|
||||
let client_limits = http::Limits {
|
||||
body_bytes: 1_048_576,
|
||||
..transport.clone()
|
||||
};
|
||||
let server = scope
|
||||
.spawn(move |child| async move {
|
||||
api::serve_upload::<_, _, true>(
|
||||
&incoming,
|
||||
&transport,
|
||||
&child,
|
||||
None,
|
||||
move |request, body, child, _| {
|
||||
let data = data.clone();
|
||||
async move {
|
||||
let mut parsed = upload::read(
|
||||
&request,
|
||||
body,
|
||||
&data,
|
||||
&Limits::default(),
|
||||
&child,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let file = parsed.take("file").unwrap();
|
||||
let size = file.size();
|
||||
parsed
|
||||
.finish()
|
||||
.unwrap()
|
||||
.decode::<Fields>(dever_runtime::wire::decode)
|
||||
.unwrap();
|
||||
file.close();
|
||||
Ok(api::response(size.to_string()))
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let request = http::Request {
|
||||
method: "POST".into(),
|
||||
target: "/upload".into(),
|
||||
headers: vec![
|
||||
http::Header {
|
||||
name: "host".into(),
|
||||
value: b"fixture.test".to_vec().into(),
|
||||
},
|
||||
http::Header {
|
||||
name: "content-type".into(),
|
||||
value: b"multipart/form-data; boundary=boundary".to_vec().into(),
|
||||
},
|
||||
],
|
||||
body: content(&vec![b'x'; 170_001]).into(),
|
||||
};
|
||||
let response = http::send("127.0.0.1", port, request, &client_limits)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body.as_ref(), b"170001");
|
||||
assert_eq!(directory.temporary_count(), 0);
|
||||
listener.close().unwrap();
|
||||
server.wait().await.unwrap().unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use dever_runtime::value::uuid::Uuid;
|
||||
|
||||
const LOWER: &str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const UPPER: &str = "550E8400-E29B-41D4-A716-446655440000";
|
||||
|
||||
#[test]
|
||||
fn uuid_is_an_inline_canonical_value_and_map_key() {
|
||||
let value = Uuid::parse(UPPER).unwrap();
|
||||
assert_eq!(value.to_text(), LOWER);
|
||||
assert_eq!(value, Uuid::parse(LOWER).unwrap());
|
||||
assert_eq!(std::mem::size_of::<Uuid>(), 16);
|
||||
assert!(!std::mem::needs_drop::<Uuid>());
|
||||
let mut values = dever_runtime::collection::Map::with_capacity(1);
|
||||
values.insert(value, 1);
|
||||
values.insert(Uuid::parse(LOWER).unwrap(), 2);
|
||||
assert_eq!(values.len(), 1);
|
||||
assert_eq!(values.get(&value), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_hyphens_hex_and_alternative_forms_are_rejected_without_panics() {
|
||||
for value in [
|
||||
"",
|
||||
"550e8400e29b41d4a716446655440000",
|
||||
"{550e8400-e29b-41d4-a716-446655440000}",
|
||||
"urn:uuid:550e8400-e29b-41d4-a716-446655440000",
|
||||
"00000000-0000-0000-0000-00000000000-",
|
||||
] {
|
||||
assert_eq!(Uuid::parse(value), None, "{value}");
|
||||
}
|
||||
for index in 0..36 {
|
||||
let mut text = LOWER.as_bytes().to_vec();
|
||||
text[index] = if matches!(index, 8 | 13 | 18 | 23) {
|
||||
b'0'
|
||||
} else {
|
||||
b'-'
|
||||
};
|
||||
assert!(
|
||||
Uuid::parse(std::str::from_utf8(&text).unwrap()).is_none(),
|
||||
"{index}"
|
||||
);
|
||||
}
|
||||
assert!(Uuid::parse("ffffffff-ffff-ffff-ffff-ffffffffffff").is_some());
|
||||
assert!(Uuid::parse("00000000-0000-0000-0000-000000000000").is_some());
|
||||
assert!(Uuid::parse(&format!("é{}", &LOWER[2..])).is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "wire")]
|
||||
#[test]
|
||||
fn uuid_wire_preserves_the_strict_source_format() {
|
||||
let value: Uuid = serde_json::from_str(&format!("\"{UPPER}\"")).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
format!("\"{LOWER}\"")
|
||||
);
|
||||
for value in ["0", "null", r#""550e8400e29b41d4a716446655440000""#] {
|
||||
assert!(serde_json::from_str::<Uuid>(value).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 真实命令的字节管道、非零退出、超时和输出预算;所有写入都在当前测试目录。
|
||||
|
||||
use super::{Asset, Bundle, Directory, Limits, Runtime};
|
||||
use dever_runtime::worker::command::{self, Limits as CommandLimits};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, path::PathBuf, process::Command, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn command_runs_owned_native_fixture_without_shell_and_drains_on_failure() {
|
||||
let _execution = super::EXECUTOR.lock().unwrap();
|
||||
let directory = Directory::new();
|
||||
let fixture = directory.0.join("fixture");
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../test/rust/runtime/worker/fixture.rs");
|
||||
let result = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let runner = directory.0.join("runner");
|
||||
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
|
||||
let result = Command::new(runner)
|
||||
.args(["--exact", "command::owned_command_case", "--nocapture"])
|
||||
.env_clear()
|
||||
.env("DEVER_COMMAND_FIXTURE", fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_command_case() {
|
||||
let Some(fixture) = std::env::var_os("DEVER_COMMAND_FIXTURE") else {
|
||||
return;
|
||||
};
|
||||
let bytes = fs::read(&fixture).unwrap();
|
||||
fs::remove_file(fixture).unwrap();
|
||||
let entry = "module/demo/tool/command";
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-command-launch-v1","ecosystem":"command","entry":entry,"executable":entry,"arguments":[],"working_directory":"module/demo/tool"})).unwrap();
|
||||
let files = [
|
||||
(entry, bytes, true),
|
||||
("module/demo/tool/command.dever-command.json", launch, false),
|
||||
]
|
||||
.map(|(path, bytes, executable)| Asset {
|
||||
path,
|
||||
sha256: Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()),
|
||||
bytes: Box::leak(bytes.into_boxed_slice()),
|
||||
executable,
|
||||
});
|
||||
let bundle: &'static Bundle = Box::leak(Box::new(Bundle {
|
||||
files: Box::leak(Box::new(files)),
|
||||
}));
|
||||
let root = std::env::current_exe()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let arguments = ["--echo", "a b", "$(touch never)", "\"quoted\""].map(str::to_owned);
|
||||
let output = command::run(
|
||||
scope,
|
||||
bundle,
|
||||
entry,
|
||||
&arguments,
|
||||
Some(&[0, 255, 1]),
|
||||
CommandLimits::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.code, 7);
|
||||
assert_eq!(output.stdout, [0, 255, 1]);
|
||||
assert_eq!(output.stderr, b"a b\n$(touch never)\n\"quoted\"");
|
||||
let empty = command::run(
|
||||
scope,
|
||||
bundle,
|
||||
entry,
|
||||
&["--echo".into()],
|
||||
None,
|
||||
CommandLimits::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(empty.stdout.is_empty());
|
||||
let error = command::run(
|
||||
scope,
|
||||
bundle,
|
||||
entry,
|
||||
&["--flood".into()],
|
||||
None,
|
||||
CommandLimits {
|
||||
timeout_ms: 1000,
|
||||
output_limit: 100,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("limit"));
|
||||
let marker = root.join("timed-pid");
|
||||
let error = command::run(
|
||||
scope,
|
||||
bundle,
|
||||
entry,
|
||||
&["--hang".into(), marker.to_str().unwrap().into()],
|
||||
None,
|
||||
CommandLimits {
|
||||
timeout_ms: 100,
|
||||
output_limit: 100,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("timed out"));
|
||||
let pid = fs::read_to_string(marker).unwrap();
|
||||
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
|
||||
let marker = root.join("cancelled-pid");
|
||||
let arguments = ["--hang".into(), marker.to_str().unwrap().into()];
|
||||
let running = command::run(
|
||||
scope,
|
||||
bundle,
|
||||
entry,
|
||||
&arguments,
|
||||
None,
|
||||
CommandLimits::default(),
|
||||
);
|
||||
tokio::pin!(running);
|
||||
tokio::select! {
|
||||
_ = &mut running => panic!("hanging command completed before cancellation"),
|
||||
ready = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while !marker.is_file() { tokio::time::sleep(Duration::from_millis(5)).await; }
|
||||
}) => ready.unwrap(),
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let pid = fs::read_to_string(root.join("cancelled-pid")).unwrap();
|
||||
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, receive) = std::sync::mpsc::channel();
|
||||
let blocker = scope
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
receive.recv().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let queued = scope
|
||||
.spawn(move |child| async move {
|
||||
command::run(
|
||||
&child,
|
||||
bundle,
|
||||
entry,
|
||||
&["--echo".into()],
|
||||
None,
|
||||
CommandLimits::default(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let stopped =
|
||||
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
|
||||
release.send(()).unwrap();
|
||||
blocker.wait().await.unwrap();
|
||||
assert!(matches!(
|
||||
stopped,
|
||||
Ok(Ok(dever_runtime::task::Timed::Expired(None)))
|
||||
));
|
||||
worker_finish(scope).await;
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let marker = root.join("child-cancelled-pid");
|
||||
let arguments = vec!["--hang".into(), marker.to_str().unwrap().into()];
|
||||
let child = scope
|
||||
.spawn(move |child| async move {
|
||||
command::run(
|
||||
&child,
|
||||
bundle,
|
||||
entry,
|
||||
&arguments,
|
||||
None,
|
||||
CommandLimits::default(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while !marker.is_file() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
child.stop().await.unwrap();
|
||||
let pid = fs::read_to_string(marker).unwrap();
|
||||
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
|
||||
worker_finish(scope).await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn worker_finish(scope: &dever_runtime::task::Scope<'_>) {
|
||||
dever_runtime::worker::finish(scope, Ok(())).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 独立 std-only 协议对端;测试从固定 Rust 工具链构建,不调用宿主语言环境。
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn read() -> Option<String> {
|
||||
let mut prefix = [0; 4];
|
||||
std::io::stdin().read_exact(&mut prefix).ok()?;
|
||||
let mut bytes = vec![0; u32::from_be_bytes(prefix) as usize];
|
||||
std::io::stdin().read_exact(&mut bytes).unwrap();
|
||||
Some(String::from_utf8(bytes).unwrap())
|
||||
}
|
||||
|
||||
fn send(value: &str) {
|
||||
let mut output = std::io::stdout().lock();
|
||||
output
|
||||
.write_all(&(value.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
output.write_all(value.as_bytes()).unwrap();
|
||||
output.flush().unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
if let Some(mode) = args.first() {
|
||||
match mode.as_str() {
|
||||
"--echo" => {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes).unwrap();
|
||||
std::io::stdout().write_all(&bytes).unwrap();
|
||||
std::io::stderr()
|
||||
.write_all(args[1..].join("\n").as_bytes())
|
||||
.unwrap();
|
||||
std::process::exit(7);
|
||||
}
|
||||
"--hang" => {
|
||||
std::fs::write(&args[1], std::process::id().to_string()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
return;
|
||||
}
|
||||
"--flood" => {
|
||||
std::io::stdout().write_all(&[1; 4096]).unwrap();
|
||||
std::io::stderr().write_all(&[2; 4096]).unwrap();
|
||||
return;
|
||||
}
|
||||
_ => panic!("unknown test command"),
|
||||
}
|
||||
}
|
||||
let Some(hello) = read() else {
|
||||
return;
|
||||
};
|
||||
assert!(hello.contains("\"kind\":\"hello\""));
|
||||
let (fields, setting) = hello.rsplit_once(",\"setting\":").unwrap();
|
||||
if setting.contains("sensitive") {
|
||||
assert!(setting.contains("sensitive\\\"\\\\雪"));
|
||||
}
|
||||
send(&format!("{fields}}}").replace("\"kind\":\"hello\"", "\"kind\":\"ready\""));
|
||||
let health = read().unwrap();
|
||||
assert!(health.contains("\"kind\":\"health\""));
|
||||
send(&health);
|
||||
let mut bad_shutdown = false;
|
||||
while let Some(request) = read() {
|
||||
if request == "{\"kind\":\"shutdown\"}" {
|
||||
if bad_shutdown {
|
||||
send("{}");
|
||||
std::process::exit(7);
|
||||
}
|
||||
send(&request);
|
||||
return;
|
||||
}
|
||||
let id = request
|
||||
.split("\"id\":")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split([',', '}'])
|
||||
.next()
|
||||
.unwrap();
|
||||
if request.contains("\"kind\":\"cancel\"") {
|
||||
send(&format!(
|
||||
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"dever.cancelled\",\"payload\":null}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"measure\"") {
|
||||
let payload = request
|
||||
.split_once(",\"payload\":")
|
||||
.unwrap()
|
||||
.1
|
||||
.strip_suffix('}')
|
||||
.unwrap();
|
||||
let length = payload.len();
|
||||
let checksum: u64 = payload.bytes().map(u64::from).sum();
|
||||
send(&format!(
|
||||
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"length\":{length},\"checksum\":{checksum}}}}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"crash\"") {
|
||||
std::process::exit(7);
|
||||
}
|
||||
if request.contains("\"operation\":\"hang\"") {
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"ignore\"") {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"shutdown_bad\"") {
|
||||
bad_shutdown = true;
|
||||
}
|
||||
if request.contains("\"operation\":\"native\"") {
|
||||
if request.contains("\"input\":-4") || request.contains("\"input\":-5") {
|
||||
bad_shutdown = true;
|
||||
}
|
||||
if request.contains("\"input\":-1") {
|
||||
send(&format!(
|
||||
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":\"wrong-type\"}}}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"input\":-2")
|
||||
|| request.contains("\"input\":-3")
|
||||
|| request.contains("\"input\":-5")
|
||||
{
|
||||
let error = if request.contains("\"input\":-2") {
|
||||
"not.declared.Failed"
|
||||
} else {
|
||||
"dever.api.Error.Invalid"
|
||||
};
|
||||
send(&format!(
|
||||
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"{error}\",\"payload\":{{}}}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let value = if request.contains("\"input\":99}") {
|
||||
std::process::id()
|
||||
} else {
|
||||
42
|
||||
};
|
||||
send(&format!(
|
||||
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":{value}}}}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"bad\"") {
|
||||
send("{\"kind\":\"result\",\"id\":900,\"payload\":{}}");
|
||||
continue;
|
||||
}
|
||||
if request.contains("\"operation\":\"error\"") {
|
||||
send(&format!(
|
||||
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"demo.test.Error.Failed\",\"payload\":{{\"message\":\"failed\"}}}}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some((_, fields)) = [
|
||||
("null_error", "\"kind\":\"result\",\"error\":null"),
|
||||
("missing_error", "\"kind\":\"error\""),
|
||||
("extra_field", "\"kind\":\"result\",\"unexpected\":true"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(operation, _)| request.contains(&format!("\"operation\":\"{operation}\"")))
|
||||
{
|
||||
send(&format!("{{{fields},\"id\":{id},\"payload\":null}}"));
|
||||
continue;
|
||||
}
|
||||
let response = format!(
|
||||
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":42,\"pid\":{}}}}}",
|
||||
std::process::id()
|
||||
);
|
||||
if request.contains("\"operation\":\"fragment\"") {
|
||||
let prefix = (response.len() as u32).to_be_bytes();
|
||||
std::io::stdout().write_all(&prefix[..2]).unwrap();
|
||||
std::io::stdout().flush().unwrap();
|
||||
assert!(read().unwrap().contains("\"kind\":\"cancel\""));
|
||||
std::io::stdout().write_all(&prefix[2..]).unwrap();
|
||||
std::io::stdout().write_all(response.as_bytes()).unwrap();
|
||||
std::io::stdout().flush().unwrap();
|
||||
} else {
|
||||
send(&response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 逐调用取消与校验失败必须完成自己的清理,不等待另一个调用或阻塞任务。
|
||||
|
||||
use super::{CONTRACT, Directory, ENTRY, EXECUTOR, bundle, call, pid};
|
||||
use dever_runtime::{
|
||||
task::{Limits, Runtime, Timed},
|
||||
wire::json::Json,
|
||||
worker::{self, Setting},
|
||||
};
|
||||
use std::{fs, path::PathBuf, process::Command, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn worker_validation_cancellation_and_cleanup_are_owned() {
|
||||
let _execution = EXECUTOR.lock().unwrap();
|
||||
let directory = Directory::new();
|
||||
let fixture = directory.0.join("fixture");
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../test/rust/runtime/worker/fixture.rs");
|
||||
let built = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
built.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&built.stderr)
|
||||
);
|
||||
let runner = directory.0.join("runner");
|
||||
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
|
||||
let result = Command::new(runner)
|
||||
.args(["--exact", "lifecycle::owned_lifecycle_case", "--nocapture"])
|
||||
.env_clear()
|
||||
.env("DEVER_LIFECYCLE_FIXTURE", fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_lifecycle_case() {
|
||||
let Some(fixture) = std::env::var_os("DEVER_LIFECYCLE_FIXTURE") else {
|
||||
return;
|
||||
};
|
||||
let bundle = bundle(fs::read(&fixture).unwrap());
|
||||
fs::remove_file(fixture).unwrap();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let first = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
let held = worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"echo",
|
||||
Json::parse("{}").unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queued = scope
|
||||
.spawn(move |child| async move { call(&child, bundle, "echo").await })
|
||||
.unwrap();
|
||||
let stopped =
|
||||
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
|
||||
assert!(matches!(stopped, Ok(Ok(Timed::Expired(None)))));
|
||||
assert_eq!(first, pid(held.resolve(Ok).await.unwrap()));
|
||||
|
||||
let held = worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"echo",
|
||||
Json::parse("{}").unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queued = scope
|
||||
.spawn(move |child| async move { call(&child, bundle, "echo").await })
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let failed = held
|
||||
.resolve::<()>(|_| Err("invalid typed payload".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(failed.contains("invalid typed payload"));
|
||||
assert!(!PathBuf::from(format!("/proc/{first}")).exists());
|
||||
assert!(
|
||||
queued
|
||||
.wait()
|
||||
.await
|
||||
.unwrap()
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("queued")
|
||||
);
|
||||
let second = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert_ne!(first, second);
|
||||
let held = worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"echo",
|
||||
Json::parse("{}").unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queued = scope
|
||||
.spawn(move |child| async move { call(&child, bundle, "echo").await })
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
drop(held);
|
||||
let failed = tokio::time::timeout(Duration::from_secs(3), queued.wait())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(failed.contains("queued"));
|
||||
assert!(!PathBuf::from(format!("/proc/{second}")).exists());
|
||||
let third = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert_ne!(second, third);
|
||||
let panicked = scope
|
||||
.spawn(move |child| async move {
|
||||
worker::call(
|
||||
&child,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"echo",
|
||||
Json::parse("{}").unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.resolve::<()>(|_| panic!("typed decoder panicked"))
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(3), panicked.wait()).await,
|
||||
Ok(Err(dever_runtime::task::Error::Panicked))
|
||||
));
|
||||
assert!(!PathBuf::from(format!("/proc/{third}")).exists());
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Config {
|
||||
#[serde(rename = "token")]
|
||||
_token: dever_runtime::value::secret::Secret,
|
||||
}
|
||||
let document = dever_runtime::adapter::Document::parse(
|
||||
r#"{"adapter":{"demo.test":{"setting":{"token":"sensitive\"\\雪"}}}}"#.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let schemas = [dever_runtime::adapter::Schema {
|
||||
name: "demo.test",
|
||||
implementations: &["default"],
|
||||
}];
|
||||
let selected = document.select(&schemas).unwrap();
|
||||
let setting = selected[0].worker_setting::<Config>().unwrap();
|
||||
let input = Json::parse("{}").unwrap();
|
||||
let process = pid(worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&setting,
|
||||
"echo",
|
||||
input.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.resolve(Ok)
|
||||
.await
|
||||
.unwrap());
|
||||
let error = worker::call(scope, bundle, ENTRY, CONTRACT, &setting, "bad", input)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(!error.contains("sensitive"));
|
||||
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, receive) = std::sync::mpsc::channel();
|
||||
let blocking = scope
|
||||
.spawn_sync(move || {
|
||||
started.send(()).unwrap();
|
||||
receive.recv().unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
let queued = scope
|
||||
.spawn(move |child| async move { call(&child, bundle, "echo").await })
|
||||
.unwrap();
|
||||
let cancelled =
|
||||
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
|
||||
release.send(()).unwrap();
|
||||
blocking.wait().await.unwrap();
|
||||
assert!(matches!(cancelled, Ok(Ok(Timed::Expired(None)))));
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let process = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
let hanging = scope
|
||||
.spawn(move |child| async move { call(&child, bundle, "ignore").await })
|
||||
.unwrap();
|
||||
let result = tokio::time::timeout(Duration::from_secs(3), hanging.timeout(50))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(result, Timed::Expired(None)));
|
||||
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let process = pid(call(scope, bundle, "shutdown_bad").await.unwrap());
|
||||
let error = worker::finish::<()>(scope, Err("primary failure".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("primary failure") && error.contains("shutdown"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 真实受管 Worker 与隔离资源目录;不访问已安装服务或生产缓存。
|
||||
|
||||
use dever_runtime::{
|
||||
task::{Limits, Runtime},
|
||||
wire::json::Json,
|
||||
worker::{self, Asset, Bundle, Reply, Setting},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::{DirBuilderExt, PermissionsExt},
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const ENTRY: &str = "module/demo/test/worker";
|
||||
static EXECUTOR: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
mod command;
|
||||
mod lifecycle;
|
||||
mod performance;
|
||||
const CONTRACT: &str = r#"{"port":"demo.test","adapter":"default","ecosystem":"exec","entry":"module/demo/test/worker","schema":"owned-fixture","capabilities":[],"operations":["bad","crash","echo","error","extra_field","fragment","hang","ignore","measure","missing_error","null_error","shutdown_bad"],"inputs":{},"outputs":{},"errors":{"demo.test.Error.Failed":{}},"setting":null}"#;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-worker-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle(bytes: Vec<u8>) -> &'static Bundle {
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-launch-v1","ecosystem":"exec","entry":ENTRY,"executable":ENTRY,"arguments":[],"working_directory":"module/demo/test"})).unwrap();
|
||||
let files = [
|
||||
(ENTRY, bytes, true),
|
||||
("module/demo/test/worker.dever-worker.json", launch, false),
|
||||
]
|
||||
.map(|(path, bytes, executable)| Asset {
|
||||
path,
|
||||
sha256: Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()),
|
||||
bytes: Box::leak(bytes.into_boxed_slice()),
|
||||
executable,
|
||||
});
|
||||
Box::leak(Box::new(Bundle {
|
||||
files: Box::leak(Box::new(files)),
|
||||
}))
|
||||
}
|
||||
|
||||
fn pid(reply: Reply) -> u32 {
|
||||
let Reply::Result(value) = reply else {
|
||||
panic!("expected result")
|
||||
};
|
||||
let value: serde_json::Value = serde_json::from_str(&value).unwrap();
|
||||
assert_eq!(value["value"], 42);
|
||||
value["pid"].as_u64().unwrap() as u32
|
||||
}
|
||||
|
||||
async fn call(
|
||||
scope: &dever_runtime::task::Scope<'_>,
|
||||
bundle: &'static Bundle,
|
||||
operation: &str,
|
||||
) -> Result<Reply, String> {
|
||||
worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
operation,
|
||||
Json::parse("{}").unwrap(),
|
||||
)
|
||||
.await?
|
||||
.resolve(Ok)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_worker_runs_from_a_relocated_owned_executable() {
|
||||
let _execution = EXECUTOR.lock().unwrap();
|
||||
let directory = Directory::new();
|
||||
let fixture = directory.0.join("fixture");
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../test/rust/runtime/worker/fixture.rs");
|
||||
let result = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let runner = directory.0.join("runner");
|
||||
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
|
||||
let result = Command::new(runner)
|
||||
.args(["--exact", "owned_process_protocol_case", "--nocapture"])
|
||||
.env_clear()
|
||||
.env("DEVER_WORKER_FIXTURE", &fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_process_protocol_case() {
|
||||
let Some(fixture) = std::env::var_os("DEVER_WORKER_FIXTURE") else {
|
||||
return;
|
||||
};
|
||||
let bundle = bundle(fs::read(&fixture).unwrap());
|
||||
fs::remove_file(fixture).unwrap();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
let process = runtime
|
||||
.run(async |scope| {
|
||||
let first = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert_eq!(first, pid(call(scope, bundle, "echo").await.unwrap()));
|
||||
for source in [
|
||||
"null",
|
||||
" \n{\"text\":\"雪\\u96ea\\n🙂\",\"value\":1e+02} \t",
|
||||
] {
|
||||
let response = worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"measure",
|
||||
Json::parse(source).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.resolve(Ok)
|
||||
.await
|
||||
.unwrap();
|
||||
let Reply::Result(payload) = response else {
|
||||
panic!("expected measured result")
|
||||
};
|
||||
let measured: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
||||
let expected = source.trim_ascii();
|
||||
assert_eq!(measured["length"], expected.len());
|
||||
assert_eq!(
|
||||
measured["checksum"],
|
||||
expected.bytes().map(u64::from).sum::<u64>()
|
||||
);
|
||||
}
|
||||
let Reply::Error { identity, payload } = call(scope, bundle, "error").await.unwrap()
|
||||
else {
|
||||
panic!("business error")
|
||||
};
|
||||
assert_eq!(identity, "demo.test.Error.Failed");
|
||||
assert_eq!(payload, "{\"message\":\"failed\"}");
|
||||
for operation in ["hang", "fragment"] {
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30), call(scope, bundle, operation))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(first, pid(call(scope, bundle, "echo").await.unwrap()));
|
||||
}
|
||||
assert!(call(scope, bundle, "crash").await.is_err());
|
||||
let second = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert_ne!(first, second);
|
||||
assert!(call(scope, bundle, "bad").await.is_err());
|
||||
let third = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert_ne!(second, third);
|
||||
worker::finish(scope, Ok(third)).await.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
|
||||
for operation in ["null_error", "missing_error", "extra_field"] {
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let process = pid(call(scope, bundle, "echo").await.unwrap());
|
||||
assert!(call(scope, bundle, operation).await.is_err());
|
||||
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_publication_reuses_only_exact_owned_bytes_and_modes() {
|
||||
let directory = Directory::new();
|
||||
let resources = bundle(b"not executed".to_vec());
|
||||
fs::create_dir(directory.0.join("data")).unwrap();
|
||||
fs::set_permissions(directory.0.join("data"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let root = resources.prepare_in(&directory.0).unwrap();
|
||||
assert_eq!(root, resources.prepare_in(&directory.0).unwrap());
|
||||
fs::write(root.join("unlocked.py"), b"not part of bundle").unwrap();
|
||||
assert!(
|
||||
resources
|
||||
.prepare_in(&directory.0)
|
||||
.unwrap_err()
|
||||
.contains("unexpected")
|
||||
);
|
||||
fs::remove_file(root.join("unlocked.py")).unwrap();
|
||||
fs::set_permissions(root.join(ENTRY), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(
|
||||
resources
|
||||
.prepare_in(&directory.0)
|
||||
.unwrap_err()
|
||||
.contains("mode")
|
||||
);
|
||||
fs::write(root.join(ENTRY), b"bad resource").unwrap();
|
||||
fs::set_permissions(root.join(ENTRY), fs::Permissions::from_mode(0o500)).unwrap();
|
||||
assert!(resources.prepare_in(&directory.0).is_err());
|
||||
let duplicate = Bundle {
|
||||
files: Box::leak(
|
||||
vec![Asset {
|
||||
path: "../escape",
|
||||
bytes: b"",
|
||||
sha256: "",
|
||||
executable: false,
|
||||
}]
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
};
|
||||
assert!(
|
||||
duplicate
|
||||
.prepare_in(&directory.0)
|
||||
.unwrap_err()
|
||||
.contains("path")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 真实复用 Worker 的逐调用成本;输入编码、进程启动和结果核验不计入样本。
|
||||
|
||||
use super::{CONTRACT, ENTRY, EXECUTOR, bundle};
|
||||
use dever_runtime::{
|
||||
task::{Limits, Runtime, Scope},
|
||||
wire::json::Json,
|
||||
worker::{self, Bundle, Reply, Setting},
|
||||
};
|
||||
use std::{fs, path::PathBuf, time::Instant};
|
||||
mod native;
|
||||
|
||||
async fn request(scope: &Scope<'_>, bundle: &'static Bundle, input: Json) -> (usize, u64) {
|
||||
let response = worker::call(
|
||||
scope,
|
||||
bundle,
|
||||
ENTRY,
|
||||
CONTRACT,
|
||||
&Setting::empty(),
|
||||
"measure",
|
||||
input,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.resolve(Ok)
|
||||
.await
|
||||
.unwrap();
|
||||
let Reply::Result(payload) = response else {
|
||||
panic!("expected measured result")
|
||||
};
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Output {
|
||||
length: usize,
|
||||
checksum: u64,
|
||||
}
|
||||
let output: Output = serde_json::from_str(&payload).unwrap();
|
||||
(output.length, output.checksum)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn worker_input_cost() {
|
||||
let _execution = EXECUTOR.lock().unwrap();
|
||||
let fixture = PathBuf::from(
|
||||
std::env::var_os("DEVER_WORKER_BENCH_FIXTURE")
|
||||
.expect("DEVER_WORKER_BENCH_FIXTURE must name the precompiled Worker fixture"),
|
||||
);
|
||||
let bundle = bundle(fs::read(&fixture).unwrap());
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let mut native = native::Native::start(&fixture, CONTRACT).await.unwrap();
|
||||
eprintln!(
|
||||
"worker_input: 15 fixed rounds; one async worker; owned inputs prepared before timing; \
|
||||
same executable, payload, frame protocol, deadlines and reply checks; \
|
||||
rust_independent uses Tokio directly without Dever Worker; \
|
||||
it excludes shared-session admission, caller-cancellation handshake and Scope cleanup; \
|
||||
no compiler runs in this probe"
|
||||
);
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for text in ["null", " \n{\"text\":\"雪\\u96ea\\n🙂\",\"value\":1e+02} \t"] {
|
||||
let trimmed = text.trim_ascii();
|
||||
let expected = (trimmed.len(), trimmed.bytes().map(u64::from).sum::<u64>());
|
||||
assert_eq!(
|
||||
request(scope, bundle, Json::parse(text).unwrap()).await,
|
||||
expected
|
||||
);
|
||||
assert_eq!(native.request(trimmed).await.unwrap(), expected);
|
||||
}
|
||||
for size in [64, 65_536, 1_048_576] {
|
||||
let iterations = match size {
|
||||
64 => 4096,
|
||||
65_536 => 512,
|
||||
_ => 64,
|
||||
};
|
||||
let input = Json::encode(&serde_json::json!({"text": "a".repeat(size)})).unwrap();
|
||||
let expected = (
|
||||
input.as_str().len(),
|
||||
input.as_str().bytes().map(u64::from).sum::<u64>(),
|
||||
);
|
||||
for _ in 0..16 {
|
||||
assert_eq!(request(scope, bundle, input.clone()).await, expected);
|
||||
assert_eq!(native.request(input.as_str()).await.unwrap(), expected);
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let mut order = ["dever", "rust_independent", "control"];
|
||||
order.rotate_left(sample % 3);
|
||||
for label in order {
|
||||
let mut results = Vec::with_capacity(iterations);
|
||||
let elapsed = if label == "rust_independent" {
|
||||
let inputs = (0..iterations)
|
||||
.map(|_| input.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let started = Instant::now();
|
||||
for payload in inputs {
|
||||
results.push(native.request(&payload).await.unwrap());
|
||||
}
|
||||
started.elapsed().as_nanos()
|
||||
} else {
|
||||
let inputs = vec![input.clone(); iterations];
|
||||
let started = Instant::now();
|
||||
for payload in inputs {
|
||||
results.push(request(scope, bundle, payload).await);
|
||||
}
|
||||
started.elapsed().as_nanos()
|
||||
};
|
||||
assert!(results.iter().all(|&result| result == expected));
|
||||
let checksum = expected.1 * results.len() as u64;
|
||||
println!("worker_input_{size},{sample},{label},{iterations},{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
worker::finish(scope, Ok(())).await.unwrap();
|
||||
native.finish().await.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn validated_reply_decode_cost() {
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Output {
|
||||
text: String,
|
||||
values: Vec<i64>,
|
||||
}
|
||||
for size in [64, 4096, 65_536] {
|
||||
let payload =
|
||||
serde_json::json!({"text": "a".repeat(size), "values": [1, 2, 3]}).to_string();
|
||||
// Channel::read 的完整帧验证已完成;只计量其后的载荷解码,不代表 IPC 往返。
|
||||
let input = Json::parse(&payload).unwrap();
|
||||
let expected = dever_runtime::wire::decode::<Output>(input.as_str()).unwrap();
|
||||
assert_eq!(
|
||||
expected,
|
||||
serde_json::from_str::<Output>(input.as_str()).unwrap()
|
||||
);
|
||||
for sample in 0..15 {
|
||||
for before in if sample % 2 == 0 {
|
||||
[true, false]
|
||||
} else {
|
||||
[false, true]
|
||||
} {
|
||||
let mut checksum = 0;
|
||||
let started = Instant::now();
|
||||
for _ in 0..2000 {
|
||||
let text = std::hint::black_box(input.as_str());
|
||||
let value = if before {
|
||||
dever_runtime::wire::decode::<Output>(text).unwrap()
|
||||
} else {
|
||||
serde_json::from_str::<Output>(text).unwrap()
|
||||
};
|
||||
checksum += std::hint::black_box(&value).text.len() + value.values.len();
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(checksum, 2000 * (size + 3));
|
||||
let label = if before { "before" } else { "after" };
|
||||
println!("worker_reply_{size},{sample},{label},{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user