Skip to main content

osproxy_server/
tenancy.rs

1//! The reference tenancy implementation the binary serves.
2//!
3//! A minimal but complete [`TenancySpi`]: the partition is the `tenant_id`
4//! body field on ingest (or the `x-tenant` header on by-id reads, which carry
5//! no body), every document gets a `_tenant` field and a `{partition}:{body.id}`
6//! id with routing, and every partition lives on one shared index. It exists to
7//! make the binary runnable and to demonstrate the SPI; real consumers provide
8//! their own.
9
10use osproxy_core::{ClusterId, Epoch, FieldName, IndexName, PartitionId};
11use osproxy_spi::{
12    BodyDoc, DocIdRule, IdTemplate, InjectedField, InjectedValue, JsonPath, PartitionKeySpec,
13    Placement, PlacementAt, SpiError, TenancySpi,
14};
15
16/// The injected tenancy field name.
17const TENANT_FIELD: &str = "_tenant";
18
19/// The header carrying the partition on by-id reads (which have no body).
20const TENANT_HEADER: &str = "x-tenant";
21
22/// Which placement kind the reference tenancy resolves every partition to. The
23/// binary defaults to [`PlacementMode::SharedIndex`] (the body-rewrite mode); the
24/// other two let one reference impl demonstrate the **no-body-rewrite** routing
25/// modes, where isolation is by cluster or by index and the document is forwarded
26/// unchanged (`docs/guide/10-choosing-a-mode`).
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum PlacementMode {
29    /// Many partitions share one index; isolation by an injected field and a
30    /// partition-scoped `_id` — the document body is rewritten on ingest.
31    #[default]
32    SharedIndex,
33    /// Each partition owns a physical index on the shared cluster; isolation is by
34    /// index name, so the body is forwarded unchanged (no rewrite).
35    DedicatedIndex,
36    /// Each partition owns a whole cluster; isolation is by cluster, so the body is
37    /// forwarded unchanged (no rewrite).
38    DedicatedCluster,
39}
40
41/// A reference tenancy: every partition resolves to a placement of the configured
42/// [`PlacementMode`]. The default `SharedIndex` mode isolates by an injected
43/// `_tenant` field; the dedicated modes isolate by index/cluster with no body
44/// rewrite.
45#[derive(Debug)]
46pub struct ReferenceTenancy {
47    cluster: ClusterId,
48    index: IndexName,
49    endpoint: String,
50    mode: PlacementMode,
51}
52
53impl ReferenceTenancy {
54    /// Builds the reference tenancy over one cluster and shared index, served at
55    /// `endpoint` (the cluster's base URL, reported as part of the placement
56    /// result so the sink can pool it). Defaults to [`PlacementMode::SharedIndex`].
57    #[must_use]
58    pub fn new(cluster: ClusterId, index: IndexName, endpoint: impl Into<String>) -> Self {
59        Self {
60            cluster,
61            index,
62            endpoint: endpoint.into(),
63            mode: PlacementMode::SharedIndex,
64        }
65    }
66
67    /// Sets the placement mode (builder). `SharedIndex` rewrites the body;
68    /// `DedicatedIndex`/`DedicatedCluster` route without touching it.
69    #[must_use]
70    pub fn with_placement_mode(mut self, mode: PlacementMode) -> Self {
71        self.mode = mode;
72        self
73    }
74}
75
76impl TenancySpi for ReferenceTenancy {
77    fn resolve_partition(
78        &self,
79        ctx: &osproxy_spi::RequestCtx<'_>,
80        body: BodyDoc<'_>,
81    ) -> Result<osproxy_core::PartitionId, osproxy_spi::SpiError> {
82        // Ingest carries the partition in the body; by-id reads have no body, so
83        // they carry it in a header set by the caller (or an auth gateway).
84        let spec = PartitionKeySpec::AnyOf(vec![
85            PartitionKeySpec::BodyField(JsonPath::new("tenant_id")),
86            PartitionKeySpec::Header(TENANT_HEADER.to_owned()),
87        ]);
88        osproxy_tenancy::resolve_partition_spec(&spec, ctx, body)
89    }
90
91    fn doc_id_rule(&self) -> Option<DocIdRule> {
92        // Only the shared index needs a partition-scoped id; the dedicated modes
93        // isolate by cluster/index and leave the id (and the body) untouched.
94        match self.mode {
95            PlacementMode::SharedIndex => {
96                Some(DocIdRule::new(IdTemplate::new("{partition}:{body.id}")).with_routing(true))
97            }
98            PlacementMode::DedicatedIndex | PlacementMode::DedicatedCluster => None,
99        }
100    }
101
102    fn injected_fields(&self) -> Vec<InjectedField> {
103        // The injected isolation field exists only in the shared index; the
104        // dedicated modes inject nothing (no body rewrite).
105        match self.mode {
106            PlacementMode::SharedIndex => vec![InjectedField::new(
107                FieldName::from(TENANT_FIELD),
108                InjectedValue::PartitionId,
109            )],
110            PlacementMode::DedicatedIndex | PlacementMode::DedicatedCluster => Vec::new(),
111        }
112    }
113
114    // `sensitive_fields` is left at the deny-by-default `all_sensitive`: this
115    // tenancy carries real tenant payloads, so every value is redacted unless a
116    // future revision allow-lists a known-safe field.
117
118    fn cluster_endpoint(&self, cluster: &ClusterId) -> Option<String> {
119        // The cursor-affinity path routes by cluster id with no placement; resolve
120        // its endpoint here (this reference tenancy has exactly one cluster).
121        (cluster == &self.cluster).then(|| self.endpoint.clone())
122    }
123
124    async fn placement_for(&self, partition: &PartitionId) -> Result<PlacementAt, SpiError> {
125        // Every partition resolves to a placement of the configured mode. A
126        // constant epoch: this reference tenancy has no migration (the epoch story
127        // is exercised by the PlacementTable-backed implementations).
128        let placement = match self.mode {
129            // Shared: isolation is by the injected field + scoped id, so every
130            // partition can share the one physical index.
131            PlacementMode::SharedIndex => Placement::SharedIndex {
132                cluster: self.cluster.clone(),
133                index: self.index.clone(),
134                inject: self.injected_fields(),
135            },
136            // Dedicated index: isolation IS the index, so each partition must get a
137            // distinct physical index (`{index}-{partition}`) — a shared index here
138            // would put two tenants in one index with no isolation field.
139            PlacementMode::DedicatedIndex => Placement::DedicatedIndex {
140                cluster: self.cluster.clone(),
141                index: IndexName::from(format!("{}-{}", self.index.as_str(), partition.as_str())),
142            },
143            // Dedicated cluster: isolation is the cluster. This reference has a
144            // single cluster/endpoint, so every partition maps to it; a real
145            // multi-cluster tenancy would resolve `partition` to its own cluster.
146            PlacementMode::DedicatedCluster => Placement::DedicatedCluster {
147                cluster: self.cluster.clone(),
148            },
149        };
150        Ok(PlacementAt::new(placement, Epoch::new(1)).with_endpoint(self.endpoint.clone()))
151    }
152}