Skip to main content

reallyme_foundationdb_kit/fdb/
transaction.rs

1// SPDX-FileCopyrightText: 2026 ReallyMe LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Transaction policy helpers.
5
6use std::time::Duration;
7use thiserror::Error;
8
9/// Transaction policy construction that failed validation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
11pub enum TransactionPolicyError {
12    /// Retry budget must be within safe operating range.
13    #[error("retry limit must be between {min} and {max}")]
14    InvalidRetryLimit {
15        /// The minimum allowed retry attempts.
16        min: u32,
17        /// The maximum allowed retry attempts.
18        max: u32,
19        /// Caller-provided value.
20        value: u32,
21    },
22    /// Timeout must be in the configured policy window.
23    #[error("transaction timeout must be between {min_ms}ms and {max_ms}ms")]
24    InvalidTimeout {
25        /// Minimum timeout in milliseconds.
26        min_ms: u64,
27        /// Maximum timeout in milliseconds.
28        max_ms: u64,
29        /// Caller-provided timeout in milliseconds.
30        value_ms: u64,
31    },
32}
33
34const MIN_RETRY_LIMIT: u32 = 1;
35const MAX_RETRY_LIMIT: u32 = 16;
36const MIN_TIMEOUT_MS: u64 = 1;
37const MAX_TIMEOUT_MS: u64 = 60_000;
38
39/// Typed read-optimized transaction behavior.
40///
41/// Reads usually benefit from smaller deadlines and lower retry pressure.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct ReadTxnPolicy {
44    retry_limit: u32,
45    time_out: Duration,
46}
47
48/// Typed mutation-optimized transaction behavior.
49///
50/// Write paths should keep the higher timeout and retries needed for fan-out
51/// and conflict chains on contended keys.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct WriteTxnPolicy {
54    retry_limit: u32,
55    time_out: Duration,
56}
57
58/// Bounded transaction policy accepted by tenant operations.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TenantTransactionPolicy {
61    /// Read with a validated retry and timeout budget.
62    Read(ReadTxnPolicy),
63    /// Mutation with a validated retry and timeout budget.
64    Write(WriteTxnPolicy),
65}
66
67impl TenantTransactionPolicy {
68    pub(crate) const fn to_transact_option(self) -> foundationdb::TransactOption {
69        match self {
70            Self::Read(policy) => policy.to_transact_option(),
71            Self::Write(policy) => policy.to_transact_option(),
72        }
73    }
74}
75
76impl ReadTxnPolicy {
77    /// Builds an explicit read policy with validated parameters.
78    pub fn try_new(retry_limit: u32, time_out: Duration) -> Result<Self, TransactionPolicyError> {
79        validate_policy_limits(retry_limit, time_out)?;
80        Ok(Self {
81            retry_limit,
82            time_out,
83        })
84    }
85
86    /// Default policy for bounded, fast-failing reads.
87    pub const fn default() -> Self {
88        Self {
89            retry_limit: 2,
90            time_out: Duration::from_millis(500),
91        }
92    }
93
94    /// Returns the maximum total attempts, including the initial attempt.
95    pub const fn retry_limit(&self) -> u32 {
96        self.retry_limit
97    }
98
99    /// Returns the deadline for the complete transaction, including retries.
100    pub const fn time_out(&self) -> Duration {
101        self.time_out
102    }
103
104    /// Converts this policy to FoundationDB transact options.
105    pub const fn to_transact_option(self) -> foundationdb::TransactOption {
106        foundationdb::TransactOption {
107            retry_limit: Some(self.retry_limit),
108            time_out: Some(self.time_out),
109            is_idempotent: true,
110        }
111    }
112}
113
114impl WriteTxnPolicy {
115    /// Builds an explicit write policy with validated parameters.
116    pub fn try_new(retry_limit: u32, time_out: Duration) -> Result<Self, TransactionPolicyError> {
117        validate_policy_limits(retry_limit, time_out)?;
118        Ok(Self {
119            retry_limit,
120            time_out,
121        })
122    }
123
124    /// Default policy for mutations and commits.
125    pub const fn default() -> Self {
126        Self {
127            retry_limit: 3,
128            time_out: Duration::from_secs(5),
129        }
130    }
131
132    /// Returns the maximum total attempts, including the initial attempt.
133    pub const fn retry_limit(&self) -> u32 {
134        self.retry_limit
135    }
136
137    /// Returns the deadline for the complete transaction, including retries.
138    pub const fn time_out(&self) -> Duration {
139        self.time_out
140    }
141
142    /// Converts this policy to FoundationDB transact options.
143    pub const fn to_transact_option(self) -> foundationdb::TransactOption {
144        foundationdb::TransactOption {
145            retry_limit: Some(self.retry_limit),
146            time_out: Some(self.time_out),
147            is_idempotent: false,
148        }
149    }
150}
151
152/// Builds the default transaction policy used by idempotent read paths.
153///
154/// The `idempotent` flag is set so FoundationDB may safely retry retryable errors
155/// without risking duplicate side effects.
156pub fn idempotent_read_option() -> foundationdb::TransactOption {
157    ReadTxnPolicy::default().to_transact_option()
158}
159
160/// Builds the default transaction policy used by mutation paths.
161///
162/// The write policy is non-idempotent so a commit that reports
163/// `maybe_committed` is surfaced as non-retryable and can be handled only under
164/// caller-owned at-most-once invariants.
165///
166/// Cancellation invariants:
167/// - Write transactions must tolerate `TransactError` outcomes that arrive after
168///   the caller canceled the future and treat a canceled path as potentially
169///   uncertain if a commit may have reached the cluster.
170/// - If an operation can be retried safely by caller logic, it should use
171///   [`idempotent_read_option`] instead.
172pub fn mutation_option() -> foundationdb::TransactOption {
173    WriteTxnPolicy::default().to_transact_option()
174}
175
176fn validate_policy_limits(
177    retry_limit: u32,
178    time_out: Duration,
179) -> Result<(), TransactionPolicyError> {
180    if !(MIN_RETRY_LIMIT..=MAX_RETRY_LIMIT).contains(&retry_limit) {
181        return Err(TransactionPolicyError::InvalidRetryLimit {
182            min: MIN_RETRY_LIMIT,
183            max: MAX_RETRY_LIMIT,
184            value: retry_limit,
185        });
186    }
187
188    let time_out_ms = u64::try_from(time_out.as_millis()).map_err(|_| {
189        TransactionPolicyError::InvalidTimeout {
190            min_ms: MIN_TIMEOUT_MS,
191            max_ms: MAX_TIMEOUT_MS,
192            value_ms: MAX_TIMEOUT_MS.saturating_add(1),
193        }
194    })?;
195
196    if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&time_out_ms) {
197        return Err(TransactionPolicyError::InvalidTimeout {
198            min_ms: MIN_TIMEOUT_MS,
199            max_ms: MAX_TIMEOUT_MS,
200            value_ms: time_out_ms,
201        });
202    }
203
204    Ok(())
205}
206
207#[cfg(test)]
208#[path = "transaction_tests.rs"]
209mod tests;