use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use tokio::sync::RwLock;
use crate::atlassian::client::{
AgileBoardList, AtlassianClient, JiraField, JiraLinkType, JiraProjectList,
};
pub const DEFAULT_TTL: Duration = Duration::from_secs(3600);
struct CacheEntry<T> {
instance_url: String,
fetched_at: Instant,
value: Arc<T>,
}
pub struct CatalogueCache {
link_types: RwLock<Option<CacheEntry<Vec<JiraLinkType>>>>,
fields: RwLock<Option<CacheEntry<Vec<JiraField>>>>,
projects: RwLock<Option<CacheEntry<JiraProjectList>>>,
boards: RwLock<Option<CacheEntry<AgileBoardList>>>,
ttl: Duration,
}
impl CatalogueCache {
#[must_use]
pub fn new(ttl: Duration) -> Self {
Self {
link_types: RwLock::new(None),
fields: RwLock::new(None),
projects: RwLock::new(None),
boards: RwLock::new(None),
ttl,
}
}
pub async fn link_types(&self, client: &AtlassianClient) -> Result<Arc<Vec<JiraLinkType>>> {
get_or_fetch(
&self.link_types,
client.instance_url(),
self.ttl,
|| async { client.get_link_types().await },
)
.await
}
pub async fn fields(&self, client: &AtlassianClient) -> Result<Arc<Vec<JiraField>>> {
get_or_fetch(&self.fields, client.instance_url(), self.ttl, || async {
client.get_fields().await
})
.await
}
pub async fn projects(&self, client: &AtlassianClient) -> Result<Arc<JiraProjectList>> {
get_or_fetch(&self.projects, client.instance_url(), self.ttl, || async {
client.get_projects(0).await
})
.await
}
pub async fn boards(&self, client: &AtlassianClient) -> Result<Arc<AgileBoardList>> {
get_or_fetch(&self.boards, client.instance_url(), self.ttl, || async {
client.get_boards(None, None, 0).await
})
.await
}
}
impl Default for CatalogueCache {
fn default() -> Self {
Self::new(DEFAULT_TTL)
}
}
async fn get_or_fetch<T, F, Fut>(
slot: &RwLock<Option<CacheEntry<T>>>,
instance_url: &str,
ttl: Duration,
fetch: F,
) -> Result<Arc<T>>
where
T: Send + Sync,
F: FnOnce() -> Fut + Send,
Fut: Future<Output = Result<T>> + Send,
{
if let Some(value) = read_fresh(slot, instance_url, ttl).await {
return Ok(value);
}
let mut guard = slot.write().await;
if let Some(entry) = guard.as_ref() {
if entry.instance_url == instance_url && entry.fetched_at.elapsed() < ttl {
return Ok(Arc::clone(&entry.value));
}
}
let value = Arc::new(fetch().await?);
*guard = Some(CacheEntry {
instance_url: instance_url.to_string(),
fetched_at: Instant::now(),
value: Arc::clone(&value),
});
Ok(value)
}
async fn read_fresh<T>(
slot: &RwLock<Option<CacheEntry<T>>>,
instance_url: &str,
ttl: Duration,
) -> Option<Arc<T>>
where
T: Send + Sync,
{
let guard = slot.read().await;
let entry = guard.as_ref()?;
if entry.instance_url == instance_url && entry.fetched_at.elapsed() < ttl {
Some(Arc::clone(&entry.value))
} else {
None
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "u@t.com", "tok").unwrap()
}
fn link_types_body() -> serde_json::Value {
serde_json::json!({
"issueLinkTypes": [
{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}
]
})
}
fn fields_body() -> serde_json::Value {
serde_json::json!([
{"id": "summary", "name": "Summary", "custom": false}
])
}
fn projects_body() -> serde_json::Value {
serde_json::json!({
"values": [{"id": "10001", "key": "PROJ", "name": "Project"}],
"total": 1,
"isLast": true
})
}
fn boards_body() -> serde_json::Value {
serde_json::json!({
"values": [{"id": 1, "name": "B", "type": "scrum"}],
"isLast": true
})
}
#[tokio::test]
async fn link_types_cached_after_first_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(link_types_body()))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_secs(60));
let a = cache.link_types(&client).await.unwrap();
let b = cache.link_types(&client).await.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_eq!(a[0].name, "Blocks");
}
#[tokio::test]
async fn fields_cached_after_first_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/field"))
.respond_with(ResponseTemplate::new(200).set_body_json(fields_body()))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_secs(60));
let _ = cache.fields(&client).await.unwrap();
let second = cache.fields(&client).await.unwrap();
assert_eq!(second[0].name, "Summary");
}
#[tokio::test]
async fn projects_cached_after_first_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/project/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(projects_body()))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_secs(60));
let _ = cache.projects(&client).await.unwrap();
let second = cache.projects(&client).await.unwrap();
assert_eq!(second.projects[0].key, "PROJ");
}
#[tokio::test]
async fn boards_cached_after_first_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/board"))
.respond_with(ResponseTemplate::new(200).set_body_json(boards_body()))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_secs(60));
let _ = cache.boards(&client).await.unwrap();
let second = cache.boards(&client).await.unwrap();
assert_eq!(second.boards[0].name, "B");
}
#[tokio::test]
async fn cache_refetches_after_ttl_expiry() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(link_types_body()))
.expect(2)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_millis(20));
let _ = cache.link_types(&client).await.unwrap();
tokio::time::sleep(Duration::from_millis(40)).await;
let _ = cache.link_types(&client).await.unwrap();
}
#[tokio::test]
async fn cache_refetches_when_instance_url_changes() {
let server_a = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(link_types_body()))
.expect(1)
.mount(&server_a)
.await;
let server_b = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(link_types_body()))
.expect(1)
.mount(&server_b)
.await;
let cache = CatalogueCache::new(Duration::from_secs(60));
let client_a = mock_client(&server_a.uri());
let client_b = mock_client(&server_b.uri());
let _ = cache.link_types(&client_a).await.unwrap();
let _ = cache.link_types(&client_b).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_refresh_shares_a_single_fetch() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(link_types_body())
.set_delay(Duration::from_millis(100)),
)
.expect(1)
.mount(&server)
.await;
let cache = Arc::new(CatalogueCache::new(Duration::from_secs(60)));
let url = server.uri();
let mut gate = cache.link_types.write().await;
*gate = Some(CacheEntry {
instance_url: url.clone(),
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(3600 * 24))
.unwrap(),
value: Arc::new(Vec::new()),
});
let mut handles = Vec::new();
for _ in 0..2 {
let cache = Arc::clone(&cache);
let url = url.clone();
handles.push(tokio::spawn(async move {
let client = mock_client(&url);
cache.link_types(&client).await.unwrap()
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
drop(gate);
for h in handles {
let v = h.await.unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].name, "Blocks");
}
}
#[tokio::test]
async fn cache_does_not_populate_on_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.expect(2)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cache = CatalogueCache::new(Duration::from_secs(60));
assert!(cache.link_types(&client).await.is_err());
assert!(cache.link_types(&client).await.is_err());
}
}