1mod arrow_convert;
23pub(crate) mod cdc;
24mod from_parse;
25
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use arrow::datatypes::{Schema, SchemaRef};
30use postgres::types::Type;
31use postgres::{Client, NoTls};
32
33use crate::config::{SourceType, TlsConfig};
34use crate::error::Result;
35use crate::source::batch_controller::AdaptiveBatchController;
36use crate::source::query::build_export_query;
37use crate::source::tls::build_native_tls;
38use crate::tuning::SourceTuning;
39use crate::types::{ColumnOverrides, SourceColumn, TypeMapping};
40
41use arrow_convert::{pg_columns_to_schema, rivet_type_for_pg_column, rows_to_record_batch_typed};
42use from_parse::try_parse_pg_simple_from_regclass_literal;
43
44pub struct PostgresSource {
45 client: Client,
46 transaction_pooler: bool,
49}
50
51fn detect_pg_transaction_pooler(client: &mut Client) -> bool {
59 let pid1: Option<i32> = client
60 .query_one("SELECT pg_backend_pid()", &[])
61 .ok()
62 .and_then(|r| r.try_get(0).ok());
63 let pid2: Option<i32> = client
64 .query_one("SELECT pg_backend_pid()", &[])
65 .ok()
66 .and_then(|r| r.try_get(0).ok());
67 matches!((pid1, pid2), (Some(a), Some(b)) if a != b)
68}
69
70impl PostgresSource {
71 pub fn connect(url: &str) -> Result<Self> {
74 let mut client = Client::connect(url, NoTls)?;
75 let transaction_pooler = detect_pg_transaction_pooler(&mut client);
76 if transaction_pooler {
77 log::warn!(
78 "transaction-mode connection pooler detected (pgBouncer/Odyssey) — \
79 SET LOCAL tuning is transaction-scoped; \
80 LISTEN/NOTIFY and advisory locks are unavailable"
81 );
82 }
83 Ok(Self {
84 client,
85 transaction_pooler,
86 })
87 }
88
89 pub fn connect_with_tls(url: &str, tls: Option<&TlsConfig>) -> Result<Self> {
92 crate::source::require_tls_or_loopback(url, tls)?;
94 match tls {
95 Some(cfg) if cfg.mode.is_enforced() => {
96 let connector = build_native_tls(cfg)?;
97 let make_tls = postgres_native_tls::MakeTlsConnector::new(connector);
98 let mut client = Client::connect(url, make_tls)?;
99 let transaction_pooler = detect_pg_transaction_pooler(&mut client);
100 if transaction_pooler {
101 log::warn!(
102 "transaction-mode connection pooler detected (pgBouncer/Odyssey) — \
103 SET LOCAL tuning is transaction-scoped; \
104 LISTEN/NOTIFY and advisory locks are unavailable"
105 );
106 }
107 Ok(Self {
108 client,
109 transaction_pooler,
110 })
111 }
112 _ => Self::connect(url),
113 }
114 }
115}
116
117struct PgTxnGuard<'a> {
125 client: &'a mut Client,
126 committed: bool,
127}
128
129impl<'a> PgTxnGuard<'a> {
130 fn begin(client: &'a mut Client) -> Result<Self> {
131 client.batch_execute("BEGIN")?;
132 Ok(Self {
133 client,
134 committed: false,
135 })
136 }
137
138 fn client_mut(&mut self) -> &mut Client {
139 self.client
140 }
141
142 fn commit(mut self) -> Result<()> {
143 self.client.batch_execute("COMMIT")?;
144 self.committed = true;
145 Ok(())
146 }
147}
148
149impl Drop for PgTxnGuard<'_> {
150 fn drop(&mut self) {
151 if !self.committed
152 && let Err(e) = self.client.batch_execute("ROLLBACK")
153 {
154 log::warn!("PgTxnGuard: ROLLBACK during drop failed: {e:#}");
157 }
158 }
159}
160
161pub(crate) fn sample_temp_bytes(url: &str, tls: Option<&TlsConfig>) -> Option<i64> {
173 let mut client = connect_client(url, tls).ok()?;
174 client
175 .query_one(
176 "SELECT temp_bytes::bigint FROM pg_stat_database WHERE datname = current_database()",
177 &[],
178 )
179 .ok()
180 .and_then(|r| r.try_get::<_, i64>(0).ok())
181}
182
183pub(crate) fn sample_harm_counters(
196 url: &str,
197 tls: Option<&TlsConfig>,
198) -> Option<Vec<(String, i64)>> {
199 let mut client = connect_client(url, tls).ok()?;
200 let row = client
205 .query_one(
206 "SELECT blks_read::bigint, blks_hit::bigint, tup_returned::bigint, \
207 tup_fetched::bigint, temp_files::bigint, deadlocks::bigint \
208 FROM pg_stat_database WHERE datname = current_database()",
209 &[],
210 )
211 .ok()?;
212 let names = [
213 "pg_blks_read",
214 "pg_blks_hit",
215 "pg_tup_returned",
216 "pg_tup_fetched",
217 "pg_temp_files",
218 "pg_deadlocks",
219 ];
220 let mut out = Vec::with_capacity(names.len());
221 for (i, name) in names.iter().enumerate() {
222 if let Ok(v) = row.try_get::<_, i64>(i) {
223 out.push(((*name).to_string(), v));
224 }
225 }
226 Some(out)
227}
228
229fn pg_fetch_work_mem_bytes(client: &mut Client) -> Option<i64> {
240 let raw: Option<String> = client
241 .query_one("SHOW work_mem", &[])
242 .ok()
243 .and_then(|r| r.try_get::<_, String>(0).ok());
244 raw.as_deref().and_then(parse_work_mem)
245}
246
247fn parse_work_mem(raw: &str) -> Option<i64> {
251 let s = raw.trim();
252 let mut split = 0;
254 for (i, ch) in s.char_indices() {
255 if !ch.is_ascii_digit() && ch != '.' && ch != '-' {
256 split = i;
257 break;
258 }
259 split = i + ch.len_utf8();
260 }
261 if split == 0 {
262 return None;
263 }
264 let (num_str, unit) = s.split_at(split);
265 let num: f64 = num_str.parse().ok()?;
266 let unit = unit.trim().to_ascii_lowercase();
267 let multiplier: f64 = match unit.as_str() {
268 "" | "kb" => 1024.0,
271 "mb" => 1024.0 * 1024.0,
272 "gb" => 1024.0 * 1024.0 * 1024.0,
273 "tb" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
274 _ => return None,
275 };
276 let bytes = (num * multiplier) as i64;
277 (bytes > 0).then_some(bytes)
278}
279
280fn pg_sample_checkpoints_req(client: &mut Client) -> Option<i64> {
286 let _ = client.execute("SELECT pg_stat_clear_snapshot()", &[]);
287 client
288 .query_one("SELECT checkpoints_req FROM pg_stat_bgwriter", &[])
289 .ok()
290 .and_then(|r| r.try_get::<_, i64>(0).ok())
291}
292
293pub(crate) fn introspect_pg_table_for_chunking(
305 url: &str,
306 tls: Option<&TlsConfig>,
307 qualified_table: &str,
308) -> Result<crate::source::TableIntrospection> {
309 let (schema, table) = match qualified_table.split_once('.') {
310 Some((s, t)) => (s.to_string(), t.to_string()),
311 None => ("public".to_string(), qualified_table.to_string()),
312 };
313 let mut client = connect_client(url, tls)?;
314
315 let (row_estimate, rel_size_bytes) = match client.query_opt(
317 "SELECT c.reltuples::bigint, pg_relation_size(c.oid)::bigint \
318 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
319 WHERE n.nspname = $1::text AND c.relname = $2::text",
320 &[&schema, &table],
321 )? {
322 Some(row) => {
323 let rt: i64 = row.try_get(0).unwrap_or(0);
324 let sz: i64 = row.try_get(1).unwrap_or(0);
325 (rt.max(0), sz.max(0))
326 }
327 None => (0, 0),
328 };
329 let avg_row_bytes = if row_estimate > 0 {
330 Some(rel_size_bytes / row_estimate)
331 } else {
332 None
333 };
334
335 let pk_rows = client.query(
337 "SELECT a.attname::text, t.typname::text \
338 FROM pg_index i \
339 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
340 JOIN pg_type t ON t.oid = a.atttypid \
341 WHERE i.indrelid = (($1::text || '.' || $2::text)::regclass) \
342 AND i.indisprimary",
343 &[&schema, &table],
344 )?;
345 let single_int_pk = if pk_rows.len() == 1 {
346 let col: String = pk_rows[0].get(0);
347 let pg_type: String = pk_rows[0].get(1);
348 if matches!(pg_type.as_str(), "int2" | "int4" | "int8") {
352 Some(col)
353 } else {
354 log::debug!(
355 "introspect_pg_table: PK '{col}' on {schema}.{table} has non-int type '{pg_type}' — skipping auto-resolve"
356 );
357 None
358 }
359 } else {
360 None
361 };
362
363 let keyset_rows = client.query(
375 "SELECT a.attname::text, i.indisprimary \
376 FROM pg_index i \
377 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] \
378 JOIN pg_type t ON t.oid = a.atttypid \
379 WHERE i.indrelid = (($1::text || '.' || $2::text)::regclass) \
380 AND i.indisunique AND i.indnkeyatts = 1 AND a.attnotnull \
381 AND t.typname <> 'numeric'",
382 &[&schema, &table],
383 )?;
384 let mut keyset_keys: Vec<String> = Vec::new();
385 for primary in [true, false] {
386 for row in &keyset_rows {
387 let col: String = row.get(0);
388 let is_primary: bool = row.get(1);
389 if is_primary == primary && !keyset_keys.contains(&col) {
390 keyset_keys.push(col);
391 }
392 }
393 }
394
395 let int_columns: Vec<String> = client
399 .query(
400 "SELECT a.attname::text FROM pg_attribute a \
401 JOIN pg_type t ON t.oid = a.atttypid \
402 WHERE a.attrelid = (($1::text || '.' || $2::text)::regclass) \
403 AND a.attnum > 0 AND NOT a.attisdropped \
404 AND t.typname IN ('int2', 'int4', 'int8')",
405 &[&schema, &table],
406 )?
407 .iter()
408 .map(|r| r.get(0))
409 .collect();
410
411 Ok(crate::source::TableIntrospection {
412 single_int_pk,
413 keyset_keys,
414 row_estimate,
415 avg_row_bytes,
416 int_columns,
417 })
418}
419
420pub(crate) fn connect_client(url: &str, tls: Option<&TlsConfig>) -> Result<Client> {
430 crate::source::require_tls_or_loopback(url, tls)?;
432 match tls {
433 Some(cfg) if cfg.mode.is_enforced() => {
434 let connector = build_native_tls(cfg)?;
435 let make_tls = postgres_native_tls::MakeTlsConnector::new(connector);
436 Ok(Client::connect(url, make_tls)?)
437 }
438 _ => Ok(Client::connect(url, NoTls)?),
439 }
440}
441
442fn pg_run_export(
451 client: &mut Client,
452 built_sql: &str,
453 tuning: &SourceTuning,
454 column_overrides: &ColumnOverrides,
455 sink: &mut dyn super::BatchSink,
456 numeric_hints: Option<&HashMap<String, (u8, i8)>>,
457) -> Result<(usize, bool)> {
458 let mut guard = PgTxnGuard::begin(client)?;
462 guard
474 .client_mut()
475 .batch_execute("SET LOCAL TimeZone = 'UTC'")?;
476 if tuning.statement_timeout_s > 0 {
477 guard.client_mut().batch_execute(&format!(
478 "SET LOCAL statement_timeout = '{}s'",
479 tuning.statement_timeout_s
480 ))?;
481 }
482 if tuning.lock_timeout_s > 0 {
483 guard.client_mut().batch_execute(&format!(
484 "SET LOCAL lock_timeout = '{}s'",
485 tuning.lock_timeout_s
486 ))?;
487 }
488 let work_mem_bytes = pg_fetch_work_mem_bytes(guard.client_mut());
495
496 guard
497 .client_mut()
498 .batch_execute(&format!("DECLARE _rivet NO SCROLL CURSOR FOR {built_sql}"))?;
499
500 let configured_batch_size = tuning.batch_size;
505 let mut ctl = AdaptiveBatchController::new(tuning, configured_batch_size);
508 ctl.seed_pressure(if tuning.adaptive {
509 pg_sample_checkpoints_req(guard.client_mut()).map(|v| v as u64)
510 } else {
511 None
512 });
513 let mut schema: Option<SchemaRef> = None;
514 let mut columns_cache: Option<Vec<(String, Type)>> = None;
515 let mut total_rows: usize = 0;
516 let mut cap_applied = false;
517 let max_value_bytes = tuning.max_value_bytes();
521
522 loop {
523 let requested = ctl.target();
524 let fetch_sql = format!("FETCH {} FROM _rivet", requested);
525 let rows = guard.client_mut().query(&fetch_sql, &[])?;
526 if rows.is_empty() {
527 break;
528 }
529
530 if schema.is_none() {
531 let stmt_cols: Vec<(String, Type)> = rows[0]
532 .columns()
533 .iter()
534 .map(|c| (c.name().to_string(), c.type_().clone()))
535 .collect();
536 let s = Arc::new(pg_columns_to_schema(
537 rows[0].columns(),
538 column_overrides,
539 numeric_hints,
540 )?);
541 sink.on_schema(s.clone())?;
542 if work_mem_bytes.is_none() {
545 let effective = tuning.effective_batch_size(Some(&s));
546 ctl.apply_memory_cap(effective.max(requested));
547 cap_applied = true;
548 }
549 schema = Some(s);
550 columns_cache = Some(stmt_cols);
551 }
552
553 let row_count = rows.len();
554 total_rows += row_count;
555
556 let s = schema.as_ref().expect("schema set on first iteration");
557 let cols = columns_cache
558 .as_ref()
559 .expect("columns set on first iteration");
560 let batch = rows_to_record_batch_typed(s, cols, &rows, max_value_bytes)?;
561 drop(rows);
562
563 if !cap_applied
568 && let Some(wm) = work_mem_bytes
569 && row_count > 0
570 {
571 let arrow_bytes = crate::tuning::SourceTuning::batch_memory_bytes(&batch);
572 let arrow_per_row = (arrow_bytes / row_count).max(1);
573 let pg_per_row = ((arrow_per_row * 12) / 10).max(64);
574 let safe = (((wm as f64) * 0.7) as usize / pg_per_row).max(100);
575 let mut target = safe;
576 if let Some(mem_mb) = tuning.batch_size_memory_mb {
577 let arrow_target = (mem_mb * 1024 * 1024) / arrow_per_row;
578 target = target.min(arrow_target.max(100));
579 }
580 if let Some(new) = ctl.apply_memory_cap(target) {
581 log::info!(
582 "PG work_mem={} B, observed row={} B (arrow), pg≈{} B → FETCH N → {} (configured={})",
583 wm,
584 arrow_per_row,
585 pg_per_row,
586 new,
587 configured_batch_size,
588 );
589 }
590 cap_applied = true;
591 }
592
593 sink.on_batch(&batch)?;
594
595 if let Some((new, under_pressure)) =
596 ctl.after_batch(|| pg_sample_checkpoints_req(guard.client_mut()).map(|v| v as u64))
597 {
598 log::info!(
599 "adaptive batch size → {} ({})",
600 new,
601 if under_pressure {
602 "pressure"
603 } else {
604 "recovery"
605 }
606 );
607 }
608
609 log::info!("fetched {} rows so far...", total_rows);
610
611 if row_count < requested {
612 break;
613 }
614 ctl.throttle(row_count);
615 }
616
617 guard.client_mut().batch_execute("CLOSE _rivet")?;
620 guard.commit()?;
621 Ok((total_rows, schema.is_some()))
622}
623
624impl super::Source for PostgresSource {
625 fn export(
626 &mut self,
627 request: &super::ExportRequest<'_>,
628 sink: &mut dyn super::BatchSink,
629 ) -> Result<()> {
630 let built = build_export_query(request, SourceType::Postgres);
631 debug_assert!(
632 built.cursor_param.is_none(),
633 "Postgres path inlines cursor values as E'…' literals — binding is unused"
634 );
635 log::debug!(
636 "executing query (connection={}): {}",
637 if self.transaction_pooler {
638 "transaction-pooler"
639 } else {
640 "direct"
641 },
642 built.sql
643 );
644
645 let hint_query = request.catalog_hint_query.unwrap_or(request.query);
649 let numeric_hints = pg_numeric_catalog_hints_opt(&mut self.client, hint_query);
650
651 let (total_rows, had_schema) = pg_run_export(
654 &mut self.client,
655 &built.sql,
656 request.tuning,
657 request.column_overrides,
658 sink,
659 numeric_hints.as_ref(),
660 )?;
661
662 if !had_schema {
663 sink.on_schema(Arc::new(Schema::empty()))?;
664 }
665
666 log::info!("total: {} rows", total_rows);
667 Ok(())
668 }
669
670 fn query_scalar(&mut self, sql: &str) -> Result<Option<String>> {
671 let rows = self.client.query(sql, &[])?;
672 if rows.is_empty() {
673 return Ok(None);
674 }
675 let row = &rows[0];
676 if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(0) {
677 return Ok(Some(v.to_string()));
678 }
679 if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(0) {
680 return Ok(Some(v.to_string()));
681 }
682 if let Ok(Some(v)) = row.try_get::<_, Option<f64>>(0) {
683 return Ok(Some(v.to_string()));
684 }
685 if let Ok(Some(v)) = row.try_get::<_, Option<chrono::NaiveDateTime>>(0) {
687 return Ok(Some(v.format("%Y-%m-%d %H:%M:%S").to_string()));
688 }
689 if let Ok(Some(v)) = row.try_get::<_, Option<chrono::NaiveDate>>(0) {
690 return Ok(Some(v.format("%Y-%m-%d").to_string()));
691 }
692 if let Ok(Some(v)) = row.try_get::<_, Option<chrono::DateTime<chrono::Utc>>>(0) {
693 return Ok(Some(v.format("%Y-%m-%d %H:%M:%S").to_string()));
694 }
695 if let Ok(Some(v)) = row.try_get::<_, Option<String>>(0) {
696 return Ok(Some(v));
697 }
698 Ok(None)
699 }
700
701 fn type_mappings(
702 &mut self,
703 query: &str,
704 column_overrides: &ColumnOverrides,
705 ) -> Result<Vec<TypeMapping>> {
706 let wrapped = format!("SELECT * FROM ({}) AS _rivet_type_probe LIMIT 0", query);
707 let stmt = self.client.prepare(&wrapped)?;
708 let hints = pg_numeric_catalog_hints_opt(&mut self.client, query);
709 let mappings = stmt
710 .columns()
711 .iter()
712 .map(|col| {
713 let rivet = rivet_type_for_pg_column(col, column_overrides, hints.as_ref());
714 let source = SourceColumn::simple(col.name(), col.type_().name(), true);
715 TypeMapping::from_source(&source, rivet)
716 })
717 .collect();
718 Ok(mappings)
719 }
720
721 fn sample_pressure(&mut self) -> Option<u64> {
725 pg_sample_checkpoints_req(&mut self.client).map(|v| v.max(0) as u64)
726 }
727
728 fn server_context(&mut self) -> Option<String> {
729 let version = self.query_scalar("SELECT version()").ok().flatten();
732 let stmt_timeout = self
733 .query_scalar("SELECT current_setting('statement_timeout')")
734 .ok()
735 .flatten();
736 let idle_timeout = self
737 .query_scalar("SELECT current_setting('idle_in_transaction_session_timeout')")
738 .ok()
739 .flatten();
740 let time_zone = self
741 .query_scalar("SELECT current_setting('TimeZone')")
742 .ok()
743 .flatten();
744 let max_conns = self
745 .query_scalar("SELECT current_setting('max_connections')")
746 .ok()
747 .flatten();
748 Some(
749 serde_json::json!({
750 "engine": "postgres",
751 "version": version,
752 "statement_timeout": stmt_timeout,
753 "idle_in_transaction_session_timeout": idle_timeout,
754 "time_zone": time_zone,
755 "max_connections": max_conns,
756 })
757 .to_string(),
758 )
759 }
760}
761
762fn pg_numeric_catalog_hints_opt(
768 client: &mut Client,
769 query: &str,
770) -> Option<HashMap<String, (u8, i8)>> {
771 match pg_fetch_numeric_catalog_hints(client, query) {
772 Ok(m) => m,
773 Err(e) => {
774 log::warn!(
780 "PG numeric catalog lookup failed — NUMERIC columns will require explicit `columns:` overrides: {e}"
781 );
782 None
783 }
784 }
785}
786
787fn pg_fetch_numeric_catalog_hints(
788 client: &mut Client,
789 query: &str,
790) -> crate::error::Result<Option<HashMap<String, (u8, i8)>>> {
791 let Some(regclass_lit) = try_parse_pg_simple_from_regclass_literal(query) else {
792 return Ok(None);
793 };
794 let locate_sql = "SELECT n.nspname::text, c.relname::text \
795 FROM pg_catalog.pg_class c \
796 JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
797 WHERE c.oid = ($1::text)::regclass";
798 let row_opt = match client.query_opt(locate_sql, &[®class_lit]) {
799 Ok(r) => r,
800 Err(e) => {
801 log::warn!("PG numeric catalog: '{regclass_lit}' regclass lookup failed: {e}");
802 return Ok(None);
803 }
804 };
805 let Some(row) = row_opt else {
806 return Ok(None);
807 };
808 let schema: String = row.get(0);
809 let table: String = row.get(1);
810 let rows = client.query(
811 "SELECT column_name::text, data_type::text, numeric_precision, numeric_scale \
812 FROM information_schema.columns \
813 WHERE table_schema = $1 AND table_name = $2 \
814 ORDER BY ordinal_position",
815 &[&schema, &table],
816 )?;
817
818 let mut map = HashMap::new();
819 for row in rows {
820 let col: String = row.get(0);
821 let dt: String = row.get(1);
822 if !is_pg_numeric_information_type(&dt) {
823 continue;
824 }
825 let p: Option<i32> = row.get(2);
826 let s: Option<i32> = row.get(3);
827 if let (Some(p), Some(s)) = (p, s)
828 && let Some(pair) = catalog_numeric_to_decimal_params(p, s)
829 {
830 map.insert(col, pair);
831 }
832 }
833
834 if map.is_empty() {
835 Ok(None)
836 } else {
837 log::debug!(
838 "PG numeric catalog: resolved {} DECIMAL/NUMERIC column(s) for relation {regclass_lit}",
839 map.len(),
840 );
841 Ok(Some(map))
842 }
843}
844
845fn is_pg_numeric_information_type(dt: &str) -> bool {
846 let d = dt.trim().to_ascii_lowercase();
847 matches!(d.as_str(), "numeric" | "decimal")
848 || d.starts_with("numeric(")
849 || d.starts_with("decimal(")
850}
851
852fn catalog_numeric_to_decimal_params(precision: i32, scale: i32) -> Option<(u8, i8)> {
854 if precision <= 0 || precision > 76 {
855 return None;
856 }
857 let precision_u = precision as u8;
858 if scale < i32::from(i8::MIN) || scale > i32::from(i8::MAX) {
859 return None;
860 }
861 let scale_i = scale as i8;
862 if scale_i > precision as i8 {
863 return None;
864 }
865 Some((precision_u, scale_i))
866}
867
868#[cfg(test)]
869mod tests {
870 use super::catalog_numeric_to_decimal_params;
871
872 #[test]
875 fn catalog_decimal_bounds() {
876 assert_eq!(catalog_numeric_to_decimal_params(18, 2), Some((18, 2)));
877 assert!(catalog_numeric_to_decimal_params(0, 2).is_none());
878 assert!(catalog_numeric_to_decimal_params(77, 0).is_none());
879 assert!(catalog_numeric_to_decimal_params(18, 19).is_none());
880 assert_eq!(catalog_numeric_to_decimal_params(76, 0), Some((76, 0)));
883 assert_eq!(catalog_numeric_to_decimal_params(18, 18), Some((18, 18)));
885 assert_eq!(
887 catalog_numeric_to_decimal_params(10, -128),
888 Some((10, -128))
889 );
890 assert!(catalog_numeric_to_decimal_params(10, -129).is_none());
891 assert!(catalog_numeric_to_decimal_params(76, 200).is_none());
894 }
895
896 #[test]
897 fn parse_work_mem_handles_pg_units() {
898 use super::parse_work_mem;
899 assert_eq!(parse_work_mem("4MB"), Some(4 * 1024 * 1024));
902 assert_eq!(parse_work_mem("16384kB"), Some(16384 * 1024));
903 assert_eq!(parse_work_mem("1GB"), Some(1024 * 1024 * 1024));
904 assert_eq!(parse_work_mem(" 4MB "), Some(4 * 1024 * 1024));
905 assert_eq!(parse_work_mem("4mb"), Some(4 * 1024 * 1024));
906 assert_eq!(parse_work_mem("65536"), Some(65536 * 1024));
907 assert_eq!(parse_work_mem(""), None);
908 assert_eq!(parse_work_mem("garbage"), None);
909 assert_eq!(parse_work_mem("4s"), None);
911 assert_eq!(
914 parse_work_mem("2TB"),
915 Some(2 * 1024 * 1024 * 1024 * 1024),
916 "terabytes are 1024^4"
917 );
918 assert_eq!(parse_work_mem("0.5MB"), Some(512 * 1024));
920 assert_eq!(parse_work_mem("0"), None);
922 assert_eq!(parse_work_mem("0MB"), None);
923 }
924}