mz_foundationdb/lib.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Common FoundationDB utilities for Materialize.
11//!
12//! This crate provides shared functionality for FoundationDB-backed
13//! implementations across Materialize, including network initialization
14//! and URL parsing utilities.
15
16use std::sync::Mutex;
17
18use foundationdb::api::NetworkAutoStop;
19use foundationdb::options::DatabaseOption;
20use mz_ore::url::SensitiveUrl;
21
22/// Re-export the `foundationdb` crate for convenience.
23pub use foundationdb::*;
24
25/// Default transaction timeout, in milliseconds, applied to a [`Database`] in
26/// tests via [`set_test_transaction_timeout`].
27///
28/// Chosen well below the test harness's 180s termination so that an unavailable
29/// or stalled server surfaces a `transaction_timed_out` error the test can
30/// report and retry on, rather than hanging until the harness kills the process.
31const TEST_TRANSACTION_TIMEOUT_MS: i32 = 60_000;
32
33/// Sets a default transaction timeout on `db` for use in tests.
34///
35/// Production code intentionally does not bound transactions here; that is a
36/// separate product decision. Tests call this so that a FoundationDB server that
37/// becomes unresponsive (for example under heavy parallel CI load) fails the
38/// affected operation promptly instead of blocking forever.
39pub fn set_test_transaction_timeout(db: &Database) {
40 db.set_option(DatabaseOption::TransactionTimeout(
41 TEST_TRANSACTION_TIMEOUT_MS,
42 ))
43 .expect("setting transaction timeout option");
44}
45
46/// FoundationDB network handle.
47/// The first element is `Some` if the network is initialized.
48/// The second element is `true` if the network has ever been initialized.
49static FDB_NETWORK: Mutex<(Option<NetworkAutoStop>, bool)> = Mutex::new((None, false));
50
51/// Initialize the FoundationDB network.
52///
53/// This function is safe to call multiple times - only the first call will
54/// actually initialize the network, subsequent calls return immediately.
55///
56/// The FoundationDB network can be booted once per process and can never be
57/// restarted: after [`shutdown_network()`], any subsequent call to this function
58/// panics.
59///
60/// Before the process exits, drop all `Database` and transaction handles and then
61/// call [`shutdown_network()`]. Skipping the shutdown can segfault the
62/// FoundationDB client during teardown; calling it while a handle is still alive
63/// can instead block on the network thread join.
64pub fn init_network() {
65 let mut guard = FDB_NETWORK.lock().expect("mutex poisoned");
66 if guard.0.is_none() {
67 if guard.1 {
68 panic!("attempted to re-initialize FoundationDB network after shutdown");
69 }
70 // SAFETY: The `foundationdb::boot()` call is unsafe because it must only
71 // be called once per process. We use a mutex to ensure this guarantee
72 // is upheld - subsequent calls to `init_network()` will see `guard.is_some()`
73 // and return early without calling `boot()` again.
74 guard.0 = Some(unsafe { boot() });
75 guard.1 = true;
76 }
77}
78
79/// Shut down the FoundationDB network.
80///
81/// Call this once, after dropping all `Database` and transaction handles, before
82/// the process exits. Not stopping the network can segfault the client during
83/// teardown. The network can never be restarted afterwards: any subsequent call
84/// to [`init_network()`] will panic. Stopping the network joins the network
85/// thread, which can block indefinitely if a handle or in-flight transaction is
86/// still alive.
87pub fn shutdown_network() {
88 let mut guard = FDB_NETWORK.lock().expect("mutex poisoned");
89 if guard.0.is_some() {
90 guard.0 = None;
91 }
92}
93
94/// Configuration parsed from a FoundationDB URL.
95///
96/// FoundationDB URLs have the format:
97/// `foundationdb:?prefix=<prefix>`
98///
99/// The cluster file is determined by FoundationDB's standard discovery mechanism:
100/// 1. The `FDB_CLUSTER_FILE` environment variable
101/// 2. The default path `/etc/foundationdb/fdb.cluster`
102///
103/// This ensures all components using FoundationDB connect to the same cluster.
104#[derive(Clone, Debug)]
105pub struct FdbConfig {
106 /// The prefix path components for the directory layer.
107 pub prefix: Vec<String>,
108}
109
110impl FdbConfig {
111 /// Parse a FoundationDB URL into configuration.
112 ///
113 /// # URL Format
114 ///
115 /// The URL format is: `foundationdb:?prefix=<prefix>`
116 ///
117 /// - The scheme must be `foundationdb`
118 /// - The `prefix` query parameter specifies the directory prefix to use,
119 /// with path components separated by `/`
120 ///
121 /// The cluster file is NOT specified in the URL. Instead, FoundationDB's
122 /// standard discovery mechanism is used (via `FDB_CLUSTER_FILE` env var
123 /// or the default `/etc/foundationdb/fdb.cluster`).
124 ///
125 /// # Examples
126 ///
127 /// ```ignore
128 /// // Use default cluster file with a prefix
129 /// let url = "foundationdb:?prefix=my_app/consensus";
130 /// ```
131 pub fn parse(url: &SensitiveUrl) -> Result<Self, anyhow::Error> {
132 let mut prefix = None;
133
134 let mut legacy_prefix = None;
135
136 for (key, value) in url.query_pairs() {
137 match &*key {
138 "prefix" => {
139 prefix = Some(value.split('/').map(|s| s.to_owned()).collect());
140 }
141 "options" => {
142 tracing::warn!(
143 "FoundationDB URL 'options' parameter is deprecated; use 'prefix' instead"
144 );
145 // Parse a string like `--search_path=<path>` to extract legacy prefix.
146 if let Some(stripped) = value.strip_prefix("--search_path=") {
147 legacy_prefix = Some(stripped.split('/').map(|s| s.to_owned()).collect());
148 } else {
149 anyhow::bail!("unrecognized FoundationDB URL options parameter: {value}");
150 }
151 }
152 key => {
153 anyhow::bail!("unrecognized FoundationDB URL query parameter: {key}={value}");
154 }
155 }
156 }
157
158 if prefix.is_some() && legacy_prefix.is_some() {
159 anyhow::bail!(
160 "cannot specify both 'prefix' and legacy 'options' parameters in FoundationDB URL"
161 );
162 }
163
164 Ok(FdbConfig {
165 prefix: prefix.or(legacy_prefix).unwrap_or_default(),
166 })
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 use std::str::FromStr;
175
176 #[mz_ore::test]
177 fn test_parse_url_with_prefix() {
178 let url = SensitiveUrl::from_str("foundationdb:?prefix=my_app/consensus").unwrap();
179 let config = FdbConfig::parse(&url).unwrap();
180 assert_eq!(config.prefix, vec!["my_app", "consensus"]);
181 }
182
183 #[mz_ore::test]
184 fn test_parse_url_with_nested_prefix() {
185 let url = SensitiveUrl::from_str("foundationdb:?prefix=a/b/c/d").unwrap();
186 let config = FdbConfig::parse(&url).unwrap();
187 assert_eq!(config.prefix, vec!["a", "b", "c", "d"]);
188 }
189
190 #[mz_ore::test]
191 fn test_parse_url_no_prefix() {
192 let url = SensitiveUrl::from_str("foundationdb:").unwrap();
193 let config = FdbConfig::parse(&url).unwrap();
194 assert!(config.prefix.is_empty());
195 }
196
197 #[mz_ore::test]
198 fn test_parse_url_invalid_query_param() {
199 let url = SensitiveUrl::from_str("foundationdb:?unknown=value").unwrap();
200 assert!(FdbConfig::parse(&url).is_err());
201 }
202}