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
58impl ReadTxnPolicy {
59    /// Builds an explicit read policy with validated parameters.
60    pub fn try_new(retry_limit: u32, time_out: Duration) -> Result<Self, TransactionPolicyError> {
61        validate_policy_limits(retry_limit, time_out)?;
62        Ok(Self {
63            retry_limit,
64            time_out,
65        })
66    }
67
68    /// Default policy for bounded, fast-failing reads.
69    pub const fn default() -> Self {
70        Self {
71            retry_limit: 2,
72            time_out: Duration::from_millis(500),
73        }
74    }
75
76    /// Returns the maximum total attempts, including the initial attempt.
77    pub const fn retry_limit(&self) -> u32 {
78        self.retry_limit
79    }
80
81    /// Returns the deadline for the complete transaction, including retries.
82    pub const fn time_out(&self) -> Duration {
83        self.time_out
84    }
85
86    /// Converts this policy to FoundationDB transact options.
87    pub const fn to_transact_option(self) -> foundationdb::TransactOption {
88        foundationdb::TransactOption {
89            retry_limit: Some(self.retry_limit),
90            time_out: Some(self.time_out),
91            is_idempotent: true,
92        }
93    }
94}
95
96impl WriteTxnPolicy {
97    /// Builds an explicit write policy with validated parameters.
98    pub fn try_new(retry_limit: u32, time_out: Duration) -> Result<Self, TransactionPolicyError> {
99        validate_policy_limits(retry_limit, time_out)?;
100        Ok(Self {
101            retry_limit,
102            time_out,
103        })
104    }
105
106    /// Default policy for mutations and commits.
107    pub const fn default() -> Self {
108        Self {
109            retry_limit: 3,
110            time_out: Duration::from_secs(5),
111        }
112    }
113
114    /// Returns the maximum total attempts, including the initial attempt.
115    pub const fn retry_limit(&self) -> u32 {
116        self.retry_limit
117    }
118
119    /// Returns the deadline for the complete transaction, including retries.
120    pub const fn time_out(&self) -> Duration {
121        self.time_out
122    }
123
124    /// Converts this policy to FoundationDB transact options.
125    pub const fn to_transact_option(self) -> foundationdb::TransactOption {
126        foundationdb::TransactOption {
127            retry_limit: Some(self.retry_limit),
128            time_out: Some(self.time_out),
129            is_idempotent: false,
130        }
131    }
132}
133
134/// Builds the default transaction policy used by idempotent read paths.
135///
136/// The `idempotent` flag is set so FoundationDB may safely retry retryable errors
137/// without risking duplicate side effects.
138pub fn idempotent_read_option() -> foundationdb::TransactOption {
139    ReadTxnPolicy::default().to_transact_option()
140}
141
142/// Builds the default transaction policy used by mutation paths.
143///
144/// The write policy is non-idempotent so a commit that reports
145/// `maybe_committed` is surfaced as non-retryable and can be handled only under
146/// caller-owned at-most-once invariants.
147///
148/// Cancellation invariants:
149/// - Write transactions must tolerate `TransactError` outcomes that arrive after
150///   the caller canceled the future and treat a canceled path as potentially
151///   uncertain if a commit may have reached the cluster.
152/// - If an operation can be retried safely by caller logic, it should use
153///   [`idempotent_read_option`] instead.
154pub fn mutation_option() -> foundationdb::TransactOption {
155    WriteTxnPolicy::default().to_transact_option()
156}
157
158fn validate_policy_limits(
159    retry_limit: u32,
160    time_out: Duration,
161) -> Result<(), TransactionPolicyError> {
162    if !(MIN_RETRY_LIMIT..=MAX_RETRY_LIMIT).contains(&retry_limit) {
163        return Err(TransactionPolicyError::InvalidRetryLimit {
164            min: MIN_RETRY_LIMIT,
165            max: MAX_RETRY_LIMIT,
166            value: retry_limit,
167        });
168    }
169
170    let time_out_ms = u64::try_from(time_out.as_millis()).map_err(|_| {
171        TransactionPolicyError::InvalidTimeout {
172            min_ms: MIN_TIMEOUT_MS,
173            max_ms: MAX_TIMEOUT_MS,
174            value_ms: MAX_TIMEOUT_MS.saturating_add(1),
175        }
176    })?;
177
178    if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&time_out_ms) {
179        return Err(TransactionPolicyError::InvalidTimeout {
180            min_ms: MIN_TIMEOUT_MS,
181            max_ms: MAX_TIMEOUT_MS,
182            value_ms: time_out_ms,
183        });
184    }
185
186    Ok(())
187}
188
189#[cfg(test)]
190#[path = "transaction_tests.rs"]
191mod tests;