pgsync-core 0.1.1

Core of pgsync: job lifecycle, transaction model, LSN and checkpoint contracts, bounded backpressure
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
//! Job configuration: YAML schema, defaults, and validation.
//!
//! Connection URLs are never stored in the file; they are read from the environment
//! variables named by `url_env` so that secrets stay out of config and logs.

use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fmt;
use std::time::Duration;

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub job: JobConfig,
    pub source: SourceConfig,
    pub target: TargetConfig,
    pub replication: ReplicationConfig,
    #[serde(default)]
    pub schema: SchemaConfig,
    #[serde(default)]
    pub resources: ResourcesConfig,
    #[serde(default)]
    pub safety: SafetyConfig,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct JobConfig {
    /// Stable identifier; also the primary key of the checkpoint row in the target.
    pub id: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceConfig {
    /// Environment variable holding the source connection URL.
    pub url_env: String,
    /// Existing publication to stream from.
    pub publication: String,
    /// Logical replication slot name (created by `bootstrap`, used by `run`).
    pub slot: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TargetConfig {
    pub url_env: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplicationMode {
    /// Initial snapshot followed by continuous CDC (default).
    SnapshotAndCdc,
    /// Reserved. Rejected by validation: pgsync cannot prove that an existing target copy is
    /// consistent with a freshly created slot, so there is no safe way to skip the snapshot.
    CdcOnly,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReplicationConfig {
    #[serde(default = "default_mode")]
    pub mode: ReplicationMode,
    /// Fully qualified `schema.table` names. Must match the publication contents.
    pub tables: Vec<TableName>,
}

fn default_mode() -> ReplicationMode {
    ReplicationMode::SnapshotAndCdc
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SchemaMode {
    /// Run `pg_dump --schema-only` for the selected tables and apply it to the target.
    #[default]
    PgDump,
    /// The target schema already exists; PGSync verifies columns and proceeds.
    Existing,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SchemaConfig {
    #[serde(default)]
    pub mode: SchemaMode,
    /// Path to the `pg_dump` binary (default: resolved from `PATH`).
    #[serde(default)]
    pub pg_dump_path: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct ResourcesConfig {
    pub memory_budget_mb: u64,
    pub buffer_size_kb: u64,
    pub max_inflight_bytes: u64,
    pub channel_capacity: usize,
    pub max_batch_rows: usize,
    pub max_batch_bytes: u64,
    /// Largest single replication message accepted from the server (one row change can be
    /// up to 1 GB in PostgreSQL). Larger messages stop the job with a `resource` error
    /// before anything is buffered; nothing is acknowledged. `None` derives a value from
    /// the remaining memory budget, capped at 32 MiB.
    pub max_message_bytes: Option<u64>,
    pub max_parallel_copies: usize,
    #[serde(with = "duration_text")]
    pub connection_timeout: Duration,
    pub retry_backoff: RetryBackoff,
    #[serde(with = "duration_text")]
    pub snapshot_timeout: Duration,
    #[serde(with = "duration_text")]
    pub feedback_interval: Duration,
    pub max_replication_lag_bytes: u64,
}

impl Default for ResourcesConfig {
    fn default() -> Self {
        ResourcesConfig {
            memory_budget_mb: 128,
            buffer_size_kb: 256,
            max_inflight_bytes: 16 * 1024 * 1024,
            channel_capacity: 4096,
            max_batch_rows: 500,
            max_batch_bytes: 4 * 1024 * 1024,
            max_message_bytes: None,
            max_parallel_copies: 2,
            connection_timeout: Duration::from_secs(10),
            retry_backoff: RetryBackoff::default(),
            snapshot_timeout: Duration::from_secs(6 * 3600),
            feedback_interval: Duration::from_secs(10),
            max_replication_lag_bytes: 1024 * 1024 * 1024,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct RetryBackoff {
    #[serde(with = "duration_text")]
    pub initial: Duration,
    #[serde(with = "duration_text")]
    pub max: Duration,
    pub multiplier: f64,
    /// `0` means retry forever.
    pub max_attempts: u32,
}

pub const MAX_MESSAGE_BYTES_CAP: u64 = 32 * 1024 * 1024;

impl ResourcesConfig {
    /// Memory reserved for channel, batch and COPY buffers.
    pub fn fixed_budget_bytes(&self) -> u64 {
        self.max_inflight_bytes
            + self.max_batch_bytes
            + (self.max_parallel_copies as u64) * 2 * self.buffer_size_kb * 1024
    }

    /// Effective message limit: the configured value, or half of what the budget leaves
    /// after the fixed buffers (a message is held twice while it is applied: decoded and
    /// serialised as statement parameters), capped at 32 MiB and never below 1 MiB.
    pub fn effective_max_message_bytes(&self) -> u64 {
        match self.max_message_bytes {
            Some(v) => v,
            None => {
                let budget = self.memory_budget_mb * 1024 * 1024;
                let spare = budget.saturating_sub(self.fixed_budget_bytes()) / 2;
                spare.clamp(1024 * 1024, MAX_MESSAGE_BYTES_CAP)
            }
        }
    }
}

impl Default for RetryBackoff {
    fn default() -> Self {
        RetryBackoff {
            initial: Duration::from_secs(1),
            max: Duration::from_secs(30),
            multiplier: 2.0,
            max_attempts: 0,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WalRetentionAction {
    #[default]
    Warn,
    Stop,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct SafetyConfig {
    pub require_tls: bool,
    /// Accept any server certificate. Only honoured for loopback hosts.
    pub insecure_skip_tls_verify: bool,
    /// PEM file with CA certificate(s) to trust in addition to the system store.
    pub tls_ca_file: Option<String>,
    pub auto_create_publication: bool,
    pub auto_drop_slot: bool,
    pub wal_retention_alarm_bytes: u64,
    pub wal_retention_action: WalRetentionAction,
    #[serde(with = "duration_text")]
    pub monitor_interval: Duration,
    /// How often the running job writes its runtime row (`pgsync.job_runtime`) to the
    /// target, for `pgsync status` and external monitoring.
    #[serde(with = "duration_text")]
    pub heartbeat_interval: Duration,
}

impl Default for SafetyConfig {
    fn default() -> Self {
        SafetyConfig {
            require_tls: true,
            insecure_skip_tls_verify: false,
            tls_ca_file: None,
            auto_create_publication: false,
            auto_drop_slot: false,
            wal_retention_alarm_bytes: 1024 * 1024 * 1024,
            wal_retention_action: WalRetentionAction::Warn,
            monitor_interval: Duration::from_secs(30),
            heartbeat_interval: Duration::from_secs(10),
        }
    }
}

/// A `schema.table` pair. Serialised as `schema.table`; quoted identifiers are not
/// supported in Beta 1 (names must be plain lower-case identifiers).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TableName {
    pub schema: String,
    pub name: String,
}

impl TableName {
    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
        TableName {
            schema: schema.into(),
            name: name.into(),
        }
    }

    pub fn parse(s: &str) -> Result<Self> {
        let (schema, name) = s
            .split_once('.')
            .ok_or_else(|| Error::config(format!("table {s:?} must be written as schema.table")))?;
        for part in [schema, name] {
            if !is_plain_identifier(part) {
                return Err(Error::config(format!(
                    "table {s:?}: {part:?} is not a plain identifier (lower-case letters, digits, underscore)"
                )));
            }
        }
        Ok(TableName::new(schema, name))
    }

    /// Double-quoted, escaped `"schema"."table"` for use in SQL.
    pub fn quoted(&self) -> String {
        format!("{}.{}", quote_ident(&self.schema), quote_ident(&self.name))
    }
}

impl fmt::Display for TableName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{}", self.schema, self.name)
    }
}

impl Serialize for TableName {
    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.collect_str(self)
    }
}

impl<'de> Deserialize<'de> for TableName {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        TableName::parse(&s).map_err(|e| serde::de::Error::custom(e.message().to_owned()))
    }
}

pub fn is_plain_identifier(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c.is_ascii_lowercase() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') && s.len() <= 63
}

/// Quote an SQL identifier, doubling embedded quotes.
pub fn quote_ident(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        if c == '"' {
            out.push('"');
        }
        out.push(c);
    }
    out.push('"');
    out
}

/// Quote an SQL string literal (single quotes doubled). Used only for replication
/// commands, which do not support parameters.
pub fn quote_literal(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for c in s.chars() {
        if c == '\'' {
            out.push('\'');
        }
        out.push(c);
    }
    out.push('\'');
    out
}

impl Config {
    pub fn from_yaml(text: &str) -> Result<Config> {
        let cfg: Config = serde_yaml_ng::from_str(text)
            .map_err(|e| Error::config(format!("invalid configuration: {e}")))?;
        cfg.validate()?;
        Ok(cfg)
    }

    pub fn validate(&self) -> Result<()> {
        let bad = |m: String| Err(Error::config(m));
        if self.job.id.is_empty() || self.job.id.len() > 64 {
            return bad("job.id must be 1-64 characters".into());
        }
        if !self
            .job
            .id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
        {
            return bad("job.id may only contain letters, digits, '-' and '_'".into());
        }
        for (field, value) in [
            ("source.url_env", &self.source.url_env),
            ("target.url_env", &self.target.url_env),
        ] {
            if value.is_empty() {
                return bad(format!("{field} must name an environment variable"));
            }
        }
        if !is_plain_identifier(&self.source.publication) {
            return bad(format!(
                "source.publication {:?} must be a plain identifier",
                self.source.publication
            ));
        }
        if !is_slot_name(&self.source.slot) {
            return bad(format!(
                "source.slot {:?} must contain only lower-case letters, digits and underscores (max 63)",
                self.source.slot
            ));
        }
        if self.replication.tables.is_empty() {
            return bad("replication.tables must list at least one table".into());
        }
        if self.replication.mode == ReplicationMode::CdcOnly {
            return bad(
                "replication.mode: cdc_only is not supported in this version: pgsync cannot verify \
                 that the target already holds a copy consistent with a new slot's snapshot, and \
                 streaming into an unverified copy would diverge silently. Use snapshot_and_cdc."
                    .into(),
            );
        }
        let mut seen = BTreeSet::new();
        for t in &self.replication.tables {
            if !seen.insert(t) {
                return bad(format!("replication.tables lists {t} more than once"));
            }
        }
        let r = &self.resources;
        if r.memory_budget_mb < 16 {
            return bad("resources.memory_budget_mb must be at least 16".into());
        }
        if r.buffer_size_kb == 0 || r.buffer_size_kb > 64 * 1024 {
            return bad("resources.buffer_size_kb must be between 1 and 65536".into());
        }
        if r.max_inflight_bytes < 64 * 1024 {
            return bad("resources.max_inflight_bytes must be at least 65536".into());
        }
        if r.channel_capacity == 0 {
            return bad("resources.channel_capacity must be positive".into());
        }
        if r.max_batch_rows == 0 || r.max_batch_bytes == 0 {
            return bad("resources.max_batch_rows and max_batch_bytes must be positive".into());
        }
        if r.max_parallel_copies == 0 || r.max_parallel_copies > 64 {
            return bad("resources.max_parallel_copies must be between 1 and 64".into());
        }
        let budget = r.memory_budget_mb * 1024 * 1024;
        let needed = r.fixed_budget_bytes();
        if needed > budget {
            return bad(format!(
                "resources.memory_budget_mb ({} MiB) is smaller than max_inflight_bytes + max_batch_bytes + copy buffers ({} bytes)",
                r.memory_budget_mb, needed
            ));
        }
        if let Some(m) = r.max_message_bytes {
            if m < 64 * 1024 {
                return bad("resources.max_message_bytes must be at least 65536".into());
            }
            if needed + 2 * m > budget {
                return bad(format!(
                    "resources.memory_budget_mb ({} MiB) cannot hold the fixed buffers ({} bytes) plus two copies of a max_message_bytes message ({} bytes)",
                    r.memory_budget_mb, needed, m
                ));
            }
        }
        if r.connection_timeout.is_zero() {
            return bad("resources.connection_timeout must be positive".into());
        }
        if r.retry_backoff.initial.is_zero() || r.retry_backoff.max < r.retry_backoff.initial {
            return bad("resources.retry_backoff: initial must be positive and <= max".into());
        }
        if !(r.retry_backoff.multiplier >= 1.0 && r.retry_backoff.multiplier <= 10.0) {
            return bad("resources.retry_backoff.multiplier must be between 1 and 10".into());
        }
        if r.snapshot_timeout.is_zero() {
            return bad("resources.snapshot_timeout must be positive".into());
        }
        if r.feedback_interval.is_zero() || r.feedback_interval > Duration::from_secs(60) {
            return bad("resources.feedback_interval must be between 1ms and 60s".into());
        }
        if r.max_replication_lag_bytes == 0 {
            return bad("resources.max_replication_lag_bytes must be positive".into());
        }
        let s = &self.safety;
        if s.wal_retention_alarm_bytes == 0 {
            return bad("safety.wal_retention_alarm_bytes must be positive".into());
        }
        if s.monitor_interval.is_zero() {
            return bad("safety.monitor_interval must be positive".into());
        }
        if s.heartbeat_interval.is_zero() || s.heartbeat_interval > Duration::from_secs(300) {
            return bad("safety.heartbeat_interval must be between 1ms and 5m".into());
        }
        Ok(())
    }
}

pub fn is_slot_name(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= 63
        && s.chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}

/// Human durations such as `500ms`, `10s`, `5m`, `2h`.
pub fn parse_duration(s: &str) -> std::result::Result<Duration, String> {
    let s = s.trim();
    let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
    let (num, unit) = s.split_at(split);
    let n: u64 = num.parse().map_err(|_| format!("invalid duration {s:?}"))?;
    let d = match unit.trim() {
        "ms" => Duration::from_millis(n),
        "s" | "" => Duration::from_secs(n),
        "m" => Duration::from_secs(n * 60),
        "h" => Duration::from_secs(n * 3600),
        _ => return Err(format!("invalid duration unit in {s:?} (use ms, s, m, h)")),
    };
    Ok(d)
}

pub fn format_duration(d: Duration) -> String {
    let ms = d.as_millis();
    if ms % 3_600_000 == 0 && ms > 0 {
        format!("{}h", ms / 3_600_000)
    } else if ms % 60_000 == 0 && ms > 0 {
        format!("{}m", ms / 60_000)
    } else if ms % 1000 == 0 {
        format!("{}s", ms / 1000)
    } else {
        format!("{ms}ms")
    }
}

mod duration_text {
    use super::*;
    use serde::{Deserializer, Serializer};

    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(&format_duration(*d))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
        let s = String::deserialize(d)?;
        parse_duration(&s).map_err(serde::de::Error::custom)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const MINIMAL: &str = r#"
job:
  id: demo
source:
  url_env: SRC
  publication: pub1
  slot: slot_1
target:
  url_env: DST
replication:
  tables: [public.users, public.orders]
"#;

    #[test]
    fn minimal_config_gets_defaults() {
        let c = Config::from_yaml(MINIMAL).unwrap();
        assert_eq!(c.replication.mode, ReplicationMode::SnapshotAndCdc);
        assert_eq!(c.resources.memory_budget_mb, 128);
        assert_eq!(c.resources.connection_timeout, Duration::from_secs(10));
        assert!(c.safety.require_tls);
        assert_eq!(c.schema.mode, SchemaMode::PgDump);
        assert_eq!(c.replication.tables[0], TableName::new("public", "users"));
    }

    #[test]
    fn spec_example_parses() {
        let yaml = r#"
job:
  id: demo-replication
source:
  url_env: PGSYNC_SOURCE_URL
  publication: pgsync_demo
  slot: pgsync_demo_slot
target:
  url_env: PGSYNC_TARGET_URL
replication:
  mode: snapshot_and_cdc
  tables:
    - public.users
    - public.orders
resources:
  memory_budget_mb: 128
  buffer_size_kb: 256
  max_inflight_bytes: 16777216
  max_parallel_copies: 2
  connection_timeout: 5s
  retry_backoff:
    initial: 500ms
    max: 1m
  snapshot_timeout: 2h
safety:
  require_tls: true
  auto_create_publication: false
  auto_drop_slot: false
"#;
        let c = Config::from_yaml(yaml).unwrap();
        assert_eq!(
            c.resources.retry_backoff.initial,
            Duration::from_millis(500)
        );
        assert_eq!(c.resources.retry_backoff.max, Duration::from_secs(60));
        assert_eq!(c.resources.snapshot_timeout, Duration::from_secs(7200));
        assert_eq!(c.resources.connection_timeout, Duration::from_secs(5));
    }

    #[test]
    fn rejects_unknown_fields_and_bad_values() {
        let cases = [
            (
                MINIMAL.replace("tables: [public.users, public.orders]", "tables: []"),
                "at least one",
            ),
            (
                MINIMAL.replace("public.orders", "public.users"),
                "more than once",
            ),
            (MINIMAL.replace("slot_1", "Slot-1"), "source.slot"),
            (
                format!("{MINIMAL}\n")
                    .replace("replication:\n", "replication:\n  mode: cdc_only\n"),
                "cdc_only is not supported",
            ),
            (MINIMAL.replace("id: demo", "id: 'demo job'"), "job.id"),
            (
                format!("{MINIMAL}\nresources:\n  memory_budget_mb: 4\n"),
                "memory_budget_mb",
            ),
            (
                format!("{MINIMAL}\nresources:\n  max_parallel_copies: 0\n"),
                "max_parallel_copies",
            ),
            (
                format!("{MINIMAL}\nresources:\n  connection_timeout: 10x\n"),
                "duration",
            ),
            (
                format!("{MINIMAL}\nresources:\n  typo: 1\n"),
                "unknown field",
            ),
            (
                format!(
                    "{MINIMAL}\nresources:\n  memory_budget_mb: 16\n  max_inflight_bytes: 20000000\n"
                ),
                "smaller than",
            ),
        ];
        for (yaml, expect) in cases {
            let err = Config::from_yaml(&yaml).expect_err(&format!("should reject: {expect}"));
            assert!(err.message().contains(expect), "{expect}: got {err}");
        }
    }

    #[test]
    fn message_limit_is_derived_from_the_budget_or_validated() {
        let c = Config::from_yaml(MINIMAL).unwrap();
        // 128 MiB - (16 + 4 + 1) MiB = 107 MiB, half is 53.5 MiB, capped at 32 MiB.
        assert_eq!(c.resources.effective_max_message_bytes(), 32 * 1024 * 1024);
        let c =
            Config::from_yaml(&format!("{MINIMAL}\nresources:\n  memory_budget_mb: 24\n")).unwrap();
        assert_eq!(c.resources.effective_max_message_bytes(), 1536 * 1024);
        let c = Config::from_yaml(&format!(
            "{MINIMAL}\nresources:\n  max_message_bytes: 8388608\n"
        ))
        .unwrap();
        assert_eq!(c.resources.effective_max_message_bytes(), 8 * 1024 * 1024);
        let err = Config::from_yaml(&format!(
            "{MINIMAL}\nresources:\n  max_message_bytes: 67108864\n"
        ))
        .unwrap_err();
        assert!(err.message().contains("two copies"), "{err}");
    }

    #[test]
    fn table_name_parsing_and_quoting() {
        assert!(TableName::parse("users").is_err());
        assert!(TableName::parse("Public.users").is_err());
        assert!(TableName::parse("public.users;drop").is_err());
        let t = TableName::parse("app_1.orders_2").unwrap();
        assert_eq!(t.quoted(), "\"app_1\".\"orders_2\"");
        assert_eq!(quote_ident("we\"ird"), "\"we\"\"ird\"");
        assert_eq!(quote_literal("it's"), "'it''s'");
    }

    #[test]
    fn durations_round_trip() {
        for (s, d) in [
            ("500ms", Duration::from_millis(500)),
            ("10s", Duration::from_secs(10)),
            ("5m", Duration::from_secs(300)),
            ("2h", Duration::from_secs(7200)),
            ("7", Duration::from_secs(7)),
        ] {
            assert_eq!(parse_duration(s).unwrap(), d, "{s}");
        }
        assert_eq!(format_duration(Duration::from_secs(300)), "5m");
        assert_eq!(format_duration(Duration::from_millis(1500)), "1500ms");
        assert!(parse_duration("abc").is_err());
        assert!(parse_duration("10d").is_err());
    }
}