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}