mirror of
https://github.com/modrinth/code.git
synced 2026-08-24 16:44:51 +00:00
* chore(labrinth): bump to redis 1.4.1 * feat(labrinth): redis cluster * chore: cleanup * feat(labrinth): cache locking * fix(labrinth): clippy * chore(labrinth): cleanup env, remove postcard support * chore(ci): fix test env for labrinth * chore(labrinth): bump all key versions * chore(labrinth): improve redis key identities handling * chore(labrinth): simplify deadline handling * chore(labrinth): remove unused lease tracking * chore(labrinth): remove distributed cache locking for now * chore(labrinth): improve redis backend init error * feat(labrinth): expose redis read replica strategy * chore(ci): remove other connection mode tests * chore: split xredis crate * feat(xredis): primaries routing * chore: tombi fmt * chore: clippy * chore: update query cache
859 lines
30 KiB
Rust
859 lines
30 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use actix_http::StatusCode;
|
|
use actix_web::test;
|
|
use ariadne::ids::base62_impl::parse_base62;
|
|
use common::api_common::models::{CommonProject, CommonVersion};
|
|
use common::api_common::{ApiProject, ApiVersion};
|
|
use common::database::{ENEMY_USER_PAT, USER_USER_PAT};
|
|
use common::environment::{TestEnvironment, with_test_environment};
|
|
use dashmap::DashMap;
|
|
use labrinth::database::models::DatabaseError;
|
|
use labrinth::database::models::project_item::{
|
|
PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE,
|
|
};
|
|
use labrinth::database::models::version_item::VERSIONS_NAMESPACE;
|
|
use redis::cluster_routing::Slot;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use tokio::sync::{Barrier, Notify};
|
|
use tokio::time::timeout;
|
|
use uuid::Uuid;
|
|
use xredis::{KeyBuilder, RedisPool, RedisTopology};
|
|
|
|
pub mod common;
|
|
|
|
async fn isolated_redis_pool(label: &str) -> RedisPool {
|
|
labrinth::env::init().expect("failed to initialize test environment");
|
|
labrinth::database::redis::from_env(format!(
|
|
"redis_test_{label}_{}",
|
|
Uuid::new_v4()
|
|
))
|
|
.await
|
|
}
|
|
|
|
fn clustered_key_builder(label: &str) -> KeyBuilder {
|
|
KeyBuilder::new(
|
|
format!("redis_test_{label}_{}", Uuid::new_v4()),
|
|
RedisTopology::Cluster,
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
|
struct CachedNumber {
|
|
value: usize,
|
|
}
|
|
|
|
/// Protects redis-rs's cross-slot result recombination and Labrinth's 32-key
|
|
/// chunk boundary, including missing and duplicate keys whose positions must
|
|
/// not shift.
|
|
#[actix_rt::test]
|
|
async fn cross_slot_mget_preserves_input_order_across_chunks() {
|
|
let pool = isolated_redis_pool("cross_slot_mget").await;
|
|
let keys = clustered_key_builder("cross_slot_mget");
|
|
let mut connection = pool.connect().await.unwrap();
|
|
|
|
let stored_keys = (0..65)
|
|
.map(|index| keys.entity("raw", format!("key-{index}")))
|
|
.collect::<Vec<_>>();
|
|
let slots = stored_keys
|
|
.iter()
|
|
.map(Slot::for_key)
|
|
.collect::<HashSet<_>>();
|
|
assert!(slots.len() > 1, "test keys must span Redis hash slots");
|
|
|
|
let mut expected_values = HashMap::new();
|
|
for (index, key) in stored_keys.iter().enumerate() {
|
|
let value = format!("value-{index}").into_bytes();
|
|
connection.set(key, &value, None).await.unwrap();
|
|
expected_values.insert(key.clone(), value);
|
|
}
|
|
|
|
let mut query_keys = stored_keys.iter().rev().cloned().collect::<Vec<_>>();
|
|
query_keys.insert(1, keys.entity("raw", "missing-first-chunk"));
|
|
query_keys.insert(33, keys.entity("raw", "missing-second-chunk"));
|
|
query_keys.push(stored_keys[7].clone());
|
|
|
|
let actual = connection.get_many(&query_keys).await.unwrap();
|
|
let expected = query_keys
|
|
.iter()
|
|
.map(|key| expected_values.get(key).cloned())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(actual, expected);
|
|
|
|
// The typed path is used by analytics counters, so it must receive the
|
|
// same cross-slot splitting and ordered recombination as cache reads.
|
|
let counter_keys = (0..3)
|
|
.map(|index| keys.entity("counter", format!("key-{index}")))
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
counter_keys
|
|
.iter()
|
|
.map(Slot::for_key)
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
> 1
|
|
);
|
|
for (index, key) in counter_keys.iter().enumerate() {
|
|
connection.set(key, index as u32, None).await.unwrap();
|
|
}
|
|
assert_eq!(
|
|
connection
|
|
.get_many_typed::<u32>(&counter_keys)
|
|
.await
|
|
.unwrap(),
|
|
vec![Some(0), Some(1), Some(2)]
|
|
);
|
|
}
|
|
|
|
/// Protects the serialized cache path from losing ordering or cardinality when
|
|
/// a many-get crosses both hash slots and Labrinth's command chunk boundary.
|
|
#[actix_rt::test]
|
|
async fn cross_slot_deserialized_mget_preserves_input_order() {
|
|
let pool = isolated_redis_pool("cross_slot_deserialized").await;
|
|
let keys = clustered_key_builder("cross_slot_deserialized");
|
|
let mut connection = pool.connect().await.unwrap();
|
|
|
|
let stored_keys = (0..65)
|
|
.map(|index| keys.entity("serialized", format!("key-{index}")))
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
stored_keys
|
|
.iter()
|
|
.map(Slot::for_key)
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
> 1
|
|
);
|
|
for (index, key) in stored_keys.iter().enumerate() {
|
|
connection
|
|
.set_serialized(key, CachedNumber { value: index }, None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let mut query_keys = stored_keys.iter().rev().cloned().collect::<Vec<_>>();
|
|
query_keys.insert(32, keys.entity("serialized", "missing"));
|
|
query_keys.push(stored_keys[12].clone());
|
|
|
|
let actual = connection
|
|
.get_many_deserialized::<CachedNumber>(&query_keys)
|
|
.await
|
|
.unwrap();
|
|
let expected = query_keys
|
|
.iter()
|
|
.map(|key| {
|
|
stored_keys
|
|
.iter()
|
|
.position(|stored| stored == key)
|
|
.map(|value| CachedNumber { value })
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(actual, expected);
|
|
}
|
|
|
|
/// Protects cross-slot invalidation, same-slot hash-tag behavior, and empty
|
|
/// batches; these are the three shapes used by model cache clear operations.
|
|
#[actix_rt::test]
|
|
async fn delete_many_and_same_slot_batches_work_in_cluster_mode() {
|
|
let pool = isolated_redis_pool("delete_many").await;
|
|
let keys = clustered_key_builder("delete_many");
|
|
let mut connection = pool.connect().await.unwrap();
|
|
|
|
let cross_slot_keys = (0..5)
|
|
.map(|index| keys.entity("delete", format!("key-{index}")))
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
cross_slot_keys
|
|
.iter()
|
|
.map(Slot::for_key)
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
> 1
|
|
);
|
|
for key in &cross_slot_keys {
|
|
connection.set(key, "present", None).await.unwrap();
|
|
}
|
|
connection.delete_many(&cross_slot_keys).await.unwrap();
|
|
assert_eq!(
|
|
connection.get_many(&cross_slot_keys).await.unwrap(),
|
|
vec![None; cross_slot_keys.len()]
|
|
);
|
|
|
|
let same_slot_keys = vec![
|
|
keys.with_slot("same_slot", "left", "shared-entity"),
|
|
keys.with_slot("same_slot", "right", "shared-entity"),
|
|
];
|
|
assert_eq!(
|
|
Slot::for_key(&same_slot_keys[0]),
|
|
Slot::for_key(&same_slot_keys[1])
|
|
);
|
|
connection
|
|
.set(&same_slot_keys[0], "left", None)
|
|
.await
|
|
.unwrap();
|
|
connection
|
|
.set(&same_slot_keys[1], "right", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
connection.get_many(&same_slot_keys).await.unwrap(),
|
|
vec![Some(b"left".to_vec()), Some(b"right".to_vec())]
|
|
);
|
|
|
|
connection.delete_many(&[]).await.unwrap();
|
|
assert!(connection.get_many(&[]).await.unwrap().is_empty());
|
|
}
|
|
|
|
/// Protects hash tags from user-controlled braces, percent signs, and empty
|
|
/// values changing the substring Redis uses to select a cluster slot.
|
|
#[actix_rt::test]
|
|
async fn cluster_key_builder_escapes_slot_tags() {
|
|
let keys = clustered_key_builder("slot_escaping");
|
|
|
|
for (slot_tag, escaped) in [
|
|
("", "%00"),
|
|
("contains%percent", "contains%25percent"),
|
|
("contains{open", "contains%7Bopen"),
|
|
("contains}close", "contains%7Dclose"),
|
|
] {
|
|
let first = keys.with_slot("escape", "first", slot_tag);
|
|
let second = keys.with_slot("escape", "second", slot_tag);
|
|
assert!(first.contains(&format!("{{{escaped}}}")));
|
|
assert_eq!(Slot::for_key(first), Slot::for_key(second));
|
|
}
|
|
|
|
// Metadata keys intentionally share one slot so metadata pipelines remain
|
|
// legal even when their logical entity IDs differ.
|
|
assert_eq!(
|
|
Slot::for_key(keys.metadata("metadata", "first")),
|
|
Slot::for_key(keys.metadata("metadata", "second"))
|
|
);
|
|
}
|
|
|
|
/// Protects the process-local single-flight contract: concurrent cache misses
|
|
/// for one key perform one backing fetch and all waiters receive its value.
|
|
#[actix_rt::test]
|
|
async fn cache_lock_coalesces_concurrent_misses_for_one_key() {
|
|
let pool = isolated_redis_pool("single_flight").await;
|
|
let barrier = Arc::new(Barrier::new(16));
|
|
let fetch_count = Arc::new(AtomicUsize::new(0));
|
|
let mut tasks = Vec::new();
|
|
|
|
for _ in 0..16 {
|
|
let pool = pool.clone();
|
|
let barrier = barrier.clone();
|
|
let fetch_count = fetch_count.clone();
|
|
tasks.push(tokio::spawn(async move {
|
|
barrier.wait().await;
|
|
pool.get_cached_keys_raw(
|
|
"single_flight:v3",
|
|
&["shared".to_string()],
|
|
move |keys| async move {
|
|
fetch_count.fetch_add(1, Ordering::SeqCst);
|
|
tokio::time::sleep(Duration::from_millis(75)).await;
|
|
let values = DashMap::new();
|
|
for key in keys {
|
|
values.insert(key.clone(), format!("value-{key}"));
|
|
}
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
)
|
|
.await
|
|
}));
|
|
}
|
|
|
|
for task in tasks {
|
|
let values = task.await.unwrap().unwrap();
|
|
assert_eq!(values.get("shared"), Some(&"value-shared".to_string()));
|
|
}
|
|
assert_eq!(fetch_count.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
/// Protects per-key locking for overlapping many-gets: `[A, B]` and `[B, C]`
|
|
/// may fetch independently, but the shared `B` must only be loaded once.
|
|
#[actix_rt::test]
|
|
async fn cache_lock_coalesces_only_overlapping_keys() {
|
|
let pool = isolated_redis_pool("overlapping_locks").await;
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
let fetch_counts = Arc::new(DashMap::<String, usize>::new());
|
|
let mut tasks = Vec::new();
|
|
|
|
for requested in [
|
|
vec!["A".to_string(), "B".to_string()],
|
|
vec!["B".to_string(), "C".to_string()],
|
|
] {
|
|
let pool = pool.clone();
|
|
let barrier = barrier.clone();
|
|
let fetch_counts = fetch_counts.clone();
|
|
tasks.push(tokio::spawn(async move {
|
|
barrier.wait().await;
|
|
pool.get_cached_keys_raw(
|
|
"overlapping_locks:v3",
|
|
&requested,
|
|
move |keys| async move {
|
|
tokio::time::sleep(Duration::from_millis(75)).await;
|
|
let values = DashMap::new();
|
|
for key in keys {
|
|
fetch_counts
|
|
.entry(key.clone())
|
|
.and_modify(|count| *count += 1)
|
|
.or_insert(1);
|
|
values.insert(key.clone(), format!("value-{key}"));
|
|
}
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
)
|
|
.await
|
|
}));
|
|
}
|
|
|
|
for task in tasks {
|
|
assert_eq!(task.await.unwrap().unwrap().len(), 2);
|
|
}
|
|
for key in ["A", "B", "C"] {
|
|
assert_eq!(fetch_counts.get(key).map(|count| *count), Some(1));
|
|
}
|
|
}
|
|
|
|
/// Protects the lock map from becoming a global mutex: a slow miss for one
|
|
/// key must not delay an unrelated cache key.
|
|
#[actix_rt::test]
|
|
async fn cache_lock_does_not_block_independent_keys() {
|
|
let pool = isolated_redis_pool("independent_locks").await;
|
|
let started = Arc::new(Notify::new());
|
|
let release = Arc::new(Notify::new());
|
|
|
|
let slow_pool = pool.clone();
|
|
let slow_started = started.clone();
|
|
let slow_release = release.clone();
|
|
let slow = tokio::spawn(async move {
|
|
slow_pool
|
|
.get_cached_keys_raw(
|
|
"independent_locks:v3",
|
|
&["slow".to_string()],
|
|
move |keys| async move {
|
|
slow_started.notify_one();
|
|
slow_release.notified().await;
|
|
let values = DashMap::new();
|
|
values.insert(keys[0].clone(), "slow-value".to_string());
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
)
|
|
.await
|
|
});
|
|
started.notified().await;
|
|
|
|
let fast = timeout(
|
|
Duration::from_secs(1),
|
|
pool.get_cached_keys_raw(
|
|
"independent_locks:v3",
|
|
&["fast".to_string()],
|
|
|keys| async move {
|
|
let values = DashMap::new();
|
|
values.insert(keys[0].clone(), "fast-value".to_string());
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
),
|
|
)
|
|
.await
|
|
.expect("an unrelated key was blocked by the slow cache fill")
|
|
.unwrap();
|
|
assert_eq!(fast.get("fast"), Some(&"fast-value".to_string()));
|
|
|
|
release.notify_one();
|
|
assert_eq!(
|
|
slow.await.unwrap().unwrap().get("slow"),
|
|
Some(&"slow-value".to_string())
|
|
);
|
|
}
|
|
|
|
/// Protects lock cleanup on both error and cancellation so a failed cache fill
|
|
/// cannot strand future requests behind a stale local lock.
|
|
#[actix_rt::test]
|
|
async fn cache_lock_is_released_after_error_and_cancellation() {
|
|
let pool = isolated_redis_pool("lock_recovery").await;
|
|
|
|
let failed = pool
|
|
.get_cached_keys_raw(
|
|
"error_recovery:v3",
|
|
&["key".to_string()],
|
|
|_| async {
|
|
Err::<DashMap<String, String>, _>(DatabaseError::Internal(
|
|
eyre::eyre!("intentional cache fill failure"),
|
|
))
|
|
},
|
|
)
|
|
.await;
|
|
assert!(failed.is_err());
|
|
|
|
let recovered = timeout(
|
|
Duration::from_secs(1),
|
|
pool.get_cached_keys_raw(
|
|
"error_recovery:v3",
|
|
&["key".to_string()],
|
|
|keys| async move {
|
|
let values = DashMap::new();
|
|
values.insert(keys[0].clone(), "recovered".to_string());
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
),
|
|
)
|
|
.await
|
|
.expect("error left the cache key locked")
|
|
.unwrap();
|
|
assert_eq!(recovered.get("key"), Some(&"recovered".to_string()));
|
|
|
|
let started = Arc::new(Notify::new());
|
|
let cancelled_pool = pool.clone();
|
|
let cancelled_started = started.clone();
|
|
let cancelled = tokio::spawn(async move {
|
|
cancelled_pool
|
|
.get_cached_keys_raw(
|
|
"cancellation_recovery:v3",
|
|
&["key".to_string()],
|
|
move |_| async move {
|
|
cancelled_started.notify_one();
|
|
std::future::pending::<
|
|
Result<DashMap<String, String>, DatabaseError>,
|
|
>()
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
});
|
|
started.notified().await;
|
|
cancelled.abort();
|
|
assert!(cancelled.await.unwrap_err().is_cancelled());
|
|
|
|
let recovered = timeout(
|
|
Duration::from_secs(1),
|
|
pool.get_cached_keys_raw(
|
|
"cancellation_recovery:v3",
|
|
&["key".to_string()],
|
|
|keys| async move {
|
|
let values = DashMap::new();
|
|
values.insert(keys[0].clone(), "recovered".to_string());
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
),
|
|
)
|
|
.await
|
|
.expect("cancellation left the cache key locked")
|
|
.unwrap();
|
|
assert_eq!(recovered.get("key"), Some(&"recovered".to_string()));
|
|
}
|
|
|
|
/// Protects stale-while-revalidate behavior: an expired cached value may serve
|
|
/// a waiter immediately while exactly one writer refreshes it in the background.
|
|
#[actix_rt::test]
|
|
async fn expired_cache_value_serves_waiter_while_writer_refreshes() {
|
|
let pool = isolated_redis_pool("stale_while_revalidate").await;
|
|
let namespace = "stale_while_revalidate:v3";
|
|
let logical_key = "key".to_string();
|
|
let mut connection = pool.connect().await.unwrap();
|
|
let redis_key = connection.key().entity(namespace, &logical_key);
|
|
connection
|
|
.set_serialized(
|
|
&redis_key,
|
|
json!({
|
|
"key": logical_key,
|
|
"alias": null,
|
|
"iat": 0,
|
|
"val": "stale",
|
|
}),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let started = Arc::new(Notify::new());
|
|
let release = Arc::new(Notify::new());
|
|
let writer_pool = pool.clone();
|
|
let writer_started = started.clone();
|
|
let writer_release = release.clone();
|
|
let writer = tokio::spawn(async move {
|
|
writer_pool
|
|
.get_cached_keys_raw(
|
|
namespace,
|
|
&["key".to_string()],
|
|
move |keys| async move {
|
|
writer_started.notify_one();
|
|
writer_release.notified().await;
|
|
let values = DashMap::new();
|
|
values.insert(keys[0].clone(), "fresh".to_string());
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
)
|
|
.await
|
|
});
|
|
started.notified().await;
|
|
|
|
let stale = timeout(
|
|
Duration::from_secs(1),
|
|
pool.get_cached_keys_raw(namespace, &["key".to_string()], |_| async {
|
|
Err::<DashMap<String, String>, _>(DatabaseError::Internal(
|
|
eyre::eyre!("stale waiter unexpectedly became writer"),
|
|
))
|
|
}),
|
|
)
|
|
.await
|
|
.expect("stale value waited for the refresh")
|
|
.unwrap();
|
|
assert_eq!(stale.get("key"), Some(&"stale".to_string()));
|
|
|
|
release.notify_one();
|
|
assert_eq!(
|
|
writer.await.unwrap().unwrap().get("key"),
|
|
Some(&"fresh".to_string())
|
|
);
|
|
let fresh = pool
|
|
.get_cached_keys_raw(namespace, &["key".to_string()], |_| async {
|
|
Err::<DashMap<String, String>, _>(DatabaseError::Internal(
|
|
eyre::eyre!("fresh value unexpectedly missed cache"),
|
|
))
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(fresh.get("key"), Some(&"fresh".to_string()));
|
|
}
|
|
|
|
/// Protects case-insensitive slug locking: differently-cased aliases must map
|
|
/// to one local lock and one backing fetch, matching their lowercase Redis key.
|
|
#[actix_rt::test]
|
|
async fn case_insensitive_slug_requests_share_one_cache_lock() {
|
|
let pool = isolated_redis_pool("slug_lock_casing").await;
|
|
let canonical_id = "A1b2C3d4";
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
let fetch_count = Arc::new(AtomicUsize::new(0));
|
|
let mut tasks = Vec::new();
|
|
|
|
for requested in ["MiXeD-Slug".to_string(), "mixed-slug".to_string()] {
|
|
let pool = pool.clone();
|
|
let barrier = barrier.clone();
|
|
let fetch_count = fetch_count.clone();
|
|
tasks.push(tokio::spawn(async move {
|
|
let requested = vec![requested];
|
|
barrier.wait().await;
|
|
pool.get_cached_keys_raw_with_slug(
|
|
"slug_values:v3",
|
|
Some("slug_aliases:v3"),
|
|
false,
|
|
&requested,
|
|
move |_| async move {
|
|
fetch_count.fetch_add(1, Ordering::SeqCst);
|
|
tokio::time::sleep(Duration::from_millis(75)).await;
|
|
let values = DashMap::new();
|
|
values.insert(
|
|
canonical_id.to_string(),
|
|
(Some("MiXeD-Slug".to_string()), "value".to_string()),
|
|
);
|
|
Ok::<_, DatabaseError>(values)
|
|
},
|
|
)
|
|
.await
|
|
}));
|
|
}
|
|
|
|
for task in tasks {
|
|
let values = task.await.unwrap().unwrap();
|
|
assert_eq!(values.get(canonical_id), Some(&"value".to_string()));
|
|
}
|
|
assert_eq!(fetch_count.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
/// Protects the cluster-aware blocking pool and Pub/Sub seed rotation, which
|
|
/// use separate connection paths from ordinary cache commands.
|
|
#[actix_rt::test]
|
|
async fn blocking_queue_and_pubsub_work_with_cluster_connections() {
|
|
let pool = isolated_redis_pool("blocking_and_pubsub").await;
|
|
let queue_key = pool.key().entity("blocking", "queue");
|
|
let mut connection = pool.connect().await.unwrap();
|
|
connection
|
|
.lpush(&queue_key, b"queued-value".as_slice())
|
|
.await
|
|
.unwrap();
|
|
let popped = pool
|
|
.brpop(&queue_key, Duration::from_secs(1))
|
|
.await
|
|
.unwrap()
|
|
.expect("BRPOP did not receive the queued value");
|
|
assert_eq!(popped[0], queue_key.as_bytes());
|
|
assert_eq!(popped[1], b"queued-value");
|
|
|
|
// An unreachable first seed verifies that the reconnect loop advances to
|
|
// another cluster seed instead of abandoning the subscription.
|
|
let valid_seed = labrinth::env::ENV
|
|
.REDIS_URL
|
|
.split(',')
|
|
.next()
|
|
.unwrap()
|
|
.to_string();
|
|
let channel: &'static str = Box::leak(
|
|
format!("redis-test-pubsub-{}", Uuid::new_v4()).into_boxed_str(),
|
|
);
|
|
let mut receiver = RedisPool::subscribe_with_seed_urls(
|
|
vec!["redis://127.0.0.1:1".to_string(), valid_seed],
|
|
channel,
|
|
);
|
|
|
|
let mut delivered = false;
|
|
for _ in 0..20 {
|
|
pool.publish(channel, b"cluster-message".as_slice())
|
|
.await
|
|
.unwrap();
|
|
if let Ok(Some(message)) =
|
|
timeout(Duration::from_millis(100), receiver.recv()).await
|
|
{
|
|
assert_eq!(message, b"cluster-message");
|
|
delivered = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(delivered, "Pub/Sub did not recover from the invalid seed");
|
|
}
|
|
|
|
/// Protects the real many-get routes as one end-to-end cluster scenario:
|
|
/// partial cache hits, IDs and case-insensitive slugs, duplicates, misses,
|
|
/// invalidation, file hashes, dependency caches, and visibility filtering.
|
|
#[actix_rt::test]
|
|
async fn many_get_routes_handle_cross_slot_cache_lifecycle() {
|
|
with_test_environment(
|
|
None,
|
|
|test_env: TestEnvironment<common::api_v3::ApiV3>| async move {
|
|
let api = &test_env.api;
|
|
let alpha = &test_env.dummy.project_alpha;
|
|
let beta = &test_env.dummy.project_beta;
|
|
let alpha_project_id = parse_base62(&alpha.project_id).unwrap();
|
|
let beta_project_id = parse_base62(&beta.project_id).unwrap();
|
|
let alpha_version_id = parse_base62(&alpha.version_id).unwrap();
|
|
let beta_version_id = parse_base62(&beta.version_id).unwrap();
|
|
let missing_project_id = "R3d1sPj8";
|
|
let missing_version_id = "R3d1sVn8";
|
|
|
|
// Clearing known dummy-data entries makes the first single read a
|
|
// deliberate warm hit and the other entities deliberate cold hits.
|
|
let mut redis = test_env.db.redis_pool.connect().await.unwrap();
|
|
let cache_keys = vec![
|
|
redis.key().entity(PROJECTS_NAMESPACE, alpha_project_id),
|
|
redis.key().entity(PROJECTS_NAMESPACE, beta_project_id),
|
|
redis.key().entity(
|
|
PROJECTS_SLUGS_NAMESPACE,
|
|
alpha.project_slug.to_lowercase(),
|
|
),
|
|
redis.key().entity(
|
|
PROJECTS_SLUGS_NAMESPACE,
|
|
beta.project_slug.to_lowercase(),
|
|
),
|
|
redis.key().entity(VERSIONS_NAMESPACE, alpha_version_id),
|
|
redis.key().entity(VERSIONS_NAMESPACE, beta_version_id),
|
|
redis.key().entity(
|
|
"versions_files:v3",
|
|
format!("sha1_{}", alpha.file_hash),
|
|
),
|
|
redis.key().entity(
|
|
"versions_files:v3",
|
|
format!("sha1_{}", beta.file_hash),
|
|
),
|
|
];
|
|
assert!(
|
|
cache_keys
|
|
.iter()
|
|
.map(Slot::for_key)
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
> 1,
|
|
"route cache keys must exercise multiple slots"
|
|
);
|
|
redis.delete_many(&cache_keys).await.unwrap();
|
|
|
|
api.get_project_deserialized_common(
|
|
&alpha.project_id,
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
let project_cache_keys = vec![
|
|
redis.key().entity(PROJECTS_NAMESPACE, alpha_project_id),
|
|
redis.key().entity(PROJECTS_NAMESPACE, beta_project_id),
|
|
];
|
|
let cached_projects =
|
|
redis.get_many(&project_cache_keys).await.unwrap();
|
|
assert!(cached_projects[0].is_some());
|
|
assert!(cached_projects[1].is_none());
|
|
|
|
let uppercase_beta_slug = beta.project_slug.to_uppercase();
|
|
let project_request = [
|
|
alpha.project_id.as_str(),
|
|
uppercase_beta_slug.as_str(),
|
|
missing_project_id,
|
|
alpha.project_slug.as_str(),
|
|
alpha.project_id.as_str(),
|
|
];
|
|
for _ in 0..2 {
|
|
let response =
|
|
api.get_projects(&project_request, USER_USER_PAT).await;
|
|
assert_status!(&response, StatusCode::OK);
|
|
let projects: Vec<CommonProject> =
|
|
test::read_body_json(response).await;
|
|
let project_ids = projects
|
|
.iter()
|
|
.map(|project| project.id.to_string())
|
|
.collect::<HashSet<_>>();
|
|
assert_eq!(projects.len(), 2);
|
|
assert_eq!(
|
|
project_ids,
|
|
HashSet::from([
|
|
alpha.project_id.clone(),
|
|
beta.project_id.clone(),
|
|
])
|
|
);
|
|
}
|
|
|
|
api.get_version_deserialized_common(
|
|
&alpha.version_id,
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
let versions = api
|
|
.get_versions_deserialized_common(
|
|
vec![
|
|
alpha.version_id.clone(),
|
|
beta.version_id.clone(),
|
|
missing_version_id.to_string(),
|
|
alpha.version_id.clone(),
|
|
],
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
versions
|
|
.iter()
|
|
.map(|version| version.id.to_string())
|
|
.collect::<HashSet<_>>(),
|
|
HashSet::from([
|
|
alpha.version_id.clone(),
|
|
beta.version_id.clone(),
|
|
])
|
|
);
|
|
|
|
let hashes = [
|
|
alpha.file_hash.as_str(),
|
|
beta.file_hash.as_str(),
|
|
"missing-hash",
|
|
alpha.file_hash.as_str(),
|
|
];
|
|
let versions_by_hash = api
|
|
.get_versions_from_hashes_deserialized_common(
|
|
&hashes,
|
|
"sha1",
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_eq!(versions_by_hash.len(), 2);
|
|
|
|
// Warming dependencies before the edit ensures the assertion below
|
|
// depends on invalidating the project's separate dependency slot.
|
|
let dependencies = api
|
|
.get_project_dependencies(&alpha.project_id, USER_USER_PAT)
|
|
.await;
|
|
assert_status!(&dependencies, StatusCode::OK);
|
|
let dependencies: serde_json::Value =
|
|
test::read_body_json(dependencies).await;
|
|
assert!(dependencies["projects"].as_array().unwrap().is_empty());
|
|
|
|
let new_slug = format!("cluster-cache-{}", Uuid::new_v4().simple());
|
|
let response = api
|
|
.edit_project(
|
|
&alpha.project_id,
|
|
json!({ "slug": new_slug }),
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_status!(&response, StatusCode::NO_CONTENT);
|
|
assert_status!(
|
|
&api.get_project(&alpha.project_slug, USER_USER_PAT).await,
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
assert_status!(
|
|
&api.get_project(&new_slug, USER_USER_PAT).await,
|
|
StatusCode::OK
|
|
);
|
|
|
|
let updated_version_name = "cluster cache invalidation";
|
|
let response = api
|
|
.edit_version(
|
|
&alpha.version_id,
|
|
json!({
|
|
"name": updated_version_name,
|
|
"dependencies": [{
|
|
"project_id": beta.project_id,
|
|
"dependency_type": "required",
|
|
"file_name": "cluster-test.jar",
|
|
}],
|
|
}),
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_status!(&response, StatusCode::NO_CONTENT);
|
|
|
|
let updated_versions: Vec<CommonVersion> = api
|
|
.get_versions_deserialized_common(
|
|
vec![alpha.version_id.clone(), beta.version_id.clone()],
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
updated_versions
|
|
.iter()
|
|
.find(|version| version.id.to_string() == alpha.version_id)
|
|
.unwrap()
|
|
.name,
|
|
updated_version_name
|
|
);
|
|
let updated_by_hash = api
|
|
.get_versions_from_hashes_deserialized_common(
|
|
&[alpha.file_hash.as_str(), beta.file_hash.as_str()],
|
|
"sha1",
|
|
USER_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
updated_by_hash[&alpha.file_hash].name,
|
|
updated_version_name
|
|
);
|
|
|
|
let dependencies = api
|
|
.get_project_dependencies(&alpha.project_id, USER_USER_PAT)
|
|
.await;
|
|
assert_status!(&dependencies, StatusCode::OK);
|
|
let dependencies: serde_json::Value =
|
|
test::read_body_json(dependencies).await;
|
|
assert!(
|
|
dependencies["projects"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|project| project["id"] == beta.project_id)
|
|
);
|
|
|
|
// Visibility is evaluated after cache resolution, so a warm private
|
|
// project must not leak through a many-get to another user.
|
|
let response = api
|
|
.get_projects(
|
|
&[alpha.project_id.as_str(), beta.project_id.as_str()],
|
|
ENEMY_USER_PAT,
|
|
)
|
|
.await;
|
|
assert_status!(&response, StatusCode::OK);
|
|
let visible_projects: Vec<CommonProject> =
|
|
test::read_body_json(response).await;
|
|
assert_eq!(visible_projects.len(), 1);
|
|
assert_eq!(visible_projects[0].id.to_string(), alpha.project_id);
|
|
},
|
|
)
|
|
.await;
|
|
}
|