use std::sync::Arc;
use async_trait::async_trait;
use cf_system_sdks::directory::{DirectoryInvalidArgument, DirectoryNotFound};
use crate::DirectoryClient;
use crate::client_hub::ClientHub;
pub use toolkit_contract::runtime::resolving::{EndpointResolver, ResolveError};
pub struct DirectoryEndpointResolver {
client: Arc<dyn DirectoryClient>,
cache: parking_lot::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>,
}
impl DirectoryEndpointResolver {
pub const TTL: std::time::Duration = std::time::Duration::from_millis(1500);
#[must_use]
pub fn new(client: Arc<dyn DirectoryClient>) -> Self {
Self {
client,
cache: parking_lot::Mutex::new(std::collections::HashMap::new()),
}
}
fn cached(&self, gear: &str) -> Option<String> {
self.cache
.lock()
.get(gear)
.filter(|(_, at)| at.elapsed() < Self::TTL)
.map(|(uri, _)| uri.clone())
}
fn store(&self, gear: &str, uri: &str) {
self.cache
.lock()
.insert(gear.to_owned(), (uri.to_owned(), std::time::Instant::now()));
}
}
#[async_trait]
impl EndpointResolver for DirectoryEndpointResolver {
async fn resolve_endpoint(&self, gear: &str) -> Result<Option<String>, ResolveError> {
if let Some(uri) = self.cached(gear) {
return Ok(Some(uri));
}
match self.client.resolve_rest_service(gear).await {
Ok(ep) => {
self.store(gear, &ep.uri);
Ok(Some(ep.uri))
}
Err(e) if e.downcast_ref::<DirectoryNotFound>().is_some() => Ok(None),
Err(e) if e.downcast_ref::<DirectoryInvalidArgument>().is_some() => {
tracing::error!(
gear,
error = %e,
"provider name permanently rejected by the directory (invalid \
argument); this is a static configuration error, not a directory \
outage, and will never resolve. Check the consumed gear name"
);
Err(ResolveError::new(gear, e))
}
Err(e) => Err(ResolveError::new(gear, e)),
}
}
}
pub struct NullEndpointResolver;
#[async_trait]
impl EndpointResolver for NullEndpointResolver {
async fn resolve_endpoint(&self, _gear: &str) -> Result<Option<String>, ResolveError> {
Ok(None)
}
}
pub struct StaticEndpointResolver {
endpoint: String,
}
impl StaticEndpointResolver {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
}
}
}
#[async_trait]
impl EndpointResolver for StaticEndpointResolver {
async fn resolve_endpoint(&self, _gear: &str) -> Result<Option<String>, ResolveError> {
Ok(Some(self.endpoint.clone()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WireOutcome {
Local,
Remote,
}
pub struct ConsumerRegistration {
pub owner_gear: &'static str,
pub dep_gear: &'static str,
pub wire: WireFn,
}
pub type WireFn = fn(
&ClientHub,
Arc<dyn EndpointResolver>,
Option<&toolkit_contract::runtime::config::InternalTokenProvider>,
) -> anyhow::Result<WireOutcome>;
inventory::collect!(ConsumerRegistration);
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use cf_system_sdks::directory::{RegisterInstanceInfo, ServiceEndpoint, ServiceInstanceInfo};
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingDirectory {
calls: AtomicUsize,
}
#[async_trait]
impl DirectoryClient for CountingDirectory {
async fn resolve_grpc_service(&self, _: &str) -> anyhow::Result<ServiceEndpoint> {
unimplemented!()
}
async fn resolve_rest_service(&self, gear: &str) -> anyhow::Result<ServiceEndpoint> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ServiceEndpoint::new(format!("http://{gear}.local")))
}
async fn get_openapi_spec(&self, _: &str) -> anyhow::Result<String> {
unimplemented!()
}
async fn list_instances(&self, _: &str) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
unimplemented!()
}
async fn list_all_instances(&self) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
unimplemented!()
}
async fn register_instance(&self, _: RegisterInstanceInfo) -> anyhow::Result<()> {
unimplemented!()
}
async fn deregister_instance(&self, _: &str, _: &str) -> anyhow::Result<()> {
unimplemented!()
}
async fn send_heartbeat(&self, _: &str, _: &str) -> anyhow::Result<()> {
unimplemented!()
}
}
#[tokio::test]
async fn memoizes_successful_resolution_within_ttl() {
let dir = Arc::new(CountingDirectory {
calls: AtomicUsize::new(0),
});
let resolver = DirectoryEndpointResolver::new(dir.clone());
for _ in 0..3 {
let ep = resolver.resolve_endpoint("billing").await.unwrap();
assert_eq!(ep.as_deref(), Some("http://billing.local"));
}
assert_eq!(
dir.calls.load(Ordering::SeqCst),
1,
"successful resolution must be memoized within the TTL (one directory lookup)"
);
}
#[tokio::test]
async fn static_resolver_always_returns_fixed_endpoint() {
let resolver = StaticEndpointResolver::new("http://localhost:8081");
assert_eq!(
resolver
.resolve_endpoint("anything")
.await
.unwrap()
.as_deref(),
Some("http://localhost:8081")
);
assert_eq!(
resolver.resolve_endpoint("other").await.unwrap().as_deref(),
Some("http://localhost:8081")
);
}
#[tokio::test]
async fn null_resolver_never_resolves() {
assert_eq!(
NullEndpointResolver
.resolve_endpoint("billing")
.await
.unwrap(),
None
);
}
struct FailingDirectory {
make_error: fn(&str) -> anyhow::Error,
}
#[async_trait]
impl DirectoryClient for FailingDirectory {
async fn resolve_grpc_service(&self, _: &str) -> anyhow::Result<ServiceEndpoint> {
unimplemented!()
}
async fn resolve_rest_service(&self, gear: &str) -> anyhow::Result<ServiceEndpoint> {
Err((self.make_error)(gear))
}
async fn get_openapi_spec(&self, _: &str) -> anyhow::Result<String> {
unimplemented!()
}
async fn list_instances(&self, _: &str) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
unimplemented!()
}
async fn list_all_instances(&self) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
unimplemented!()
}
async fn register_instance(&self, _: RegisterInstanceInfo) -> anyhow::Result<()> {
unimplemented!()
}
async fn deregister_instance(&self, _: &str, _: &str) -> anyhow::Result<()> {
unimplemented!()
}
async fn send_heartbeat(&self, _: &str, _: &str) -> anyhow::Result<()> {
unimplemented!()
}
}
#[tokio::test]
async fn not_found_sentinel_resolves_to_ok_none() {
let dir = Arc::new(FailingDirectory {
make_error: |gear| DirectoryNotFound::new(format!("gear {gear}")).into(),
});
let resolver = DirectoryEndpointResolver::new(dir);
assert_eq!(resolver.resolve_endpoint("billing").await.unwrap(), None);
}
#[tokio::test]
async fn backend_failure_resolves_to_err() {
let dir = Arc::new(FailingDirectory {
make_error: |_| anyhow::anyhow!("connection refused"),
});
let resolver = DirectoryEndpointResolver::new(dir);
assert!(resolver.resolve_endpoint("billing").await.is_err());
}
#[tokio::test]
#[tracing_test::traced_test]
async fn invalid_argument_resolves_to_err_and_logs_config_error() {
let dir = Arc::new(FailingDirectory {
make_error: |gear| DirectoryInvalidArgument::new(format!("bad name {gear}")).into(),
});
let resolver = DirectoryEndpointResolver::new(dir);
assert!(resolver.resolve_endpoint("Bad Name").await.is_err());
logs_assert(|lines: &[&str]| {
match lines
.iter()
.find(|line| line.contains("permanently rejected by the directory"))
{
Some(line) if line.contains("ERROR") => Ok(()),
Some(_) => Err("invalid-argument arm logged at the wrong level".to_owned()),
None => {
Err("invalid-argument arm must emit a distinct config-error log".to_owned())
}
}
});
}
#[allow(
clippy::unnecessary_wraps,
reason = "mirrors the fallible signature of ConsumerRegistration::wire so the test \
exercises the same shape the macro emits"
)]
fn generated_wire_body<C>(hub: &ClientHub, proxy: Arc<C>) -> anyhow::Result<WireOutcome>
where
C: ?Sized + Send + Sync + 'static,
{
if hub.try_get_local::<C>().is_some() {
return Ok(WireOutcome::Local);
}
if hub.has_remote_proxy::<C>() {
return Ok(WireOutcome::Remote);
}
hub.register_remote_proxy::<C>(proxy);
Ok(WireOutcome::Remote)
}
trait Payments: Send + Sync {}
struct PaymentsProxy;
impl Payments for PaymentsProxy {}
struct PaymentsLocal;
impl Payments for PaymentsLocal {}
#[test]
fn two_consumers_of_one_contract_both_report_remote() {
let hub = ClientHub::new();
let first = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
let second = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
assert_eq!(first, WireOutcome::Remote);
assert_eq!(
second,
WireOutcome::Remote,
"the second consumer must still gate readiness, not short-circuit to Local"
);
}
#[test]
fn a_genuine_local_impl_still_wins() {
let hub = ClientHub::new();
hub.register::<dyn Payments>(Arc::new(PaymentsLocal));
let outcome = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
assert_eq!(outcome, WireOutcome::Local);
}
#[tokio::test]
async fn local_directory_client_not_found_reaches_the_resolver_as_ok_none() {
let dir = Arc::new(crate::directory::LocalDirectoryClient::new(Arc::new(
crate::runtime::GearManager::new(),
)));
let resolver = DirectoryEndpointResolver::new(dir);
assert_eq!(
resolver.resolve_endpoint("never-registered").await.unwrap(),
None
);
}
}