Line data Source code
1 : //! Functions for handling per-tenant configuration options
2 : //!
3 : //! If tenant is created with --config option,
4 : //! the tenant-specific config will be stored in tenant's directory.
5 : //! Otherwise, global pageserver's config is used.
6 : //!
7 : //! If the tenant config file is corrupted, the tenant will be disabled.
8 : //! We cannot use global or default config instead, because wrong settings
9 : //! may lead to a data loss.
10 : //!
11 : use anyhow::bail;
12 : pub(crate) use pageserver_api::config::TenantConfigToml as TenantConf;
13 : use pageserver_api::models::AuxFilePolicy;
14 : use pageserver_api::models::CompactionAlgorithmSettings;
15 : use pageserver_api::models::EvictionPolicy;
16 : use pageserver_api::models::{self, ThrottleConfig};
17 : use pageserver_api::shard::{ShardCount, ShardIdentity, ShardNumber, ShardStripeSize};
18 : use serde::de::IntoDeserializer;
19 : use serde::{Deserialize, Serialize};
20 : use serde_json::Value;
21 : use std::num::NonZeroU64;
22 : use std::time::Duration;
23 : use utils::generation::Generation;
24 :
25 0 : #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
26 : pub(crate) enum AttachmentMode {
27 : /// Our generation is current as far as we know, and as far as we know we are the only attached
28 : /// pageserver. This is the "normal" attachment mode.
29 : Single,
30 : /// Our generation number is current as far as we know, but we are advised that another
31 : /// pageserver is still attached, and therefore to avoid executing deletions. This is
32 : /// the attachment mode of a pagesever that is the destination of a migration.
33 : Multi,
34 : /// Our generation number is superseded, or about to be superseded. We are advised
35 : /// to avoid remote storage writes if possible, and to avoid sending billing data. This
36 : /// is the attachment mode of a pageserver that is the origin of a migration.
37 : Stale,
38 : }
39 :
40 0 : #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
41 : pub(crate) struct AttachedLocationConfig {
42 : pub(crate) generation: Generation,
43 : pub(crate) attach_mode: AttachmentMode,
44 : // TODO: add a flag to override AttachmentMode's policies under
45 : // disk pressure (i.e. unblock uploads under disk pressure in Stale
46 : // state, unblock deletions after timeout in Multi state)
47 : }
48 :
49 0 : #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50 : pub(crate) struct SecondaryLocationConfig {
51 : /// If true, keep the local cache warm by polling remote storage
52 : pub(crate) warm: bool,
53 : }
54 :
55 0 : #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56 : pub(crate) enum LocationMode {
57 : Attached(AttachedLocationConfig),
58 : Secondary(SecondaryLocationConfig),
59 : }
60 :
61 : /// Per-tenant, per-pageserver configuration. All pageservers use the same TenantConf,
62 : /// but have distinct LocationConf.
63 0 : #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
64 : pub(crate) struct LocationConf {
65 : /// The location-specific part of the configuration, describes the operating
66 : /// mode of this pageserver for this tenant.
67 : pub(crate) mode: LocationMode,
68 :
69 : /// The detailed shard identity. This structure is already scoped within
70 : /// a TenantShardId, but we need the full ShardIdentity to enable calculating
71 : /// key->shard mappings.
72 : #[serde(default = "ShardIdentity::unsharded")]
73 : #[serde(skip_serializing_if = "ShardIdentity::is_unsharded")]
74 : pub(crate) shard: ShardIdentity,
75 :
76 : /// The pan-cluster tenant configuration, the same on all locations
77 : pub(crate) tenant_conf: TenantConfOpt,
78 : }
79 :
80 : impl std::fmt::Debug for LocationConf {
81 0 : fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 0 : match &self.mode {
83 0 : LocationMode::Attached(conf) => {
84 0 : write!(
85 0 : f,
86 0 : "Attached {:?}, gen={:?}",
87 0 : conf.attach_mode, conf.generation
88 0 : )
89 : }
90 0 : LocationMode::Secondary(conf) => {
91 0 : write!(f, "Secondary, warm={}", conf.warm)
92 : }
93 : }
94 0 : }
95 : }
96 :
97 : impl AttachedLocationConfig {
98 : /// Consult attachment mode to determine whether we are currently permitted
99 : /// to delete layers. This is only advisory, not required for data safety.
100 : /// See [`AttachmentMode`] for more context.
101 2262 : pub(crate) fn may_delete_layers_hint(&self) -> bool {
102 2262 : // TODO: add an override for disk pressure in AttachedLocationConfig,
103 2262 : // and respect it here.
104 2262 : match &self.attach_mode {
105 2262 : AttachmentMode::Single => true,
106 : AttachmentMode::Multi | AttachmentMode::Stale => {
107 : // In Multi mode we avoid doing deletions because some other
108 : // attached pageserver might get 404 while trying to read
109 : // a layer we delete which is still referenced in their metadata.
110 : //
111 : // In Stale mode, we avoid doing deletions because we expect
112 : // that they would ultimately fail validation in the deletion
113 : // queue due to our stale generation.
114 0 : false
115 : }
116 : }
117 2262 : }
118 :
119 : /// Whether we are currently hinted that it is worthwhile to upload layers.
120 : /// This is only advisory, not required for data safety.
121 : /// See [`AttachmentMode`] for more context.
122 0 : pub(crate) fn may_upload_layers_hint(&self) -> bool {
123 0 : // TODO: add an override for disk pressure in AttachedLocationConfig,
124 0 : // and respect it here.
125 0 : match &self.attach_mode {
126 0 : AttachmentMode::Single | AttachmentMode::Multi => true,
127 : AttachmentMode::Stale => {
128 : // In Stale mode, we avoid doing uploads because we expect that
129 : // our replacement pageserver will already have started its own
130 : // IndexPart that will never reference layers we upload: it is
131 : // wasteful.
132 0 : false
133 : }
134 : }
135 0 : }
136 : }
137 :
138 : impl LocationConf {
139 : /// For use when loading from a legacy configuration: presence of a tenant
140 : /// implies it is in AttachmentMode::Single, which used to be the only
141 : /// possible state. This function should eventually be removed.
142 594 : pub(crate) fn attached_single(
143 594 : tenant_conf: TenantConfOpt,
144 594 : generation: Generation,
145 594 : shard_params: &models::ShardParameters,
146 594 : ) -> Self {
147 594 : Self {
148 594 : mode: LocationMode::Attached(AttachedLocationConfig {
149 594 : generation,
150 594 : attach_mode: AttachmentMode::Single,
151 594 : }),
152 594 : shard: ShardIdentity::from_params(ShardNumber(0), shard_params),
153 594 : tenant_conf,
154 594 : }
155 594 : }
156 :
157 : /// For use when attaching/re-attaching: update the generation stored in this
158 : /// structure. If we were in a secondary state, promote to attached (posession
159 : /// of a fresh generation implies this).
160 0 : pub(crate) fn attach_in_generation(&mut self, mode: AttachmentMode, generation: Generation) {
161 0 : match &mut self.mode {
162 0 : LocationMode::Attached(attach_conf) => {
163 0 : attach_conf.generation = generation;
164 0 : attach_conf.attach_mode = mode;
165 0 : }
166 : LocationMode::Secondary(_) => {
167 : // We are promoted to attached by the control plane's re-attach response
168 0 : self.mode = LocationMode::Attached(AttachedLocationConfig {
169 0 : generation,
170 0 : attach_mode: mode,
171 0 : })
172 : }
173 : }
174 0 : }
175 :
176 0 : pub(crate) fn try_from(conf: &'_ models::LocationConfig) -> anyhow::Result<Self> {
177 0 : let tenant_conf = TenantConfOpt::try_from(&conf.tenant_conf)?;
178 :
179 0 : fn get_generation(conf: &'_ models::LocationConfig) -> Result<Generation, anyhow::Error> {
180 0 : conf.generation
181 0 : .map(Generation::new)
182 0 : .ok_or_else(|| anyhow::anyhow!("Generation must be set when attaching"))
183 0 : }
184 :
185 0 : let mode = match &conf.mode {
186 : models::LocationConfigMode::AttachedMulti => {
187 : LocationMode::Attached(AttachedLocationConfig {
188 0 : generation: get_generation(conf)?,
189 0 : attach_mode: AttachmentMode::Multi,
190 : })
191 : }
192 : models::LocationConfigMode::AttachedSingle => {
193 : LocationMode::Attached(AttachedLocationConfig {
194 0 : generation: get_generation(conf)?,
195 0 : attach_mode: AttachmentMode::Single,
196 : })
197 : }
198 : models::LocationConfigMode::AttachedStale => {
199 : LocationMode::Attached(AttachedLocationConfig {
200 0 : generation: get_generation(conf)?,
201 0 : attach_mode: AttachmentMode::Stale,
202 : })
203 : }
204 : models::LocationConfigMode::Secondary => {
205 0 : anyhow::ensure!(conf.generation.is_none());
206 :
207 0 : let warm = conf
208 0 : .secondary_conf
209 0 : .as_ref()
210 0 : .map(|c| c.warm)
211 0 : .unwrap_or(false);
212 0 : LocationMode::Secondary(SecondaryLocationConfig { warm })
213 : }
214 : models::LocationConfigMode::Detached => {
215 : // Should not have been called: API code should translate this mode
216 : // into a detach rather than trying to decode it as a LocationConf
217 0 : return Err(anyhow::anyhow!("Cannot decode a Detached configuration"));
218 : }
219 : };
220 :
221 0 : let shard = if conf.shard_count == 0 {
222 0 : ShardIdentity::unsharded()
223 : } else {
224 0 : ShardIdentity::new(
225 0 : ShardNumber(conf.shard_number),
226 0 : ShardCount::new(conf.shard_count),
227 0 : ShardStripeSize(conf.shard_stripe_size),
228 0 : )?
229 : };
230 :
231 0 : Ok(Self {
232 0 : shard,
233 0 : mode,
234 0 : tenant_conf,
235 0 : })
236 0 : }
237 : }
238 :
239 : impl Default for LocationConf {
240 : // TODO: this should be removed once tenant loading can guarantee that we are never
241 : // loading from a directory without a configuration.
242 : // => tech debt since https://github.com/neondatabase/neon/issues/1555
243 0 : fn default() -> Self {
244 0 : Self {
245 0 : mode: LocationMode::Attached(AttachedLocationConfig {
246 0 : generation: Generation::none(),
247 0 : attach_mode: AttachmentMode::Single,
248 0 : }),
249 0 : tenant_conf: TenantConfOpt::default(),
250 0 : shard: ShardIdentity::unsharded(),
251 0 : }
252 0 : }
253 : }
254 :
255 : /// Same as TenantConf, but this struct preserves the information about
256 : /// which parameters are set and which are not.
257 360 : #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
258 : pub struct TenantConfOpt {
259 : #[serde(skip_serializing_if = "Option::is_none")]
260 : #[serde(default)]
261 : pub checkpoint_distance: Option<u64>,
262 :
263 : #[serde(skip_serializing_if = "Option::is_none")]
264 : #[serde(with = "humantime_serde")]
265 : #[serde(default)]
266 : pub checkpoint_timeout: Option<Duration>,
267 :
268 : #[serde(skip_serializing_if = "Option::is_none")]
269 : #[serde(default)]
270 : pub compaction_target_size: Option<u64>,
271 :
272 : #[serde(skip_serializing_if = "Option::is_none")]
273 : #[serde(with = "humantime_serde")]
274 : #[serde(default)]
275 : pub compaction_period: Option<Duration>,
276 :
277 : #[serde(skip_serializing_if = "Option::is_none")]
278 : #[serde(default)]
279 : pub compaction_threshold: Option<usize>,
280 :
281 : #[serde(skip_serializing_if = "Option::is_none")]
282 : #[serde(default)]
283 : pub compaction_algorithm: Option<CompactionAlgorithmSettings>,
284 :
285 : #[serde(skip_serializing_if = "Option::is_none")]
286 : #[serde(default)]
287 : pub gc_horizon: Option<u64>,
288 :
289 : #[serde(skip_serializing_if = "Option::is_none")]
290 : #[serde(with = "humantime_serde")]
291 : #[serde(default)]
292 : pub gc_period: Option<Duration>,
293 :
294 : #[serde(skip_serializing_if = "Option::is_none")]
295 : #[serde(default)]
296 : pub image_creation_threshold: Option<usize>,
297 :
298 : #[serde(skip_serializing_if = "Option::is_none")]
299 : #[serde(with = "humantime_serde")]
300 : #[serde(default)]
301 : pub pitr_interval: Option<Duration>,
302 :
303 : #[serde(skip_serializing_if = "Option::is_none")]
304 : #[serde(with = "humantime_serde")]
305 : #[serde(default)]
306 : pub walreceiver_connect_timeout: Option<Duration>,
307 :
308 : #[serde(skip_serializing_if = "Option::is_none")]
309 : #[serde(with = "humantime_serde")]
310 : #[serde(default)]
311 : pub lagging_wal_timeout: Option<Duration>,
312 :
313 : #[serde(skip_serializing_if = "Option::is_none")]
314 : #[serde(default)]
315 : pub max_lsn_wal_lag: Option<NonZeroU64>,
316 :
317 : #[serde(skip_serializing_if = "Option::is_none")]
318 : #[serde(default)]
319 : pub eviction_policy: Option<EvictionPolicy>,
320 :
321 : #[serde(skip_serializing_if = "Option::is_none")]
322 : #[serde(default)]
323 : pub min_resident_size_override: Option<u64>,
324 :
325 : #[serde(skip_serializing_if = "Option::is_none")]
326 : #[serde(with = "humantime_serde")]
327 : #[serde(default)]
328 : pub evictions_low_residence_duration_metric_threshold: Option<Duration>,
329 :
330 : #[serde(skip_serializing_if = "Option::is_none")]
331 : #[serde(with = "humantime_serde")]
332 : #[serde(default)]
333 : pub heatmap_period: Option<Duration>,
334 :
335 : #[serde(skip_serializing_if = "Option::is_none")]
336 : #[serde(default)]
337 : pub lazy_slru_download: Option<bool>,
338 :
339 : #[serde(skip_serializing_if = "Option::is_none")]
340 : pub timeline_get_throttle: Option<pageserver_api::models::ThrottleConfig>,
341 :
342 : #[serde(skip_serializing_if = "Option::is_none")]
343 : pub image_layer_creation_check_threshold: Option<u8>,
344 :
345 : #[serde(skip_serializing_if = "Option::is_none")]
346 : #[serde(default)]
347 : pub switch_aux_file_policy: Option<AuxFilePolicy>,
348 :
349 : #[serde(skip_serializing_if = "Option::is_none")]
350 : #[serde(with = "humantime_serde")]
351 : #[serde(default)]
352 : pub lsn_lease_length: Option<Duration>,
353 :
354 : #[serde(skip_serializing_if = "Option::is_none")]
355 : #[serde(with = "humantime_serde")]
356 : #[serde(default)]
357 : pub lsn_lease_length_for_ts: Option<Duration>,
358 : }
359 :
360 : impl TenantConfOpt {
361 0 : pub fn merge(&self, global_conf: TenantConf) -> TenantConf {
362 0 : TenantConf {
363 0 : checkpoint_distance: self
364 0 : .checkpoint_distance
365 0 : .unwrap_or(global_conf.checkpoint_distance),
366 0 : checkpoint_timeout: self
367 0 : .checkpoint_timeout
368 0 : .unwrap_or(global_conf.checkpoint_timeout),
369 0 : compaction_target_size: self
370 0 : .compaction_target_size
371 0 : .unwrap_or(global_conf.compaction_target_size),
372 0 : compaction_period: self
373 0 : .compaction_period
374 0 : .unwrap_or(global_conf.compaction_period),
375 0 : compaction_threshold: self
376 0 : .compaction_threshold
377 0 : .unwrap_or(global_conf.compaction_threshold),
378 0 : compaction_algorithm: self
379 0 : .compaction_algorithm
380 0 : .as_ref()
381 0 : .unwrap_or(&global_conf.compaction_algorithm)
382 0 : .clone(),
383 0 : gc_horizon: self.gc_horizon.unwrap_or(global_conf.gc_horizon),
384 0 : gc_period: self.gc_period.unwrap_or(global_conf.gc_period),
385 0 : image_creation_threshold: self
386 0 : .image_creation_threshold
387 0 : .unwrap_or(global_conf.image_creation_threshold),
388 0 : pitr_interval: self.pitr_interval.unwrap_or(global_conf.pitr_interval),
389 0 : walreceiver_connect_timeout: self
390 0 : .walreceiver_connect_timeout
391 0 : .unwrap_or(global_conf.walreceiver_connect_timeout),
392 0 : lagging_wal_timeout: self
393 0 : .lagging_wal_timeout
394 0 : .unwrap_or(global_conf.lagging_wal_timeout),
395 0 : max_lsn_wal_lag: self.max_lsn_wal_lag.unwrap_or(global_conf.max_lsn_wal_lag),
396 0 : eviction_policy: self.eviction_policy.unwrap_or(global_conf.eviction_policy),
397 0 : min_resident_size_override: self
398 0 : .min_resident_size_override
399 0 : .or(global_conf.min_resident_size_override),
400 0 : evictions_low_residence_duration_metric_threshold: self
401 0 : .evictions_low_residence_duration_metric_threshold
402 0 : .unwrap_or(global_conf.evictions_low_residence_duration_metric_threshold),
403 0 : heatmap_period: self.heatmap_period.unwrap_or(global_conf.heatmap_period),
404 0 : lazy_slru_download: self
405 0 : .lazy_slru_download
406 0 : .unwrap_or(global_conf.lazy_slru_download),
407 0 : timeline_get_throttle: self
408 0 : .timeline_get_throttle
409 0 : .clone()
410 0 : .unwrap_or(global_conf.timeline_get_throttle),
411 0 : image_layer_creation_check_threshold: self
412 0 : .image_layer_creation_check_threshold
413 0 : .unwrap_or(global_conf.image_layer_creation_check_threshold),
414 0 : switch_aux_file_policy: self
415 0 : .switch_aux_file_policy
416 0 : .unwrap_or(global_conf.switch_aux_file_policy),
417 0 : lsn_lease_length: self
418 0 : .lsn_lease_length
419 0 : .unwrap_or(global_conf.lsn_lease_length),
420 0 : lsn_lease_length_for_ts: self
421 0 : .lsn_lease_length_for_ts
422 0 : .unwrap_or(global_conf.lsn_lease_length_for_ts),
423 0 : }
424 0 : }
425 : }
426 :
427 : impl TryFrom<&'_ models::TenantConfig> for TenantConfOpt {
428 : type Error = anyhow::Error;
429 :
430 12 : fn try_from(request_data: &'_ models::TenantConfig) -> Result<Self, Self::Error> {
431 : // Convert the request_data to a JSON Value
432 12 : let json_value: Value = serde_json::to_value(request_data)?;
433 :
434 : // Create a Deserializer from the JSON Value
435 12 : let deserializer = json_value.into_deserializer();
436 :
437 : // Use serde_path_to_error to deserialize the JSON Value into TenantConfOpt
438 12 : let tenant_conf: TenantConfOpt = serde_path_to_error::deserialize(deserializer)?;
439 :
440 6 : Ok(tenant_conf)
441 12 : }
442 : }
443 :
444 : impl TryFrom<toml_edit::Item> for TenantConfOpt {
445 : type Error = anyhow::Error;
446 :
447 0 : fn try_from(item: toml_edit::Item) -> Result<Self, Self::Error> {
448 0 : match item {
449 0 : toml_edit::Item::Value(value) => {
450 0 : let d = value.into_deserializer();
451 0 : return serde_path_to_error::deserialize(d)
452 0 : .map_err(|e| anyhow::anyhow!("{}: {}", e.path(), e.inner().message()));
453 : }
454 0 : toml_edit::Item::Table(table) => {
455 0 : let deserializer =
456 0 : toml_edit::de::Deserializer::from(toml_edit::DocumentMut::from(table));
457 0 : return serde_path_to_error::deserialize(deserializer)
458 0 : .map_err(|e| anyhow::anyhow!("{}: {}", e.path(), e.inner().message()));
459 : }
460 : _ => {
461 0 : bail!("expected non-inline table but found {item}")
462 : }
463 : }
464 0 : }
465 : }
466 :
467 : /// This is a conversion from our internal tenant config object to the one used
468 : /// in external APIs.
469 : impl From<TenantConfOpt> for models::TenantConfig {
470 0 : fn from(value: TenantConfOpt) -> Self {
471 0 : fn humantime(d: Duration) -> String {
472 0 : format!("{}s", d.as_secs())
473 0 : }
474 0 : Self {
475 0 : checkpoint_distance: value.checkpoint_distance,
476 0 : checkpoint_timeout: value.checkpoint_timeout.map(humantime),
477 0 : compaction_algorithm: value.compaction_algorithm,
478 0 : compaction_target_size: value.compaction_target_size,
479 0 : compaction_period: value.compaction_period.map(humantime),
480 0 : compaction_threshold: value.compaction_threshold,
481 0 : gc_horizon: value.gc_horizon,
482 0 : gc_period: value.gc_period.map(humantime),
483 0 : image_creation_threshold: value.image_creation_threshold,
484 0 : pitr_interval: value.pitr_interval.map(humantime),
485 0 : walreceiver_connect_timeout: value.walreceiver_connect_timeout.map(humantime),
486 0 : lagging_wal_timeout: value.lagging_wal_timeout.map(humantime),
487 0 : max_lsn_wal_lag: value.max_lsn_wal_lag,
488 0 : eviction_policy: value.eviction_policy,
489 0 : min_resident_size_override: value.min_resident_size_override,
490 0 : evictions_low_residence_duration_metric_threshold: value
491 0 : .evictions_low_residence_duration_metric_threshold
492 0 : .map(humantime),
493 0 : heatmap_period: value.heatmap_period.map(humantime),
494 0 : lazy_slru_download: value.lazy_slru_download,
495 0 : timeline_get_throttle: value.timeline_get_throttle.map(ThrottleConfig::from),
496 0 : image_layer_creation_check_threshold: value.image_layer_creation_check_threshold,
497 0 : switch_aux_file_policy: value.switch_aux_file_policy,
498 0 : lsn_lease_length: value.lsn_lease_length.map(humantime),
499 0 : lsn_lease_length_for_ts: value.lsn_lease_length_for_ts.map(humantime),
500 0 : }
501 0 : }
502 : }
503 :
504 : #[cfg(test)]
505 : mod tests {
506 : use super::*;
507 : use models::TenantConfig;
508 :
509 : #[test]
510 6 : fn de_serializing_pageserver_config_omits_empty_values() {
511 6 : let small_conf = TenantConfOpt {
512 6 : gc_horizon: Some(42),
513 6 : ..TenantConfOpt::default()
514 6 : };
515 6 :
516 6 : let toml_form = toml_edit::ser::to_string(&small_conf).unwrap();
517 6 : assert_eq!(toml_form, "gc_horizon = 42\n");
518 6 : assert_eq!(small_conf, toml_edit::de::from_str(&toml_form).unwrap());
519 :
520 6 : let json_form = serde_json::to_string(&small_conf).unwrap();
521 6 : assert_eq!(json_form, "{\"gc_horizon\":42}");
522 6 : assert_eq!(small_conf, serde_json::from_str(&json_form).unwrap());
523 6 : }
524 :
525 : #[test]
526 6 : fn test_try_from_models_tenant_config_err() {
527 6 : let tenant_config = models::TenantConfig {
528 6 : lagging_wal_timeout: Some("5a".to_string()),
529 6 : ..TenantConfig::default()
530 6 : };
531 6 :
532 6 : let tenant_conf_opt = TenantConfOpt::try_from(&tenant_config);
533 6 :
534 6 : assert!(
535 6 : tenant_conf_opt.is_err(),
536 0 : "Suceeded to convert TenantConfig to TenantConfOpt"
537 : );
538 :
539 6 : let expected_error_str =
540 6 : "lagging_wal_timeout: invalid value: string \"5a\", expected a duration";
541 6 : assert_eq!(tenant_conf_opt.unwrap_err().to_string(), expected_error_str);
542 6 : }
543 :
544 : #[test]
545 6 : fn test_try_from_models_tenant_config_success() {
546 6 : let tenant_config = models::TenantConfig {
547 6 : lagging_wal_timeout: Some("5s".to_string()),
548 6 : ..TenantConfig::default()
549 6 : };
550 6 :
551 6 : let tenant_conf_opt = TenantConfOpt::try_from(&tenant_config).unwrap();
552 6 :
553 6 : assert_eq!(
554 6 : tenant_conf_opt.lagging_wal_timeout,
555 6 : Some(Duration::from_secs(5))
556 6 : );
557 6 : }
558 : }
|