Skip to main content

faucet_sink_postgres/
config.rs

1//! PostgreSQL sink configuration.
2
3use faucet_core::DEFAULT_BATCH_SIZE;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// How to map JSON records to table columns.
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum PostgresColumnMapping {
11    /// Insert each record as a single `jsonb` column. The column name
12    /// defaults to `"data"` but can be overridden.
13    Jsonb { column: String },
14    /// Map top-level JSON keys directly to table columns.
15    /// Only keys that match existing columns are inserted; extra keys are ignored.
16    AutoMap,
17}
18
19impl Default for PostgresColumnMapping {
20    fn default() -> Self {
21        Self::Jsonb {
22            column: "data".into(),
23        }
24    }
25}
26
27/// How rows are shipped to PostgreSQL in append mode.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "snake_case")]
30pub enum PostgresWriteMethod {
31    /// Multi-row `INSERT INTO … VALUES …` — the default. Works with every
32    /// write mode and the exactly-once transaction path.
33    #[default]
34    Insert,
35    /// `COPY … FROM STDIN (FORMAT text)` bulk load — typically **5–10×
36    /// faster** than multi-row `INSERT` for bulk append (issue #308).
37    ///
38    /// **Append-only**: `COPY` has no `ON CONFLICT`, so combining
39    /// `write_method: copy` with `write_mode: upsert|delete` is rejected at
40    /// config load. The exactly-once path (`write_batch_idempotent`) always
41    /// uses the `INSERT`/transaction path regardless of this setting — the
42    /// watermark token must commit atomically with the page's data.
43    ///
44    /// Error semantics are all-or-nothing per batch, the same as a failed
45    /// multi-row `INSERT`: one bad row fails the whole `COPY` and the DLQ
46    /// router's `on_batch_error` policy applies.
47    Copy,
48}
49
50/// Configuration for the PostgreSQL sink.
51#[derive(Clone, Serialize, Deserialize, JsonSchema)]
52pub struct PostgresSinkConfig {
53    /// PostgreSQL connection URL (e.g. `postgres://user:pass@host/db`).
54    pub connection_url: String,
55    /// Target table name.
56    pub table_name: String,
57    /// Optional schema (namespace) qualifying [`table_name`](Self::table_name).
58    ///
59    /// When set, both the AutoMap column-discovery probe and the `INSERT`
60    /// target `schema.table_name` explicitly. When unset (the default), the
61    /// table resolves against the connection's `search_path`, and column
62    /// discovery is scoped to whichever schema the `INSERT` actually resolves
63    /// to — so a same-named table in another schema no longer pollutes the
64    /// AutoMap column set (#146 M13).
65    #[serde(default)]
66    pub schema: Option<String>,
67    /// How to map JSON records to columns. Defaults to a single `jsonb`
68    /// column named `data`.
69    #[serde(default)]
70    pub column_mapping: PostgresColumnMapping,
71    /// Maximum rows per multi-row `INSERT` statement. Defaults to
72    /// [`DEFAULT_BATCH_SIZE`].
73    ///
74    /// When the upstream `StreamPage` carries more records than `batch_size`,
75    /// the sink slices the page into `batch_size`-row chunks and issues one
76    /// multi-row `INSERT` per chunk. When `batch_size = 0`, the entire slice
77    /// is sent in a single `INSERT` — useful when the source already chunks
78    /// to a Postgres-friendly size.
79    ///
80    /// `batch_size = 0` is the "no batching" sentinel: the entire upstream
81    /// page is forwarded in one statement, subject to Postgres' natural
82    /// per-statement bind-parameter limit of 65 535. AutoMap mode binds one
83    /// parameter per column per row, so the safe ceiling is roughly
84    /// `65_535 / num_columns` rows per call; JSONB mode binds a single
85    /// array parameter and has no such ceiling. Keep the default unless the
86    /// upstream page size is already tuned for Postgres.
87    ///
88    /// **Recommended value: ~1000** — Postgres' multi-row `INSERT` sweet
89    /// spot. Larger chunks rarely add throughput and risk hitting the
90    /// 65 535-parameter ceiling in AutoMap mode.
91    #[serde(default = "default_batch_size")]
92    pub batch_size: usize,
93    /// Maximum number of connections in the pool. Defaults to 5.
94    ///
95    /// Bounded on purpose: the pool must be finite so a wide fan-out (many
96    /// matrix rows / shards) cannot exhaust the server's own `max_connections`.
97    /// There is no "unlimited" setting — raise this explicitly if you need more.
98    #[serde(default = "default_max_connections")]
99    pub max_connections: u32,
100    /// Write mode, key columns, and optional delete marker. `write_mode`
101    /// defaults to `append`. Upsert/delete require `column_mapping: auto_map`
102    /// and a UNIQUE/PRIMARY KEY constraint on `key`.
103    #[serde(flatten)]
104    pub write: faucet_core::WriteSpec,
105    /// How append-mode rows are shipped: multi-row `insert` (default) or the
106    /// `copy` bulk-load fast-path (`COPY … FROM STDIN`, typically 5–10×
107    /// faster for bulk append). `copy` is append-only — it is rejected with
108    /// `write_mode: upsert|delete` — and the exactly-once path always stays
109    /// on `insert` so data + watermark commit in one transaction.
110    #[serde(default)]
111    pub write_method: PostgresWriteMethod,
112}
113
114fn default_batch_size() -> usize {
115    DEFAULT_BATCH_SIZE
116}
117
118fn default_max_connections() -> u32 {
119    5
120}
121
122impl std::fmt::Debug for PostgresSinkConfig {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("PostgresSinkConfig")
125            .field("connection_url", &"***")
126            .field("table_name", &self.table_name)
127            .field("schema", &self.schema)
128            .field("column_mapping", &self.column_mapping)
129            .field("batch_size", &self.batch_size)
130            .field("max_connections", &self.max_connections)
131            .field("write_method", &self.write_method)
132            .finish()
133    }
134}
135
136impl PostgresSinkConfig {
137    /// Create a new config with required fields and sensible defaults.
138    pub fn new(connection_url: impl Into<String>, table_name: impl Into<String>) -> Self {
139        Self {
140            connection_url: connection_url.into(),
141            table_name: table_name.into(),
142            schema: None,
143            column_mapping: PostgresColumnMapping::default(),
144            batch_size: DEFAULT_BATCH_SIZE,
145            max_connections: 5,
146            write: faucet_core::WriteSpec::default(),
147            write_method: PostgresWriteMethod::default(),
148        }
149    }
150
151    /// Set the schema (namespace) that qualifies the table. When unset, the
152    /// table resolves against the connection's `search_path`.
153    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
154        self.schema = Some(schema.into());
155        self
156    }
157
158    /// Set the column mapping strategy.
159    pub fn column_mapping(mut self, mapping: PostgresColumnMapping) -> Self {
160        self.column_mapping = mapping;
161        self
162    }
163
164    /// Set the per-statement row count for multi-row `INSERT`.
165    ///
166    /// Pass `0` to opt out of re-chunking — the sink forwards each upstream
167    /// [`StreamPage`](faucet_core::StreamPage) as a single `INSERT`
168    /// statement. Postgres' multi-row `INSERT` sweet spot is ~1000 rows.
169    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
170        self.batch_size = batch_size;
171        self
172    }
173
174    /// Set the maximum number of connections in the pool.
175    pub fn max_connections(mut self, n: u32) -> Self {
176        self.max_connections = n;
177        self
178    }
179
180    /// Choose how append-mode rows are shipped (`insert` vs the `copy`
181    /// bulk-load fast-path). See [`PostgresWriteMethod`].
182    pub fn with_write_method(mut self, method: PostgresWriteMethod) -> Self {
183        self.write_method = method;
184        self
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn default_config() {
194        let config = PostgresSinkConfig::new("postgres://localhost/test", "events");
195        assert_eq!(config.table_name, "events");
196        assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
197        assert!(matches!(
198            config.column_mapping,
199            PostgresColumnMapping::Jsonb { ref column } if column == "data"
200        ));
201    }
202
203    #[test]
204    fn builder_methods() {
205        let config = PostgresSinkConfig::new("postgres://localhost/test", "events")
206            .column_mapping(PostgresColumnMapping::AutoMap)
207            .with_batch_size(100);
208        assert_eq!(config.batch_size, 100);
209        assert!(matches!(
210            config.column_mapping,
211            PostgresColumnMapping::AutoMap
212        ));
213    }
214
215    #[test]
216    fn jsonb_custom_column() {
217        let config = PostgresSinkConfig::new("postgres://localhost/test", "events").column_mapping(
218            PostgresColumnMapping::Jsonb {
219                column: "payload".into(),
220            },
221        );
222        assert!(matches!(
223            config.column_mapping,
224            PostgresColumnMapping::Jsonb { ref column } if column == "payload"
225        ));
226    }
227
228    #[test]
229    fn with_batch_size_overrides_default() {
230        let config =
231            PostgresSinkConfig::new("postgres://localhost/test", "events").with_batch_size(250);
232        assert_eq!(config.batch_size, 250);
233    }
234
235    #[test]
236    fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
237        let config =
238            PostgresSinkConfig::new("postgres://localhost/test", "events").with_batch_size(0);
239        assert_eq!(config.batch_size, 0);
240        assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
241    }
242
243    #[test]
244    fn batch_size_above_max_is_rejected_by_validate_batch_size() {
245        let config = PostgresSinkConfig::new("postgres://localhost/test", "events")
246            .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
247        assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
248    }
249
250    #[test]
251    fn batch_size_deserializes_from_json() {
252        let json = r#"{
253            "connection_url": "postgres://localhost/test",
254            "table_name": "events",
255            "column_mapping": {"jsonb": {"column": "data"}},
256            "batch_size": 250,
257            "max_connections": 5
258        }"#;
259        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
260        assert_eq!(config.batch_size, 250);
261    }
262
263    #[test]
264    fn batch_size_defaults_when_absent_in_json() {
265        let json = r#"{
266            "connection_url": "postgres://localhost/test",
267            "table_name": "events",
268            "column_mapping": {"jsonb": {"column": "data"}},
269            "max_connections": 5
270        }"#;
271        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
272        assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
273    }
274
275    #[test]
276    fn config_builder_chaining() {
277        let config = PostgresSinkConfig::new("postgres://localhost/test", "events")
278            .with_batch_size(100)
279            .with_batch_size(250);
280        assert_eq!(config.batch_size, 250);
281    }
282
283    #[test]
284    fn max_connections_and_column_mapping_default_when_absent_in_json() {
285        // Only the two genuinely-required fields are supplied; the pool size and
286        // column mapping must fall back to their documented defaults rather than
287        // failing to deserialize.
288        let json = r#"{
289            "connection_url": "postgres://localhost/test",
290            "table_name": "events"
291        }"#;
292        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
293        assert_eq!(config.max_connections, 5);
294        assert!(matches!(
295            config.column_mapping,
296            PostgresColumnMapping::Jsonb { .. }
297        ));
298        assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
299    }
300
301    #[test]
302    fn max_connections_deserializes_when_present() {
303        let json = r#"{
304            "connection_url": "postgres://localhost/test",
305            "table_name": "events",
306            "max_connections": 20
307        }"#;
308        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
309        assert_eq!(config.max_connections, 20);
310    }
311
312    #[test]
313    fn write_method_defaults_to_insert() {
314        let config = PostgresSinkConfig::new("postgres://localhost/test", "events");
315        assert_eq!(config.write_method, PostgresWriteMethod::Insert);
316
317        // Absent in JSON → insert (back-compat: existing configs unchanged).
318        let json = r#"{
319            "connection_url": "postgres://localhost/test",
320            "table_name": "events"
321        }"#;
322        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
323        assert_eq!(config.write_method, PostgresWriteMethod::Insert);
324    }
325
326    #[test]
327    fn write_method_serde_round_trips() {
328        let json = r#"{
329            "connection_url": "postgres://localhost/test",
330            "table_name": "events",
331            "write_method": "copy"
332        }"#;
333        let config: PostgresSinkConfig = serde_json::from_str(json).unwrap();
334        assert_eq!(config.write_method, PostgresWriteMethod::Copy);
335        let text = serde_json::to_string(&config).unwrap();
336        assert!(text.contains("\"write_method\":\"copy\""));
337    }
338
339    #[test]
340    fn with_write_method_builder() {
341        let config = PostgresSinkConfig::new("postgres://localhost/test", "events")
342            .with_write_method(PostgresWriteMethod::Copy);
343        assert_eq!(config.write_method, PostgresWriteMethod::Copy);
344    }
345}