Skip to main content

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}