osproxy_core/target.rs
1//! Where a routed request is sent.
2//!
3//! A [`Target`] is the physical destination a routing decision resolves to: a
4//! concrete cluster and a concrete index. In v1 every request resolves to
5//! exactly one target, there is no synchronous fan-out (`docs/00` non-goals,
6//! ADR-002). The tenancy layer turns a partition's placement into a `Target`;
7//! the sink and upstream pool consume it.
8
9use std::fmt;
10
11use crate::ids::{ClusterId, IndexName};
12
13/// The physical destination of a single routed request.
14///
15/// Both fields are ids/names (never tenant values), so a `Target` is safe to
16/// render in telemetry and `/debug/explain` (`docs/05` ยง7).
17///
18/// # Examples
19///
20/// ```
21/// use osproxy_core::{ClusterId, IndexName, Target};
22///
23/// let target = Target::new(ClusterId::from("eu-1"), IndexName::from("logs-shared"));
24/// assert_eq!(target.cluster.as_str(), "eu-1");
25/// assert_eq!(target.to_string(), "eu-1/logs-shared");
26/// ```
27#[derive(Clone, Debug)]
28pub struct Target {
29 /// The physical OpenSearch cluster the request is sent to.
30 pub cluster: ClusterId,
31 /// The concrete (physical) index the request operates on.
32 pub index: IndexName,
33 /// The cluster's base URL, supplied by the tenancy as part of the placement
34 /// result (the sink builds a pool for it on first use). `None` only in unit
35 /// tests that dispatch to an in-memory sink, which ignores it.
36 ///
37 /// Excluded from identity (equality/hashing/`Display`): the endpoint is a
38 /// function of the cluster, not part of *which* target this is, so two ops
39 /// for the same `cluster`+`index` stay one demux key regardless of it.
40 pub endpoint: Option<String>,
41}
42
43impl Target {
44 /// Constructs a target from a cluster and an index (no endpoint).
45 #[must_use]
46 pub fn new(cluster: ClusterId, index: IndexName) -> Self {
47 Self {
48 cluster,
49 index,
50 endpoint: None,
51 }
52 }
53
54 /// Sets the cluster's base URL (builder style), as resolved from the
55 /// placement result.
56 #[must_use]
57 pub fn with_endpoint(mut self, endpoint: Option<String>) -> Self {
58 self.endpoint = endpoint;
59 self
60 }
61}
62
63// Identity is (cluster, index) only; the endpoint is dispatch metadata derived
64// from the cluster, so it is deliberately excluded.
65impl PartialEq for Target {
66 fn eq(&self, other: &Self) -> bool {
67 self.cluster == other.cluster && self.index == other.index
68 }
69}
70impl Eq for Target {}
71impl std::hash::Hash for Target {
72 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
73 self.cluster.hash(state);
74 self.index.hash(state);
75 }
76}
77
78impl fmt::Display for Target {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 write!(f, "{}/{}", self.cluster, self.index)
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn target_exposes_cluster_and_index_and_displays_path_like() {
90 let target = Target::new(ClusterId::from("us-2"), IndexName::from("orders-7"));
91 assert_eq!(target.cluster.as_str(), "us-2");
92 assert_eq!(target.index.as_str(), "orders-7");
93 assert_eq!(target.to_string(), "us-2/orders-7");
94 }
95
96 #[test]
97 fn targets_compare_by_both_fields() {
98 let a = Target::new(ClusterId::from("c"), IndexName::from("i"));
99 let b = Target::new(ClusterId::from("c"), IndexName::from("j"));
100 assert_ne!(a, b);
101 assert_eq!(a, a.clone());
102 }
103}