pub trait TenancySpi:
Send
+ Sync
+ 'static {
// Required methods
fn resolve_partition(
&self,
ctx: &RequestCtx<'_>,
body: BodyDoc<'_>,
) -> Result<PartitionId, SpiError>;
fn doc_id_rule(&self) -> Option<DocIdRule>;
fn injected_fields(&self) -> Vec<InjectedField>;
async fn placement_for(
&self,
partition: &PartitionId,
) -> Result<PlacementAt, SpiError>;
// Provided methods
fn sensitive_fields(&self) -> SensitivitySpec { ... }
async fn admit_write(&self, _partition: &PartitionId, _epoch: Epoch) -> bool { ... }
fn cluster_endpoint(&self, _cluster: &ClusterId) -> Option<String> { ... }
}Expand description
The tenancy-focused contract most implementers provide.
It declares tenancy rules, how to find the partition, how to build the
document _id, which fields to inject, which are sensitive, plus a
placement lookup. osproxy-tenancy turns this into a crate::RoutingSpi,
so tenancy implementers never touch crate::RouteDecision plumbing
(docs/02 §2).
§Invariants
TenancySpi::resolve_partitionMUST yield a partition id for every routable request, or it returnsSpiError::PartitionUnresolvedand the request is rejected.- In
SharedIndexmode the partition id MUST be part of the constructed_idto prevent cross-tenant id collisions (docs/03); the adapter enforces this. TenancySpi::injected_fieldsnames andTenancySpi::sensitive_fieldsMUST be stable for a given logical-index version, so the read-path strip/filter stays symmetric with the write-path inject.
§Examples
use osproxy_core::{ClusterId, Epoch, FieldName, IndexName, PartitionId};
use osproxy_spi::{
BodyDoc, InjectedField, InjectedValue, Placement, PlacementAt, PartitionKeySpecKind,
RequestCtx, SensitivitySpec, SpiError, TenancySpi,
};
struct OneTenantPerHeader;
impl TenancySpi for OneTenantPerHeader {
fn resolve_partition(&self, ctx: &RequestCtx<'_>, _body: BodyDoc<'_>)
-> Result<PartitionId, SpiError>
{
// Real impls usually defer to `osproxy_tenancy::resolve_partition_spec`;
// here we resolve inline to keep the SPI crate self-contained.
ctx.headers().get("x-tenant").map(PartitionId::from).ok_or(
SpiError::PartitionUnresolved { tried: vec![PartitionKeySpecKind::Header] })
}
fn doc_id_rule(&self) -> Option<osproxy_spi::DocIdRule> { None }
fn injected_fields(&self) -> Vec<InjectedField> {
vec![InjectedField::new(FieldName::from("_tenant"), InjectedValue::PartitionId)]
}
fn sensitive_fields(&self) -> SensitivitySpec { SensitivitySpec::none() }
async fn placement_for(&self, p: &PartitionId) -> Result<PlacementAt, SpiError> {
Ok(PlacementAt::new(
Placement::SharedIndex {
cluster: ClusterId::from("eu-1"),
index: IndexName::from("logs-shared"),
inject: self.injected_fields(),
},
Epoch::ZERO,
))
}
}Required Methods§
Sourcefn resolve_partition(
&self,
ctx: &RequestCtx<'_>,
body: BodyDoc<'_>,
) -> Result<PartitionId, SpiError>
fn resolve_partition( &self, ctx: &RequestCtx<'_>, body: BodyDoc<'_>, ) -> Result<PartitionId, SpiError>
Resolves the partition id for a request.
body is a BodyDoc view over the document: the whole request for
single-doc ingest, or one operation’s source line for _bulk. Read the
partition key from it with BodyDoc::scalar, the proxy scans the bytes
on demand, so no JSON tree is built (ADR-014).
Most implementations just defer to the declarative resolver
osproxy_tenancy::resolve_partition_spec, naming the source(s) the
partition id lives in (a body field, a header, a principal attribute):
fn resolve_partition(&self, ctx: &RequestCtx<'_>, body: BodyDoc<'_>)
-> Result<PartitionId, SpiError>
{
osproxy_tenancy::resolve_partition_spec(
&PartitionKeySpec::BodyField(JsonPath::new("tenant_id")), ctx, body)
}Compose BodyDoc::scalar with header/principal lookups for cases the
declarative sources cannot express, combining several inputs, decoding a
structured token, without ever parsing raw bytes yourself. You choose the
order; nothing is tried implicitly before you.
§Errors
Returns SpiError::PartitionUnresolved when no configured source yields a
partition id; the request is then rejected.
The no-value-leak rule holds (NFR-S2): whatever you decode here must not be logged. The id you return is treated as a partition id (an opaque routing key), never as a tenant value to capture.
Sourcefn doc_id_rule(&self) -> Option<DocIdRule>
fn doc_id_rule(&self) -> Option<DocIdRule>
Optional rule to construct the document _id (and _routing).
Sourcefn injected_fields(&self) -> Vec<InjectedField>
fn injected_fields(&self) -> Vec<InjectedField>
Fields injected on ingest and stripped on read. The field names are chosen here (the SPI decides them).
Sourceasync fn placement_for(
&self,
partition: &PartitionId,
) -> Result<PlacementAt, SpiError>
async fn placement_for( &self, partition: &PartitionId, ) -> Result<PlacementAt, SpiError>
Resolves a partition to its current placement and the epoch it was read at. NOT a pure function, migration mutates the placement state.
§Errors
Returns SpiError::PlacementMissing when the partition has no
placement, or SpiError::PlacementBackend when the lookup backend is
unavailable.
Provided Methods§
Sourcefn sensitive_fields(&self) -> SensitivitySpec
fn sensitive_fields(&self) -> SensitivitySpec
Declares which field values observability may capture, driving
value-suppression (NFR-S2). Deny-by-default: the standard implementation
returns SensitivitySpec::all_sensitive (everything redacted) and
allow-lists known-safe fields with SensitivitySpec::allowing. The
default here is all_sensitive, so a tenancy that does not override it
leaks nothing.
Sourceasync fn admit_write(&self, _partition: &PartitionId, _epoch: Epoch) -> bool
async fn admit_write(&self, _partition: &PartitionId, _epoch: Epoch) -> bool
The migration write gate (docs/06 §2): may a write that resolved at
epoch for partition still commit? Re-checked at dispatch, after the
decision was stamped, so a placement that advanced (or entered cutover) in
the meantime is caught. false means reject as a retryable stale-epoch
error; the client re-resolves against the new placement.
Defaults to always-admit: an implementation without live migration (a constant placement) never needs to hold a write.
Sourcefn cluster_endpoint(&self, _cluster: &ClusterId) -> Option<String>
fn cluster_endpoint(&self, _cluster: &ClusterId) -> Option<String>
The base URL of a cluster, by id. The data plane carries each cluster’s
endpoint on the placement result, but the cursor-affinity and admin
pass-through paths route to a cluster by id with no placement to consult,
so they resolve the endpoint through this lookup. Return None for an
unknown cluster; the request then fails closed rather than route blind.
Default None. A tenancy that runs cursor affinity or admin pass-through
against OpenSearchSink must implement it for the clusters those paths
reach (which is just its own cluster catalog by id).
Dyn Compatibility§
This trait is not dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".