#![allow(clippy::unwrap_used, clippy::panic, clippy::print_stderr)]
use std::{
sync::Arc,
time::{Duration, Instant},
};
use fraiseql_cli::schema::{ConvertOptions, SchemaConverter, intermediate::IntermediateSchema};
use fraiseql_core::{db::postgres::PostgresAdapter, schema::CompiledSchema};
use fraiseql_server::{
Server,
server_config::{CacheCrossInstance, ServerConfig},
};
use fraiseql_test_support::wire::CountingProxy;
use serde_json::{Value, json};
const SCHEMA: &str = "p1577";
const USER: &str = "00000000-0000-0000-0000-000000001577";
const DELIVERY: Duration = Duration::from_secs(2);
async fn database_url() -> String {
fraiseql_test_support::postgres()
.await
.expect("DATABASE_URL must be set (or enable fraiseql-test-support/local-testcontainers)")
.url()
.to_string()
}
async fn client(url: &str) -> tokio_postgres::Client {
let (client, connection) = tokio_postgres::connect(url, tokio_postgres::NoTls).await.unwrap();
tokio::spawn(connection);
client
}
async fn provision(url: &str) {
let client = client(url).await;
client
.batch_execute(include_str!("../../fraiseql-cli/sql/helpers/mutation_response.sql"))
.await
.unwrap();
client
.batch_execute(&format!(
"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE;
CREATE SCHEMA {SCHEMA};
CREATE TYPE {SCHEMA}.mutation_response AS (succeeded boolean,
state_changed boolean, error_class text, status_detail text,
http_status smallint, message text, entity_id uuid, entity_type text,
entity jsonb, updated_fields text[], cascade jsonb, error_detail jsonb,
metadata jsonb);
CREATE TABLE {SCHEMA}.tb_user (pk_user bigint GENERATED ALWAYS AS IDENTITY
PRIMARY KEY, id uuid NOT NULL UNIQUE, bio text NOT NULL);
INSERT INTO {SCHEMA}.tb_user (id, bio) VALUES ('{USER}', 'v0');
CREATE VIEW {SCHEMA}.v_user AS SELECT u.id,
jsonb_build_object('id', u.id, 'bio', u.bio) AS data FROM {SCHEMA}.tb_user u;
CREATE FUNCTION {SCHEMA}.fn_update_user(p_id uuid, p_bio text)
RETURNS SETOF {SCHEMA}.mutation_response LANGUAGE plpgsql AS $$
BEGIN
UPDATE {SCHEMA}.tb_user SET bio = p_bio WHERE id = p_id;
RETURN QUERY SELECT * FROM fraiseql.mutation_ok(
(SELECT data FROM {SCHEMA}.v_user WHERE id = p_id), p_id, 'User');
END $$;"
))
.await
.unwrap();
}
fn schema() -> CompiledSchema {
let field = |name: &str, ty: &str| json!({ "name": name, "type": ty, "nullable": false });
let intermediate: IntermediateSchema = serde_json::from_value(json!({
"types": [{ "name": "User", "sql_source": format!("{SCHEMA}.v_user"), "is_input": false,
"fields": [field("id", "ID"), field("bio", "String")] }],
"queries": [{ "name": "users", "return_type": "User", "returns_list": true,
"nullable": false, "sql_source": format!("{SCHEMA}.v_user"),
"arguments": [], "cache_ttl_seconds": 0 }],
"mutations": [{ "name": "updateUser", "return_type": "User", "operation": "update",
"sql_source": format!("{SCHEMA}.fn_update_user"),
"arguments": [field("id", "ID"), field("bio", "String")] }],
}))
.unwrap();
let mut schema = SchemaConverter::convert_artifact(intermediate, &ConvertOptions::default())
.unwrap()
.schema;
schema.build_indexes();
schema
}
struct Running {
base: String,
_shutdown: tokio::sync::oneshot::Sender<()>,
}
async fn serve(url: &str, feed_url: &str, cross_instance: CacheCrossInstance) -> Running {
let adapter = Arc::new(PostgresAdapter::new(url).await.unwrap());
let config = ServerConfig {
database_url: feed_url.to_string(),
cache_cross_instance: cross_instance,
cors_enabled: false,
..ServerConfig::default()
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = Box::pin(Server::new(config, schema(), adapter, None)).await.unwrap();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
server
.serve_on_listener(listener, async {
let _ = rx.await;
})
.await
});
Running {
base: format!("http://127.0.0.1:{port}"),
_shutdown: tx,
}
}
fn named(url: &str, name: &str) -> String {
format!("{url}{}application_name={name}", if url.contains('?') { "&" } else { "?" })
}
async fn graphql(server: &Running, query: &str) -> Value {
let body: Value = reqwest::Client::new()
.post(format!("{}/graphql", server.base))
.json(&json!({ "query": query }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(body.get("errors").is_none(), "{query}: {body}");
body
}
async fn bio(server: &Running) -> String {
let body = graphql(server, "{ users { bio } }").await;
body["data"]["users"][0]["bio"]
.as_str()
.unwrap_or_else(|| panic!("{body}"))
.to_string()
}
async fn update_user(server: &Running, bio: &str) {
graphql(
server,
&format!(r#"mutation {{ updateUser(id: "{USER}", bio: "{bio}") {{ id }} }}"#),
)
.await;
}
async fn behind_back(url: &str, bio: &str) {
client(url)
.await
.execute(&format!("UPDATE {SCHEMA}.tb_user SET bio = $1"), &[&bio])
.await
.unwrap();
}
async fn serves_from_cache(server: &Running, url: &str, probe: &str) -> bool {
let before = bio(server).await;
behind_back(url, probe).await;
bio(server).await == before
}
async fn until_caching(server: &Running, url: &str) {
let start = Instant::now();
for i in 0.. {
if serves_from_cache(server, url, &format!("probe {i}")).await {
return;
}
assert!(start.elapsed() < Duration::from_secs(10), "the server never cached `users`");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn until_served(server: &Running, expected: &str) -> Duration {
let start = Instant::now();
loop {
let served = bio(server).await;
if served == expected {
return start.elapsed();
}
assert!(
start.elapsed() < DELIVERY,
"still serving {served:?} {DELIVERY:?} after another server wrote {expected:?}"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
async fn backend(url: &str, name: &str, other_than: Option<i32>) -> i32 {
let client = client(url).await;
let start = Instant::now();
loop {
let row = client
.query_opt(
"SELECT pid FROM pg_stat_activity WHERE application_name = $1 AND pid <> $2",
&[&name, &other_than.unwrap_or(0)],
)
.await
.unwrap();
if let Some(row) = row {
return row.get(0);
}
assert!(start.elapsed() < Duration::from_secs(10), "no backend named {name}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn a_write_through_one_server_evicts_the_entry_on_another() {
let url = database_url().await;
provision(&url).await;
let a = serve(&url, &named(&url, "p1577a"), CacheCrossInstance::Notify).await;
let b = serve(&url, &named(&url, "p1577b"), CacheCrossInstance::Notify).await;
until_caching(&b, &url).await;
update_user(&a, "written through a").await;
let took = until_served(&b, "written through a").await;
eprintln!("b served a's write {took:?} after it committed");
}
#[tokio::test]
async fn without_the_feed_a_write_through_one_server_leaves_another_stale() {
let url = database_url().await;
provision(&url).await;
let a = serve(&url, &url, CacheCrossInstance::Off).await;
let b = serve(&url, &url, CacheCrossInstance::Off).await;
until_caching(&b, &url).await;
let cached = bio(&b).await;
update_user(&a, "written through a").await;
tokio::time::sleep(DELIVERY).await;
assert_eq!(bio(&b).await, cached, "b has no way to hear a's write");
assert_eq!(bio(&a).await, "written through a", "a evicted its own entry");
}
#[tokio::test]
async fn a_server_that_cannot_reach_its_feed_caches_nothing() {
let url = database_url().await;
provision(&url).await;
let proxy = CountingProxy::start(&url);
proxy.sever();
let b = serve(&url, proxy.url(), CacheCrossInstance::Notify).await;
for i in 0..10 {
assert!(
!serves_from_cache(&b, &url, &format!("probe {i}")).await,
"probe {i}: b cached without hearing the other servers"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test]
async fn a_server_whose_feed_receives_nothing_caches_nothing() {
let url = database_url().await;
provision(&url).await;
let proxy = CountingProxy::start(&url);
proxy.drop_notifications(true);
let b = serve(&url, proxy.url(), CacheCrossInstance::Notify).await;
for i in 0..10 {
assert!(
!serves_from_cache(&b, &url, &format!("probe {i}")).await,
"probe {i}: b cached though its feed receives nothing"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test]
async fn a_server_whose_feed_is_down_serves_no_stale_entry_and_recovers() {
let url = database_url().await;
provision(&url).await;
let proxy = CountingProxy::start(&url);
let a = serve(&url, &url, CacheCrossInstance::Notify).await;
let b = serve(&url, proxy.url(), CacheCrossInstance::Notify).await;
until_caching(&b, &url).await;
proxy.sever();
tokio::time::sleep(Duration::from_millis(300)).await;
update_user(&a, "written while b is deaf").await;
assert_eq!(
bio(&b).await,
"written while b is deaf",
"b served an entry it could not know was stale"
);
proxy.restore();
until_caching(&b, &url).await;
}
#[tokio::test]
async fn a_server_reads_fresh_after_its_feed_backend_is_terminated() {
let url = database_url().await;
provision(&url).await;
let a = serve(&url, &url, CacheCrossInstance::Notify).await;
let b = serve(&url, &named(&url, "p1577b"), CacheCrossInstance::Notify).await;
until_caching(&b, &url).await;
let listener = backend(&url, "p1577b", None).await;
client(&url)
.await
.execute("SELECT pg_terminate_backend($1)", &[&listener])
.await
.unwrap();
update_user(&a, "written while b reconnects").await;
backend(&url, "p1577b", Some(listener)).await;
until_served(&b, "written while b reconnects").await;
}