Skip to main content

autopush_common/db/bigtable/
mod.rs

1/// This uses Google Cloud Platform (GCP) Bigtable as a storage and management
2/// system for Autopush Notifications and Routing information.
3///
4/// Bigtable has a single index key, and uses "cell family" designators to
5/// perform garbage collection.
6///
7/// Keys for the data are
8/// `{uaid}` - the meta data record around a given UAID record
9/// `{uaid}#{channelid}` - the meta record for a channel associated with a
10///     UAID
11/// `{uaid}#{channelid}#{sortkey_timestamp}` - a message record for a UAID
12///     and channel
13///
14/// Bigtable will automatically sort by the primary key. This schema uses
15/// regular expression lookups in order to do things like return the channels
16/// associated with a given UAID, fetch the appropriate topic messages, and
17/// other common functions. Please refer to the Bigtable documentation
18/// for how to create these keys, since they must be inclusive. Partial
19/// key matches will not return data. (e.g `/foo/` will not match `foobar`,
20/// but `/foo.*/` will)
21///
22mod bigtable_client;
23mod pool;
24
25pub use bigtable_client::BigTableClientImpl;
26pub use bigtable_client::error::BigTableError;
27
28use serde::Deserialize;
29use std::time::Duration;
30use tonic::metadata::MetadataMap;
31
32use crate::db::bigtable::bigtable_client::MetadataBuilder;
33use crate::db::error::DbError;
34use crate::util::{deserialize_opt_u32_to_duration, deserialize_u32_to_duration};
35
36const DEFAULT_GRPC_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
37const DEFAULT_GRPC_POINT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5);
38const DEFAULT_GRPC_SCAN_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20);
39const DEFAULT_GRPC_POINT_TOTAL_TIMEOUT: Duration = Duration::from_secs(15);
40// Kept strictly under the 30s GCP backend-service default `timeoutSec`, so a
41// stalled scan returns our own error before the load balancer gives up on the
42// request. At exactly 30s the two race and the client sees an LB 5xx instead,
43// which would also hide this budget from any p95 measured on inbound requests.
44const DEFAULT_GRPC_SCAN_TOTAL_TIMEOUT: Duration = Duration::from_secs(25);
45
46fn grpc_connect_timeout_default() -> Duration {
47    DEFAULT_GRPC_CONNECT_TIMEOUT
48}
49
50fn grpc_point_attempt_timeout_default() -> Duration {
51    DEFAULT_GRPC_POINT_ATTEMPT_TIMEOUT
52}
53
54fn grpc_scan_attempt_timeout_default() -> Duration {
55    DEFAULT_GRPC_SCAN_ATTEMPT_TIMEOUT
56}
57
58fn grpc_point_total_timeout_default() -> Duration {
59    DEFAULT_GRPC_POINT_TOTAL_TIMEOUT
60}
61
62fn grpc_scan_total_timeout_default() -> Duration {
63    DEFAULT_GRPC_SCAN_TOTAL_TIMEOUT
64}
65
66fn retry_default() -> usize {
67    bigtable_client::RETRY_COUNT
68}
69
70/// The settings for accessing the BigTable contents.
71#[derive(Clone, Debug, Deserialize)]
72pub struct BigTableDbSettings {
73    /// The Table name matches the GRPC template for table paths.
74    /// e.g. `projects/{projectid}/instances/{instanceid}/tables/{tablename}`
75    /// *NOTE* There is no leading `/`
76    /// By default, this (may?) use the `*` variant which translates to
77    /// `projects/*/instances/*/tables/*` which searches all data stored in
78    /// bigtable.
79    #[serde(default)]
80    pub table_name: String,
81    /// Routing replication profile id.
82    /// Should be used everywhere we set `table_name` when creating requests
83    #[serde(default)]
84    pub app_profile_id: String,
85    #[serde(default)]
86    pub router_family: String,
87    #[serde(default)]
88    pub message_family: String,
89    #[serde(default)]
90    pub message_topic_family: String,
91    #[serde(default)]
92    pub database_pool_max_size: Option<u32>,
93    /// Number of shared tonic channels used for Bigtable RPCs. Defaults to four.
94    /// Size it from peak concurrent operations for this workload on one pod,
95    /// rather than from the maximum logical operation-pool size. See
96    /// `DEFAULT_GRPC_CHANNEL_COUNT` for the arithmetic and its caveats.
97    #[serde(default)]
98    pub grpc_channel_count: Option<u32>,
99    /// Max time (in seconds) to create a pooled client handle.
100    #[serde(default)]
101    #[serde(deserialize_with = "deserialize_opt_u32_to_duration")]
102    pub database_pool_create_timeout: Option<Duration>,
103    /// Max time (in seconds) to wait for a logical operation slot.
104    #[serde(default)]
105    #[serde(deserialize_with = "deserialize_opt_u32_to_duration")]
106    pub database_pool_wait_timeout: Option<Duration>,
107    /// Max time (in seconds) for DNS, TCP, and TLS connection establishment.
108    #[serde(
109        default = "grpc_connect_timeout_default",
110        deserialize_with = "deserialize_u32_to_duration"
111    )]
112    pub grpc_connect_timeout: Duration,
113    /// Per-attempt deadline (in seconds) for point reads and writes.
114    #[serde(
115        default = "grpc_point_attempt_timeout_default",
116        deserialize_with = "deserialize_u32_to_duration"
117    )]
118    pub grpc_point_attempt_timeout: Duration,
119    /// Per-attempt deadline (in seconds) for message range scans.
120    #[serde(
121        default = "grpc_scan_attempt_timeout_default",
122        deserialize_with = "deserialize_u32_to_duration"
123    )]
124    pub grpc_scan_attempt_timeout: Duration,
125    /// End-to-end retry budget (in seconds) for point reads and writes.
126    #[serde(
127        default = "grpc_point_total_timeout_default",
128        deserialize_with = "deserialize_u32_to_duration"
129    )]
130    pub grpc_point_total_timeout: Duration,
131    /// End-to-end retry budget (in seconds) for message range scans.
132    #[serde(
133        default = "grpc_scan_total_timeout_default",
134        deserialize_with = "deserialize_u32_to_duration"
135    )]
136    pub grpc_scan_total_timeout: Duration,
137    /// Include route to leader header in metadata
138    #[serde(default)]
139    pub route_to_leader: bool,
140    /// Number of retries after the initial gRPC data operation. Defaults to
141    /// two. Health checks use this same configured value.
142    #[serde(default = "retry_default")]
143    pub retry_count: usize,
144    /// Max lifetime (in seconds) for a router entry
145    #[serde(default)]
146    #[serde(deserialize_with = "deserialize_opt_u32_to_duration")]
147    pub max_router_ttl: Option<Duration>,
148}
149
150// Used by test, but we don't want available for release.
151#[allow(clippy::derivable_impls)]
152#[cfg(test)]
153impl Default for BigTableDbSettings {
154    fn default() -> Self {
155        use crate::MAX_ROUTER_TTL_SECS;
156
157        Self {
158            table_name: Default::default(),
159            router_family: Default::default(),
160            message_family: Default::default(),
161            message_topic_family: Default::default(),
162            database_pool_max_size: Default::default(),
163            grpc_channel_count: Default::default(),
164            database_pool_create_timeout: Default::default(),
165            database_pool_wait_timeout: Default::default(),
166            grpc_connect_timeout: grpc_connect_timeout_default(),
167            grpc_point_attempt_timeout: grpc_point_attempt_timeout_default(),
168            grpc_scan_attempt_timeout: grpc_scan_attempt_timeout_default(),
169            grpc_point_total_timeout: grpc_point_total_timeout_default(),
170            grpc_scan_total_timeout: grpc_scan_total_timeout_default(),
171            route_to_leader: Default::default(),
172            retry_count: Default::default(),
173            app_profile_id: Default::default(),
174            max_router_ttl: Some(Duration::from_secs(MAX_ROUTER_TTL_SECS)),
175        }
176    }
177}
178
179impl BigTableDbSettings {
180    pub fn metadata(&self) -> Result<MetadataMap, BigTableError> {
181        MetadataBuilder::with_prefix(&self.table_name)
182            .routing_param("table_name", &self.table_name)
183            .route_to_leader(self.route_to_leader)
184            .build()
185    }
186
187    pub fn get_instance_name(&self) -> Result<String, BigTableError> {
188        let parts: Vec<&str> = self.table_name.split('/').collect();
189        if parts.len() < 4 || parts[0] != "projects" || parts[2] != "instances" {
190            return Err(BigTableError::Config(
191                "Invalid table name specified. Cannot parse instance".to_owned(),
192            ));
193        }
194        Ok(parts[0..4].join("/"))
195    }
196}
197
198impl TryFrom<&str> for BigTableDbSettings {
199    type Error = DbError;
200    fn try_from(setting_string: &str) -> Result<Self, Self::Error> {
201        let mut me: Self = serde_json::from_str(setting_string)
202            .map_err(|e| DbError::General(format!("Could not parse DdbSettings: {e:?}")))?;
203
204        if me.table_name.starts_with('/') {
205            return Err(DbError::ConnectionError(
206                "Table name path begins with a '/'".to_owned(),
207            ));
208        };
209
210        if me.grpc_channel_count == Some(0) {
211            return Err(DbError::ConnectionError(
212                "grpc_channel_count must be greater than zero".to_owned(),
213            ));
214        }
215
216        let nonzero_durations = [
217            ("grpc_connect_timeout", me.grpc_connect_timeout),
218            ("grpc_point_attempt_timeout", me.grpc_point_attempt_timeout),
219            ("grpc_scan_attempt_timeout", me.grpc_scan_attempt_timeout),
220            ("grpc_point_total_timeout", me.grpc_point_total_timeout),
221            ("grpc_scan_total_timeout", me.grpc_scan_total_timeout),
222        ];
223        if let Some((name, _)) = nonzero_durations
224            .into_iter()
225            .find(|(_, duration)| duration.is_zero())
226        {
227            return Err(DbError::ConnectionError(format!(
228                "{name} must be greater than zero"
229            )));
230        }
231        if me.grpc_point_attempt_timeout > me.grpc_point_total_timeout {
232            return Err(DbError::ConnectionError(
233                "grpc_point_attempt_timeout must not exceed grpc_point_total_timeout".to_owned(),
234            ));
235        }
236        if me.grpc_scan_attempt_timeout > me.grpc_scan_total_timeout {
237            return Err(DbError::ConnectionError(
238                "grpc_scan_attempt_timeout must not exceed grpc_scan_total_timeout".to_owned(),
239            ));
240        }
241
242        // specify the default string "default" if it's not specified.
243        // There's a small chance that this could be reported as "unspecified", so this
244        // removes that confusion.
245        if me.app_profile_id.is_empty() {
246            "default".clone_into(&mut me.app_profile_id);
247        }
248
249        Ok(me)
250    }
251}
252
253mod tests {
254
255    #[test]
256    fn test_settings_parse() -> Result<(), crate::db::error::DbError> {
257        let settings =
258            super::BigTableDbSettings::try_from("{\"database_pool_create_timeout\": 123}")?;
259        assert_eq!(
260            settings.database_pool_create_timeout,
261            Some(std::time::Duration::from_secs(123))
262        );
263        assert_eq!(settings.retry_count, 2);
264        assert_eq!(settings.grpc_channel_count, None);
265        assert_eq!(
266            settings.grpc_connect_timeout,
267            std::time::Duration::from_secs(5)
268        );
269        assert_eq!(
270            settings.grpc_point_attempt_timeout,
271            std::time::Duration::from_secs(5)
272        );
273        assert_eq!(
274            settings.grpc_scan_attempt_timeout,
275            std::time::Duration::from_secs(20)
276        );
277        assert_eq!(
278            settings.grpc_point_total_timeout,
279            std::time::Duration::from_secs(15)
280        );
281        assert_eq!(
282            settings.grpc_scan_total_timeout,
283            std::time::Duration::from_secs(25)
284        );
285        Ok(())
286    }
287
288    #[test]
289    fn test_zero_grpc_channel_count_is_rejected() {
290        let result = super::BigTableDbSettings::try_from("{\"grpc_channel_count\": 0}");
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_invalid_grpc_timeouts_are_rejected() {
296        assert!(
297            super::BigTableDbSettings::try_from(
298                "{\"grpc_point_attempt_timeout\": 6, \"grpc_point_total_timeout\": 5}"
299            )
300            .is_err()
301        );
302        assert!(
303            super::BigTableDbSettings::try_from(
304                "{\"grpc_scan_attempt_timeout\": 31, \"grpc_scan_total_timeout\": 30}"
305            )
306            .is_err()
307        );
308    }
309    #[test]
310    fn test_get_instance() -> Result<(), super::BigTableError> {
311        let settings = super::BigTableDbSettings {
312            table_name: "projects/foo/instances/bar/tables/gorp".to_owned(),
313            ..Default::default()
314        };
315        let res = settings.get_instance_name()?;
316        assert_eq!(res.as_str(), "projects/foo/instances/bar");
317
318        let settings = super::BigTableDbSettings {
319            table_name: "projects/foo/".to_owned(),
320            ..Default::default()
321        };
322        assert!(settings.get_instance_name().is_err());
323
324        let settings = super::BigTableDbSettings {
325            table_name: "protect/foo/instances/bar/tables/gorp".to_owned(),
326            ..Default::default()
327        };
328        assert!(settings.get_instance_name().is_err());
329
330        let settings = super::BigTableDbSettings {
331            table_name: "project/foo/instance/bar/tables/gorp".to_owned(),
332            ..Default::default()
333        };
334        assert!(settings.get_instance_name().is_err());
335
336        Ok(())
337    }
338}