Skip to main content

reallyme_foundationdb_kit/fdb/tenant/
data.rs

1// SPDX-FileCopyrightText: 2026 ReallyMe LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Application key access inside a FoundationDB tenant.
5
6use std::num::NonZeroUsize;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use foundationdb::{
10    RangeOption, Transaction,
11    future::{FdbSlice, FdbValues},
12    options::{MutationType, TransactionOption},
13};
14use thiserror::Error;
15
16const RESERVED_METADATA_START: &[u8] = b"__meta";
17const RESERVED_METADATA_END: &[u8] = b"__metb";
18const MAX_TENANT_KEY_BYTES: usize = 10_000;
19const MAX_TENANT_VALUE_BYTES: usize = 100_000;
20const MAX_RANGE_RESULTS: usize = 64;
21const MAX_RANGE_TARGET_BYTES: usize = 1_000_000;
22const MAX_TRANSACTION_SIZE_LIMIT: i32 = 10_000_000;
23const VERSIONSTAMP_BYTES: usize = 10;
24const VERSIONSTAMP_OFFSET_BYTES: usize = 4;
25const MIN_STABLE_KEY_PREFIX_BYTES: usize = 6;
26
27/// A finite reason for rejecting an application key or range.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
29pub enum TenantDataAccessErrorReason {
30    /// Keys cannot be empty.
31    #[error("tenant data key is empty")]
32    EmptyKey,
33    /// FoundationDB cannot accept a key above its size limit.
34    #[error("tenant data key exceeds the size limit")]
35    KeyTooLong,
36    /// Kit metadata and FoundationDB system keys are outside application access.
37    #[error("tenant data key is reserved")]
38    ReservedKey,
39    /// FoundationDB cannot accept a value above its size limit.
40    #[error("tenant data value exceeds the size limit")]
41    ValueTooLong,
42    /// A range must have a non-empty, increasing interval.
43    #[error("tenant data range bounds are invalid")]
44    InvalidRange,
45    /// A range could read or clear the kit's metadata keys.
46    #[error("tenant data range overlaps reserved metadata")]
47    ReservedRange,
48    /// Range reads and transaction options must have bounded limits.
49    #[error("tenant data limit is invalid")]
50    InvalidRangeLimit,
51    /// A mutation is prohibited by the transaction policy or key boundary.
52    #[error("tenant data mutation is prohibited")]
53    KeyChangingMutation,
54}
55
56/// Validated cluster-side transaction byte ceiling.
57#[derive(Clone, Copy)]
58pub struct TenantTransactionSizeLimit(i32);
59
60impl TenantTransactionSizeLimit {
61    /// Validates the positive FoundationDB transaction size limit.
62    pub fn new(bytes: i32) -> Result<Self, TenantDataAccessErrorReason> {
63        if !(1..=MAX_TRANSACTION_SIZE_LIMIT).contains(&bytes) {
64            return Err(TenantDataAccessErrorReason::InvalidRangeLimit);
65        }
66        Ok(Self(bytes))
67    }
68}
69
70/// Validated maximum response bytes for one range request.
71#[derive(Clone, Copy)]
72pub struct TenantDataRangeTargetBytes(NonZeroUsize);
73
74impl TenantDataRangeTargetBytes {
75    /// Rejects unbounded or empty response targets.
76    pub fn new(bytes: usize) -> Result<Self, TenantDataAccessErrorReason> {
77        let bytes = NonZeroUsize::new(bytes)
78            .filter(|bytes| bytes.get() <= MAX_RANGE_TARGET_BYTES)
79            .ok_or(TenantDataAccessErrorReason::InvalidRangeLimit)?;
80        Ok(Self(bytes))
81    }
82}
83
84/// A borrowed application key validated against the kit's reserved namespace.
85#[derive(Clone, Copy)]
86pub struct TenantDataKey<'a> {
87    bytes: &'a [u8],
88}
89
90impl<'a> TenantDataKey<'a> {
91    /// Validates a tenant-relative key before it reaches a transaction.
92    pub fn new(bytes: &'a [u8]) -> Result<Self, TenantDataAccessErrorReason> {
93        if bytes.is_empty() {
94            return Err(TenantDataAccessErrorReason::EmptyKey);
95        }
96        if bytes.len() > MAX_TENANT_KEY_BYTES {
97            return Err(TenantDataAccessErrorReason::KeyTooLong);
98        }
99        if (RESERVED_METADATA_START..RESERVED_METADATA_END).contains(&bytes) || bytes[0] == 0xff {
100            return Err(TenantDataAccessErrorReason::ReservedKey);
101        }
102        Ok(Self { bytes })
103    }
104
105    /// Returns the validated tenant-relative bytes.
106    pub fn as_bytes(self) -> &'a [u8] {
107        self.bytes
108    }
109}
110
111/// A validated half-open range that cannot include metadata keys.
112pub struct TenantDataRange<'a> {
113    begin: TenantDataKey<'a>,
114    end: TenantDataKey<'a>,
115}
116
117impl<'a> TenantDataRange<'a> {
118    /// Checks both bounds and the complete interval between them.
119    pub fn new(begin: &'a [u8], end: &'a [u8]) -> Result<Self, TenantDataAccessErrorReason> {
120        let begin = TenantDataKey::new(begin)?;
121        let end = TenantDataKey::new(end)?;
122        if begin.as_bytes() >= end.as_bytes() {
123            return Err(TenantDataAccessErrorReason::InvalidRange);
124        }
125        if begin.as_bytes() < RESERVED_METADATA_END && end.as_bytes() > RESERVED_METADATA_START {
126            return Err(TenantDataAccessErrorReason::ReservedRange);
127        }
128        Ok(Self { begin, end })
129    }
130
131    /// Returns the validated inclusive lower bound.
132    pub fn begin(&self) -> &[u8] {
133        self.begin.as_bytes()
134    }
135
136    /// Returns the validated exclusive upper bound.
137    pub fn end(&self) -> &[u8] {
138        self.end.as_bytes()
139    }
140}
141
142/// A bounded number of key-value pairs to return from one range read.
143#[derive(Clone, Copy)]
144pub struct TenantDataRangeLimit(NonZeroUsize);
145
146impl TenantDataRangeLimit {
147    /// Largest page accepted by the tenant data capability.
148    pub const MAX: usize = MAX_RANGE_RESULTS;
149
150    /// Rejects zero and unbounded result counts.
151    pub fn new(value: usize) -> Result<Self, TenantDataAccessErrorReason> {
152        let value = NonZeroUsize::new(value)
153            .filter(|value| value.get() <= MAX_RANGE_RESULTS)
154            .ok_or(TenantDataAccessErrorReason::InvalidRangeLimit)?;
155        Ok(Self(value))
156    }
157
158    /// Returns the validated maximum result count.
159    pub fn get(self) -> usize {
160        self.0.get()
161    }
162}
163
164/// The application view of a tenant transaction.
165///
166/// This type intentionally exposes no raw transaction handle, commit operation,
167/// or option setters. Every key-bearing operation takes a validated data key or
168/// range so application callbacks cannot modify kit metadata.
169pub struct TenantDataTransaction<'a> {
170    inner: &'a Transaction,
171    writable: bool,
172    mutation_rejected: AtomicBool,
173}
174
175impl<'a> TenantDataTransaction<'a> {
176    pub(super) fn new(inner: &'a Transaction, writable: bool) -> Self {
177        Self {
178            inner,
179            writable,
180            mutation_rejected: AtomicBool::new(false),
181        }
182    }
183
184    pub(super) fn mutation_rejected(&self) -> bool {
185        self.mutation_rejected.load(Ordering::Relaxed)
186    }
187
188    fn require_write(&self) -> Result<(), TenantDataAccessErrorReason> {
189        if self.writable {
190            Ok(())
191        } else {
192            // Callbacks can ignore a method's Result. The retry adapter must
193            // still abort the transaction before it can commit.
194            self.mutation_rejected.store(true, Ordering::Relaxed);
195            Err(TenantDataAccessErrorReason::KeyChangingMutation)
196        }
197    }
198
199    /// Applies a validated cluster-side size ceiling before application operations.
200    pub fn set_size_limit(&self, limit: TenantTransactionSizeLimit) -> foundationdb::FdbResult<()> {
201        self.inner.set_option(TransactionOption::SizeLimit(limit.0))
202    }
203
204    /// Reads an application key, preserving FoundationDB's retryable error.
205    pub async fn get(
206        &self,
207        key: TenantDataKey<'_>,
208        snapshot: bool,
209    ) -> foundationdb::FdbResult<Option<FdbSlice>> {
210        self.inner.get(key.as_bytes(), snapshot).await
211    }
212
213    /// Writes a bounded value under an application key.
214    pub fn set(
215        &self,
216        key: TenantDataKey<'_>,
217        value: &[u8],
218    ) -> Result<(), TenantDataAccessErrorReason> {
219        self.require_write()?;
220        if value.len() > MAX_TENANT_VALUE_BYTES {
221            return Err(TenantDataAccessErrorReason::ValueTooLong);
222        }
223        self.inner.set(key.as_bytes(), value);
224        Ok(())
225    }
226
227    /// Removes one application key. A read-policy attempt aborts the enclosing
228    /// transaction even though this compatibility method returns no result.
229    pub fn clear(&self, key: TenantDataKey<'_>) {
230        let _ = self.try_clear(key);
231    }
232
233    /// Checks the write policy before removing one application key.
234    pub fn try_clear(&self, key: TenantDataKey<'_>) -> Result<(), TenantDataAccessErrorReason> {
235        self.require_write()?;
236        self.inner.clear(key.as_bytes());
237        Ok(())
238    }
239
240    /// Removes only the validated application interval. A read-policy attempt
241    /// aborts the enclosing transaction even though this method returns no result.
242    pub fn clear_range(&self, range: &TenantDataRange<'_>) {
243        let _ = self.try_clear_range(range);
244    }
245
246    /// Checks the write policy before clearing an application interval.
247    pub fn try_clear_range(
248        &self,
249        range: &TenantDataRange<'_>,
250    ) -> Result<(), TenantDataAccessErrorReason> {
251        self.require_write()?;
252        self.inner.clear_range(range.begin(), range.end());
253        Ok(())
254    }
255
256    /// Applies an atomic mutation to an application key.
257    pub fn atomic_op(
258        &self,
259        key: TenantDataKey<'_>,
260        parameter: &[u8],
261        mutation: MutationType,
262    ) -> Result<(), TenantDataAccessErrorReason> {
263        self.require_write()?;
264        validate_atomic_mutation(mutation)?;
265        if parameter.len() > MAX_TENANT_VALUE_BYTES {
266            return Err(TenantDataAccessErrorReason::ValueTooLong);
267        }
268        self.inner.atomic_op(key.as_bytes(), parameter, mutation);
269        Ok(())
270    }
271
272    /// Writes a tuple-layer versionstamped key whose stable prefix is tenant data.
273    ///
274    /// The incomplete versionstamp must lie after the first six key bytes so
275    /// commit-time substitution cannot move a key into kit metadata or system space.
276    pub fn set_versionstamped_key(
277        &self,
278        key_template: &[u8],
279        value: &[u8],
280    ) -> Result<(), TenantDataAccessErrorReason> {
281        self.require_write()?;
282        validate_versionstamped_key_template(key_template)?;
283        if value.len() > MAX_TENANT_VALUE_BYTES {
284            return Err(TenantDataAccessErrorReason::ValueTooLong);
285        }
286        self.inner
287            .atomic_op(key_template, value, MutationType::SetVersionstampedKey);
288        Ok(())
289    }
290
291    /// Reads one bounded page from an application range.
292    pub async fn get_range(
293        &self,
294        range: &TenantDataRange<'_>,
295        limit: TenantDataRangeLimit,
296        snapshot: bool,
297    ) -> foundationdb::FdbResult<FdbValues> {
298        self.get_range_with_target_bytes(range, limit, MAX_RANGE_TARGET_BYTES, snapshot)
299            .await
300    }
301
302    /// Reads a bounded page with an application-selected response byte target.
303    pub async fn get_range_with_target(
304        &self,
305        range: &TenantDataRange<'_>,
306        limit: TenantDataRangeLimit,
307        target_bytes: TenantDataRangeTargetBytes,
308        snapshot: bool,
309    ) -> foundationdb::FdbResult<FdbValues> {
310        self.get_range_with_target_bytes(range, limit, target_bytes.0.get(), snapshot)
311            .await
312    }
313
314    async fn get_range_with_target_bytes(
315        &self,
316        range: &TenantDataRange<'_>,
317        limit: TenantDataRangeLimit,
318        target_bytes: usize,
319        snapshot: bool,
320    ) -> foundationdb::FdbResult<FdbValues> {
321        let mut options = RangeOption::from((range.begin(), range.end()));
322        options.limit = Some(limit.get());
323        options.target_bytes = target_bytes;
324        self.inner.get_range(&options, 1, snapshot).await
325    }
326}
327
328fn validate_versionstamped_key_template(
329    key_template: &[u8],
330) -> Result<(), TenantDataAccessErrorReason> {
331    let key_bytes = key_template
332        .len()
333        .checked_sub(VERSIONSTAMP_OFFSET_BYTES)
334        .ok_or(TenantDataAccessErrorReason::KeyChangingMutation)?;
335    TenantDataKey::new(&key_template[..key_bytes])?;
336    let offset_bytes: [u8; VERSIONSTAMP_OFFSET_BYTES] = key_template[key_bytes..]
337        .try_into()
338        .map_err(|_| TenantDataAccessErrorReason::KeyChangingMutation)?;
339    let offset = usize::try_from(u32::from_le_bytes(offset_bytes))
340        .map_err(|_| TenantDataAccessErrorReason::KeyChangingMutation)?;
341    let end = offset
342        .checked_add(VERSIONSTAMP_BYTES)
343        .ok_or(TenantDataAccessErrorReason::KeyChangingMutation)?;
344    if offset < MIN_STABLE_KEY_PREFIX_BYTES
345        || end > key_bytes
346        || key_template[offset..end].iter().any(|byte| *byte != 0xff)
347    {
348        return Err(TenantDataAccessErrorReason::KeyChangingMutation);
349    }
350    Ok(())
351}
352
353fn validate_atomic_mutation(mutation: MutationType) -> Result<(), TenantDataAccessErrorReason> {
354    // Only reviewed value mutations may pass. This upstream enum is non-exhaustive:
355    // accepting a future variant by default could let it change a validated key.
356    match mutation {
357        MutationType::Add
358        | MutationType::And
359        | MutationType::BitAnd
360        | MutationType::Or
361        | MutationType::BitOr
362        | MutationType::Xor
363        | MutationType::BitXor
364        | MutationType::AppendIfFits
365        | MutationType::Max
366        | MutationType::Min
367        | MutationType::SetVersionstampedValue
368        | MutationType::ByteMin
369        | MutationType::ByteMax
370        | MutationType::CompareAndClear => Ok(()),
371        MutationType::SetVersionstampedKey => Err(TenantDataAccessErrorReason::KeyChangingMutation),
372        _ => Err(TenantDataAccessErrorReason::KeyChangingMutation),
373    }
374}
375
376#[cfg(test)]
377#[path = "data_tests.rs"]
378mod tests;