toolkit/discovery.rs
1//! Consumer-side discovery wiring for eventual readiness.
2//!
3//! Bridges the service [`DirectoryClient`](crate::DirectoryClient) into the
4//! contract layer's [`EndpointResolver`], and carries the
5//! [`ConsumerRegistration`]s emitted by `#[toolkit::consumes]` that the
6//! runtime's proxy-wiring phase replays at startup.
7//!
8//! Unconditional: consuming another gear over a discovered REST transport is
9//! the normal case, and `#[toolkit::consumes]` emits its registration
10//! unconditionally too. Putting this behind a feature only created a way to
11//! build a gear whose declared dependencies were silently never wired.
12
13use std::sync::Arc;
14
15use async_trait::async_trait;
16
17use cf_system_sdks::directory::{DirectoryInvalidArgument, DirectoryNotFound};
18
19use crate::DirectoryClient;
20use crate::client_hub::ClientHub;
21
22/// Re-export so the `#[toolkit::consumes]`-generated wire fn can name the
23/// resolver type as `toolkit::discovery::EndpointResolver` without the consuming
24/// gear crate needing a direct `toolkit-contract` dependency.
25pub use toolkit_contract::runtime::resolving::{EndpointResolver, ResolveError};
26
27/// Adapts the service [`DirectoryClient`] into the contract-layer
28/// [`EndpointResolver`] consumed by `DirectoryResolvingClient`.
29///
30/// Keeps `toolkit-contract` free of a dependency on the directory SDK: the
31/// adapter lives here in `toolkit`, which already owns `DirectoryClient`.
32///
33/// Successful resolutions are memoized for a short TTL ([`Self::TTL`]) so a
34/// per-call resolving client does not incur a directory round-trip on every
35/// business call (the `OoP` directory is a network hop). Only `Ok(Some(uri))` is
36/// cached; `Ok(None)` / `Err` always re-resolve. The TTL is deliberately short
37/// so endpoint churn self-heals within ~TTL.
38pub struct DirectoryEndpointResolver {
39 client: Arc<dyn DirectoryClient>,
40 cache: parking_lot::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>,
41}
42
43impl DirectoryEndpointResolver {
44 /// Memoization window for successful resolutions.
45 ///
46 /// Public because it bounds observable behaviour: a provider that moves is
47 /// followed within roughly this window, not instantly, since the cache
48 /// holds only successes and is not invalidated by a failed call.
49 pub const TTL: std::time::Duration = std::time::Duration::from_millis(1500);
50
51 /// Wrap a [`DirectoryClient`] with a short-TTL resolution cache.
52 #[must_use]
53 pub fn new(client: Arc<dyn DirectoryClient>) -> Self {
54 Self {
55 client,
56 cache: parking_lot::Mutex::new(std::collections::HashMap::new()),
57 }
58 }
59
60 fn cached(&self, gear: &str) -> Option<String> {
61 self.cache
62 .lock()
63 .get(gear)
64 .filter(|(_, at)| at.elapsed() < Self::TTL)
65 .map(|(uri, _)| uri.clone())
66 }
67
68 fn store(&self, gear: &str, uri: &str) {
69 self.cache
70 .lock()
71 .insert(gear.to_owned(), (uri.to_owned(), std::time::Instant::now()));
72 }
73}
74
75#[async_trait]
76impl EndpointResolver for DirectoryEndpointResolver {
77 async fn resolve_endpoint(&self, gear: &str) -> Result<Option<String>, ResolveError> {
78 // Fresh cache hit avoids the directory round-trip.
79 if let Some(uri) = self.cached(gear) {
80 return Ok(Some(uri));
81 }
82 // Preserve the trait's `Ok(None)` (no live instance / not ready) vs
83 // `Err` (directory backend unreachable) distinction so a real directory
84 // outage surfaces at `warn` rather than being silently treated as
85 // "provider not ready". The directory returns the typed
86 // `DirectoryNotFound` sentinel for the not-found case; anything else is
87 // a genuine backend/transport failure (relevant for the OoP gRPC
88 // directory client).
89 match self.client.resolve_rest_service(gear).await {
90 Ok(ep) => {
91 self.store(gear, &ep.uri);
92 Ok(Some(ep.uri))
93 }
94 Err(e) if e.downcast_ref::<DirectoryNotFound>().is_some() => Ok(None),
95 Err(e) if e.downcast_ref::<DirectoryInvalidArgument>().is_some() => {
96 // A malformed provider name is a permanent configuration error,
97 // not a transient outage: re-resolving can never succeed. Log
98 // it distinctly from the generic backend-failure path (at
99 // `error`, not the caller's transient `warn`) so the actionable
100 // "fix your config" message is not mistaken for a directory
101 // outage, and still return `Err` so the call fails.
102 tracing::error!(
103 gear,
104 error = %e,
105 "provider name permanently rejected by the directory (invalid \
106 argument); this is a static configuration error, not a directory \
107 outage, and will never resolve. Check the consumed gear name"
108 );
109 Err(ResolveError::new(gear, e))
110 }
111 Err(e) => Err(ResolveError::new(gear, e)),
112 }
113 }
114}
115
116/// An [`EndpointResolver`] that never resolves any gear (`Ok(None)` for every
117/// lookup). Used by the proxy-wiring phase when no [`DirectoryClient`] is present
118/// in the [`ClientHub`]: co-located (local) consumers still short-circuit and
119/// wire successfully, while remote consumers register as *unresolved* readiness
120/// gates (so `/readyz` correctly stays 503) rather than being silently skipped.
121pub struct NullEndpointResolver;
122
123#[async_trait]
124impl EndpointResolver for NullEndpointResolver {
125 async fn resolve_endpoint(&self, _gear: &str) -> Result<Option<String>, ResolveError> {
126 Ok(None)
127 }
128}
129
130/// An [`EndpointResolver`] that always resolves to a single, fixed endpoint —
131/// the ADR-0004 static-endpoint override escape hatch for local development and
132/// integration testing. It bypasses the service directory: a consumer configured
133/// with `gears.<owner>.config.consumer_wiring.<dep> = "http://host:port"` wires a
134/// resolving client whose endpoint is this constant. Not for production use.
135pub struct StaticEndpointResolver {
136 endpoint: String,
137}
138
139impl StaticEndpointResolver {
140 /// Wrap a fixed base endpoint URI.
141 #[must_use]
142 pub fn new(endpoint: impl Into<String>) -> Self {
143 Self {
144 endpoint: endpoint.into(),
145 }
146 }
147}
148
149#[async_trait]
150impl EndpointResolver for StaticEndpointResolver {
151 async fn resolve_endpoint(&self, _gear: &str) -> Result<Option<String>, ResolveError> {
152 Ok(Some(self.endpoint.clone()))
153 }
154}
155
156/// Outcome of a [`ConsumerRegistration::wire`] call: whether the dependency was
157/// satisfied by a co-located compile-time impl (no directory dependency) or by
158/// a directory-resolving REST client that still needs endpoint resolution.
159///
160/// The runtime uses this to gate readiness: `Local` deps are readiness-resolved
161/// immediately (no `/readyz` gating, no probe loop), while `Remote` deps gate
162/// readiness until the directory resolves them (ADR-0007).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum WireOutcome {
165 /// A compile-time (in-process) impl was already registered — it won the
166 /// `hub.try_get_local` short-circuit; no remote binding was created.
167 Local,
168 /// A directory-resolving REST client was registered; its endpoint is
169 /// resolved lazily/at readiness-probe time.
170 Remote,
171}
172
173/// A consumer→provider contract wiring, registered by `#[toolkit::consumes]`
174/// via `inventory::submit!` and replayed by the runtime's proxy-wiring phase.
175///
176/// `wire` is a non-capturing `fn` (the provider gear name is inlined into the
177/// generated body). It must:
178/// 1. short-circuit returning [`WireOutcome::Local`] if a compile-time (local)
179/// impl is already registered (`hub.try_get_local::<dyn Trait>().is_some()`),
180/// so in-process providers win;
181/// 2. otherwise register a directory-resolving REST client under the contract
182/// trait via [`ClientHub::register_remote_proxy`] (using the supplied
183/// [`EndpointResolver`]) and return [`WireOutcome::Remote`].
184///
185/// Step 1 must use `try_get_local`, not `try_get`. The hub is keyed by type, so
186/// when two consumers in one process depend on the same contract the second to
187/// wire would find the first one's proxy under `try_get`, report `Local`, and
188/// have its dependency marked readiness-resolved — flipping `/readyz` to 200
189/// while the provider is still absent. `register_remote_proxy` is what makes
190/// that distinction visible to the hub.
191pub struct ConsumerRegistration {
192 /// Gear that declares the dependency (diagnostics).
193 pub owner_gear: &'static str,
194 /// Provider gear name the contract resolves against (diagnostics).
195 pub dep_gear: &'static str,
196 /// Wiring action: register the client into the hub; reports whether the
197 /// dependency was satisfied locally or bound to a remote client. See
198 /// [`WireFn`] for the argument roles.
199 pub wire: WireFn,
200}
201
202/// The wiring function pointer emitted by `#[toolkit::consumes]`: registers the
203/// client into the `ClientHub` from an endpoint resolver and a prepared
204/// [`ClientTuning`](toolkit_contract::wiring::ClientTuning) (the `consumer_wiring`
205/// knobs plus the process's platform-plane credential source). Aliased to keep
206/// the fn-pointer type readable.
207pub type WireFn = fn(
208 &ClientHub,
209 Arc<dyn EndpointResolver>,
210 toolkit_contract::wiring::ClientTuning,
211) -> anyhow::Result<WireOutcome>;
212
213inventory::collect!(ConsumerRegistration);
214
215#[cfg(test)]
216#[cfg_attr(coverage_nightly, coverage(off))]
217mod tests {
218 use super::*;
219 use cf_system_sdks::directory::{RegisterInstanceInfo, ServiceEndpoint, ServiceInstanceInfo};
220 use std::sync::atomic::{AtomicUsize, Ordering};
221
222 /// Counts `resolve_rest_service` calls; all other methods are unused here.
223 struct CountingDirectory {
224 calls: AtomicUsize,
225 }
226
227 #[async_trait]
228 impl DirectoryClient for CountingDirectory {
229 async fn resolve_grpc_service(&self, _: &str) -> anyhow::Result<ServiceEndpoint> {
230 unimplemented!()
231 }
232 async fn resolve_rest_service(&self, gear: &str) -> anyhow::Result<ServiceEndpoint> {
233 self.calls.fetch_add(1, Ordering::SeqCst);
234 Ok(ServiceEndpoint::new(format!("http://{gear}.local")))
235 }
236 async fn get_openapi_spec(&self, _: &str) -> anyhow::Result<String> {
237 unimplemented!()
238 }
239 async fn list_instances(&self, _: &str) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
240 unimplemented!()
241 }
242 async fn list_all_instances(&self) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
243 unimplemented!()
244 }
245 async fn register_instance(&self, _: RegisterInstanceInfo) -> anyhow::Result<()> {
246 unimplemented!()
247 }
248 async fn deregister_instance(&self, _: &str, _: &str) -> anyhow::Result<()> {
249 unimplemented!()
250 }
251 async fn send_heartbeat(&self, _: &str, _: &str) -> anyhow::Result<()> {
252 unimplemented!()
253 }
254 }
255
256 #[tokio::test]
257 async fn memoizes_successful_resolution_within_ttl() {
258 let dir = Arc::new(CountingDirectory {
259 calls: AtomicUsize::new(0),
260 });
261 let resolver = DirectoryEndpointResolver::new(dir.clone());
262
263 for _ in 0..3 {
264 let ep = resolver.resolve_endpoint("billing").await.unwrap();
265 assert_eq!(ep.as_deref(), Some("http://billing.local"));
266 }
267
268 assert_eq!(
269 dir.calls.load(Ordering::SeqCst),
270 1,
271 "successful resolution must be memoized within the TTL (one directory lookup)"
272 );
273 }
274
275 #[tokio::test]
276 async fn static_resolver_always_returns_fixed_endpoint() {
277 let resolver = StaticEndpointResolver::new("http://localhost:8081");
278 assert_eq!(
279 resolver
280 .resolve_endpoint("anything")
281 .await
282 .unwrap()
283 .as_deref(),
284 Some("http://localhost:8081")
285 );
286 assert_eq!(
287 resolver.resolve_endpoint("other").await.unwrap().as_deref(),
288 Some("http://localhost:8081")
289 );
290 }
291
292 #[tokio::test]
293 async fn null_resolver_never_resolves() {
294 assert_eq!(
295 NullEndpointResolver
296 .resolve_endpoint("billing")
297 .await
298 .unwrap(),
299 None
300 );
301 }
302
303 /// Answers every lookup with a caller-chosen failure, so the resolver's
304 /// two error branches can be told apart.
305 struct FailingDirectory {
306 make_error: fn(&str) -> anyhow::Error,
307 }
308
309 #[async_trait]
310 impl DirectoryClient for FailingDirectory {
311 async fn resolve_grpc_service(&self, _: &str) -> anyhow::Result<ServiceEndpoint> {
312 unimplemented!()
313 }
314 async fn resolve_rest_service(&self, gear: &str) -> anyhow::Result<ServiceEndpoint> {
315 Err((self.make_error)(gear))
316 }
317 async fn get_openapi_spec(&self, _: &str) -> anyhow::Result<String> {
318 unimplemented!()
319 }
320 async fn list_instances(&self, _: &str) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
321 unimplemented!()
322 }
323 async fn list_all_instances(&self) -> anyhow::Result<Vec<ServiceInstanceInfo>> {
324 unimplemented!()
325 }
326 async fn register_instance(&self, _: RegisterInstanceInfo) -> anyhow::Result<()> {
327 unimplemented!()
328 }
329 async fn deregister_instance(&self, _: &str, _: &str) -> anyhow::Result<()> {
330 unimplemented!()
331 }
332 async fn send_heartbeat(&self, _: &str, _: &str) -> anyhow::Result<()> {
333 unimplemented!()
334 }
335 }
336
337 #[tokio::test]
338 async fn not_found_sentinel_resolves_to_ok_none() {
339 // "The provider has not registered yet" is a routine startup condition,
340 // not a failure: it must come back as `Ok(None)` so the readiness probe
341 // logs it at `debug` and the resolving client reports `Unresolved`
342 // rather than warning on every call.
343 let dir = Arc::new(FailingDirectory {
344 make_error: |gear| DirectoryNotFound::new(format!("gear {gear}")).into(),
345 });
346 let resolver = DirectoryEndpointResolver::new(dir);
347
348 assert_eq!(resolver.resolve_endpoint("billing").await.unwrap(), None);
349 }
350
351 #[tokio::test]
352 async fn backend_failure_resolves_to_err() {
353 // Anything that is not the sentinel is a genuine directory outage and
354 // must stay an error, so a stuck 503 has a diagnostic trail.
355 let dir = Arc::new(FailingDirectory {
356 make_error: |_| anyhow::anyhow!("connection refused"),
357 });
358 let resolver = DirectoryEndpointResolver::new(dir);
359
360 assert!(resolver.resolve_endpoint("billing").await.is_err());
361 }
362
363 #[tokio::test]
364 #[tracing_test::traced_test]
365 async fn invalid_argument_resolves_to_err_and_logs_config_error() {
366 // A malformed provider name is a permanent configuration error, handled
367 // by the dedicated arm. Unlike the not-found sentinel (`Ok(None)`), it
368 // must stay an `Err` so the call fails rather than being treated as a
369 // provider that is merely not ready yet. It must also be logged
370 // distinctly at `error` (a static configuration error) so it is not
371 // folded into the generic backend-failure path, which emits no log of
372 // its own at the resolver.
373 let dir = Arc::new(FailingDirectory {
374 make_error: |gear| DirectoryInvalidArgument::new(format!("bad name {gear}")).into(),
375 });
376 let resolver = DirectoryEndpointResolver::new(dir);
377
378 assert!(resolver.resolve_endpoint("Bad Name").await.is_err());
379
380 logs_assert(|lines: &[&str]| {
381 match lines
382 .iter()
383 .find(|line| line.contains("permanently rejected by the directory"))
384 {
385 Some(line) if line.contains("ERROR") => Ok(()),
386 Some(_) => Err("invalid-argument arm logged at the wrong level".to_owned()),
387 None => {
388 Err("invalid-argument arm must emit a distinct config-error log".to_owned())
389 }
390 }
391 });
392 }
393
394 /// Mirrors the body `#[toolkit::consumes]` generates, so the wiring
395 /// contract is exercised without going through `inventory` (which is
396 /// link-time global and cannot hold two registrations built by a test).
397 #[allow(
398 clippy::unnecessary_wraps,
399 reason = "mirrors the fallible signature of ConsumerRegistration::wire so the test \
400 exercises the same shape the macro emits"
401 )]
402 fn generated_wire_body<C>(hub: &ClientHub, proxy: Arc<C>) -> anyhow::Result<WireOutcome>
403 where
404 C: ?Sized + Send + Sync + 'static,
405 {
406 if hub.try_get_local::<C>().is_some() {
407 return Ok(WireOutcome::Local);
408 }
409 if hub.has_remote_proxy::<C>() {
410 return Ok(WireOutcome::Remote);
411 }
412 hub.register_remote_proxy::<C>(proxy);
413 Ok(WireOutcome::Remote)
414 }
415
416 trait Payments: Send + Sync {}
417 struct PaymentsProxy;
418 impl Payments for PaymentsProxy {}
419 struct PaymentsLocal;
420 impl Payments for PaymentsLocal {}
421
422 #[test]
423 fn two_consumers_of_one_contract_both_report_remote() {
424 // Two gears in one process consuming the same contract from the same
425 // provider. Whichever wires second used to find the first one's proxy,
426 // conclude the dependency was local, and get it marked
427 // readiness-resolved — with both registrations sharing a `dep_gear`,
428 // that flipped the very entry the unresolved remote dep was gating on,
429 // and `/readyz` went green with no provider up.
430 let hub = ClientHub::new();
431
432 let first = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
433 let second = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
434
435 assert_eq!(first, WireOutcome::Remote);
436 assert_eq!(
437 second,
438 WireOutcome::Remote,
439 "the second consumer must still gate readiness, not short-circuit to Local"
440 );
441 }
442
443 #[test]
444 fn a_genuine_local_impl_still_wins() {
445 // The short-circuit must keep working for its actual purpose: a
446 // co-located provider registered during init means no HTTP hop and no
447 // readiness gate.
448 let hub = ClientHub::new();
449 hub.register::<dyn Payments>(Arc::new(PaymentsLocal));
450
451 let outcome = generated_wire_body::<dyn Payments>(&hub, Arc::new(PaymentsProxy)).unwrap();
452
453 assert_eq!(outcome, WireOutcome::Local);
454 }
455
456 #[tokio::test]
457 async fn local_directory_client_not_found_reaches_the_resolver_as_ok_none() {
458 // End-to-end over the real in-process client rather than a stub: this
459 // is the pairing that was broken (the client returned a bare `anyhow`,
460 // so the resolver could never produce `Ok(None)`).
461 let dir = Arc::new(crate::directory::LocalDirectoryClient::new(Arc::new(
462 crate::runtime::GearManager::new(),
463 )));
464 let resolver = DirectoryEndpointResolver::new(dir);
465
466 assert_eq!(
467 resolver.resolve_endpoint("never-registered").await.unwrap(),
468 None
469 );
470 }
471}