rivet/config/export.rs
1//! Per-export configuration: query/table/mode, chunking, format, destination link.
2//!
3//! `SchemaDriftPolicy` lives here because it is only ever read via
4//! [`ExportConfig::on_schema_drift`].
5
6use std::path::Path;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::IncrementalCursorMode;
12use super::destination::DestinationConfig;
13use super::format::{CompressionProfile, CompressionType, FormatType, ParquetConfig};
14use super::resolve::{parse_file_size, resolve_vars};
15use crate::tuning::TuningConfig;
16
17/// What to do when structural schema drift is detected (column added, removed, or retyped).
18///
19/// ```yaml
20/// exports:
21/// - name: orders
22/// on_schema_drift: fail # warn (default), continue, fail
23/// ```
24/// How deep `--validate` must verify each part's integrity.
25#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum VerifyMode {
28 /// Accept size-only verification when no content checksum is available.
29 #[default]
30 Size,
31 /// Require every part's content to be MD5-verified against the store's
32 /// listing; fail validation for any part that is only size-verified.
33 Content,
34}
35
36impl VerifyMode {
37 /// Whether content (not just size) verification is required.
38 pub fn requires_content(self) -> bool {
39 matches!(self, VerifyMode::Content)
40 }
41}
42
43#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum SchemaDriftPolicy {
46 /// Log a warning and continue. The new schema fingerprint is stored. (Default.)
47 #[default]
48 Warn,
49 /// Silently accept schema changes — store the new schema, no log output.
50 Continue,
51 /// Abort the run with a non-zero exit. The schema store is NOT updated so the
52 /// next run will detect the same change again.
53 Fail,
54}
55#[derive(Debug, Deserialize, JsonSchema, Clone)]
56#[serde(deny_unknown_fields)]
57pub struct ExportConfig {
58 pub name: String,
59 #[serde(default)]
60 pub query: Option<String>,
61 pub query_file: Option<String>,
62 /// Shortcut for `query: "SELECT * FROM <schema>.<table>"`.
63 ///
64 /// Accepts `table` or `schema.table` with ASCII-only identifiers
65 /// (`[A-Za-z_][A-Za-z0-9_]*`). Generates an unquoted single-table
66 /// query so the Postgres NUMERIC catalog-hint resolver recognises it
67 /// and auto-types `numeric(p,s)` columns without manual overrides.
68 ///
69 /// Mutually exclusive with `query` and `query_file`.
70 #[serde(default)]
71 pub table: Option<String>,
72 /// CDC only: capture **several** tables through ONE change stream (one
73 /// PostgreSQL slot / one MySQL binlog connection) instead of one export —
74 /// and one slot — per table. Each table's parts land under
75 /// `<destination>/<table>/` with their own `manifest.json` + `_SUCCESS`;
76 /// the checkpoint (stream position) is shared. Mutually exclusive with
77 /// `table:`. Not yet supported for SQL Server (capture instances are
78 /// per-table).
79 #[serde(default)]
80 pub tables: Option<Vec<String>>,
81 #[serde(default = "default_mode")]
82 pub mode: ExportMode,
83 /// Change-data-capture settings, required when `mode: cdc`. Reuses the
84 /// export's `table`, `destination`, and `format`; carries only the
85 /// CDC-specific knobs (resume checkpoint, per-engine stream params).
86 #[serde(default)]
87 pub cdc: Option<CdcExportConfig>,
88 pub cursor_column: Option<String>,
89 /// Secondary column for [`IncrementalCursorMode::Coalesce`] only (see ADR-0007).
90 #[serde(default)]
91 pub cursor_fallback_column: Option<String>,
92 /// How primary (and optional fallback) columns drive incremental progression.
93 #[serde(default)]
94 pub incremental_cursor_mode: IncrementalCursorMode,
95 pub chunk_column: Option<String>,
96 #[serde(default)]
97 pub chunk_dense: bool,
98 #[serde(default = "default_chunk_size")]
99 pub chunk_size: usize,
100 /// Target memory budget per chunk in MB. When set, `chunk_size` is derived
101 /// from this budget at plan-build time using a `pg_class` row-size estimate
102 /// (`pg_relation_size / reltuples`), clamped to `[10_000, 5_000_000]` rows.
103 ///
104 /// Mutually exclusive with an explicit non-default `chunk_size:`. Only
105 /// applies to `mode: chunked` on a Postgres source using the `table:`
106 /// shortcut (the row-size probe needs a known relation).
107 ///
108 /// ```yaml
109 /// exports:
110 /// - name: page_views
111 /// table: public.page_views
112 /// mode: chunked
113 /// chunk_size_memory_mb: 256
114 /// ```
115 #[serde(default)]
116 pub chunk_size_memory_mb: Option<u64>,
117 /// Divide the column range into exactly this many equal chunks.
118 /// Mutually exclusive with `chunk_dense` and `chunk_by_days`.
119 /// When set, `chunk_size` is computed dynamically from min/max.
120 pub chunk_count: Option<usize>,
121 pub chunk_by_days: Option<u32>,
122 /// Keyset (seek) pagination on this single index-backed unique key — the
123 /// source-safe shape for tables without a single-integer PK (OPT-4). The
124 /// column MUST be backed by a usable index (PK or unique); the planner
125 /// refuses a non-indexed key rather than emit a full-scan + filesort query.
126 pub chunk_by_key: Option<String>,
127 /// Concurrent chunk/page workers (default 1). On a RANGE chunk (`chunk_column`)
128 /// or KEYSET (`chunk_by_key`) export, `parallel: N` fans the table into N
129 /// ROW-percentile ranges that seek concurrently over separate connections — the
130 /// half-open intervals partition the key, so the union reads every row exactly
131 /// once (structural parity, all engines). Extraction is I/O-bound, so the win
132 /// plateaus early (~3x at N=4, little beyond). SWEET SPOT: indexed tables up to
133 /// ~10M rows at `parallel: 4`. `rivet init` scaffolds a row-scaled value
134 /// (<=500K -> 1, <5M -> 2, >=5M -> 4); a preflight warns past ~5M rows (peak RSS
135 /// ~= N x chunk_size). Beyond ~10M the KEYSET boundary sampler (an index OFFSET
136 /// skip) grows costly at setup — prefer a range `chunk_column` there.
137 #[serde(default = "default_parallel")]
138 pub parallel: usize,
139
140 /// Advisory execution wave (1 = highest priority, run first). Written by
141 /// `rivet plan` from the source-aware prioritization score (see ADR-0006)
142 /// and consumed by `rivet apply`, which runs exports wave-by-wave in
143 /// ascending order. `None` = unscheduled (apply treats it as the last wave).
144 /// Operators may hand-edit it; a later `rivet plan` refreshes it in place.
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub wave: Option<u32>,
147
148 /// Whether this export is cheap enough to run concurrently with its
149 /// wave-mates under `rivet apply --parallel-export-processes`. Written by
150 /// `rivet plan` (true when the source-aware cost class is `Low`, i.e.
151 /// < ~100K rows); a heavier table already chunk-parallelizes internally, so
152 /// two of them at once would overload the source. `None`/`false` → the
153 /// export runs alone within its wave. Operators may hand-edit it; a later
154 /// `rivet plan` refreshes it in place.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub parallel_safe: Option<bool>,
157 pub time_column: Option<String>,
158 #[serde(default = "default_time_column_type")]
159 pub time_column_type: TimeColumnType,
160 pub days_window: Option<u32>,
161
162 /// Date/time output partitioning: split this export's rows into one
163 /// destination sub-prefix per calendar bucket of this **DATE or TIMESTAMP**
164 /// column, bucketed by `partition_granularity`
165 /// (`day` / `month` / `year`), in a Hive-style `col=value/` layout
166 /// (`created_at=2023-01-01/`, `created_at=2023-01/`, `created_at=2023/`).
167 /// Requires a `{partition}` token in `destination.path` /
168 /// `destination.prefix`.
169 ///
170 /// This is **not** arbitrary value partitioning: the column's min/max is
171 /// read and parsed as a date to generate contiguous calendar buckets, so a
172 /// non-temporal column (e.g. `partition_by: status`) fails at run time with
173 /// "could not parse partition min `<value>` from column `<col>` as a date".
174 /// To split by a categorical column, write one export per value with a
175 /// `WHERE` filter instead.
176 ///
177 /// Orthogonal to `mode`: each partition runs the export's own mode, so
178 /// `mode: chunked` chunks *within* a day. Rows whose partition column is
179 /// NULL land in `col=__HIVE_DEFAULT_PARTITION__/` (Hive default partition)
180 /// so no row is silently dropped. Not compatible with `mode: time_window`.
181 ///
182 /// ```yaml
183 /// exports:
184 /// - name: events
185 /// table: events
186 /// partition_by: created_at # must be a DATE or TIMESTAMP column
187 /// partition_granularity: day
188 /// destination:
189 /// type: s3
190 /// bucket: my-bucket
191 /// prefix: "events/{partition}/" # → events/created_at=2023-01-01/
192 /// ```
193 #[serde(default)]
194 pub partition_by: Option<String>,
195
196 /// Calendar bucket width for `partition_by`:
197 /// `day` (default), `month`, or `year`. Determines how the partition
198 /// column's date/timestamp range is split into contiguous Hive buckets
199 /// (`col=2023-01-01/` / `col=2023-01/` / `col=2023/`). Has no effect
200 /// unless `partition_by` is set.
201 #[serde(default)]
202 pub partition_granularity: PartitionGranularity,
203 pub format: FormatType,
204 #[serde(default)]
205 pub compression: CompressionType,
206 pub compression_level: Option<u32>,
207 pub compression_profile: Option<CompressionProfile>,
208 #[serde(default)]
209 pub skip_empty: bool,
210 pub destination: DestinationConfig,
211 /// Integrity depth required of `--validate` for this export's parts.
212 /// `size` (default) accepts size-only verification; `content` requires every
213 /// part's content MD5 to be checked against the store's listing (no
214 /// download) and **fails** validation for any part that could only be
215 /// size-verified — e.g. a part too large to upload as a single PUT (raise
216 /// `max_file_size` down so it fits), or a backend that exposes no checksum.
217 #[serde(default)]
218 pub verify: VerifyMode,
219 #[serde(default)]
220 pub meta_columns: MetaColumns,
221 #[serde(default)]
222 pub quality: Option<QualityConfig>,
223 /// Rotate to a new part when the current file reaches this size.
224 /// Accepts `B`/`KB`/`MB`/`GB` (case-insensitive) or a bare byte count;
225 /// a fractional value is allowed (`1.5GB`). Units are binary (IEC-style):
226 /// `KB` = 1024 bytes, `MB` = 1024 KB, `GB` = 1024 MB. Example: `256MB`.
227 pub max_file_size: Option<String>,
228 /// Persist per-chunk / per-page progress so a **crashed** run resumes from the
229 /// last durably committed point instead of re-reading from the start. This is
230 /// pure crash-recovery: a *clean* re-run (the prior run finished) still does a
231 /// full pass — it never silently skips already-exported rows. Safe to enable
232 /// on any table; `rivet init` defaults it on for chunked and keyset exports.
233 #[serde(default)]
234 pub chunk_checkpoint: bool,
235 /// Keyset only (`chunk_by_key`): on a **clean** re-run, continue from the last
236 /// exported key — pull ONLY rows with a key past the high-water mark. This is
237 /// incremental-by-key, correct ONLY for APPEND-ONLY tables (a mutable row whose
238 /// key already passed is silently never re-read). Opt-in and off by default;
239 /// crash-recovery does not need it (that is `chunk_checkpoint`). For a mutable
240 /// table use `mode: incremental` on a timestamp cursor instead.
241 #[serde(default)]
242 pub keyset_incremental: bool,
243 pub chunk_max_attempts: Option<u32>,
244 #[serde(default)]
245 pub tuning: Option<TuningConfig>,
246 /// Optional logical group for shared source capacity (replica, host). Advisory prioritization only.
247 #[serde(default)]
248 pub source_group: Option<String>,
249 /// Hint (Epic C / ADR-0006) that this export should always be treated as reconcile-heavy
250 /// by planning, independent of the `--reconcile` CLI flag. Advisory only.
251 #[serde(default)]
252 pub reconcile_required: bool,
253
254 /// Per-column type overrides (roadmap §8). Keys are column names; values
255 /// are short type strings such as `decimal(18,2)`, `timestamp_tz`, `json`.
256 ///
257 /// ```yaml
258 /// exports:
259 /// - name: payments
260 /// columns:
261 /// amount: decimal(18,2)
262 /// fee: decimal(18,6)
263 /// created_at: timestamp_tz
264 /// ```
265 ///
266 /// Overrides take priority over autodetection and are validated at
267 /// plan time — an invalid type string fails before the export runs.
268 #[serde(default)]
269 pub columns: std::collections::HashMap<String, String>,
270
271 /// Downstream warehouse this export targets (`bigquery` / `bq`,
272 /// `duckdb`). When set, `rivet check --type-report` resolves each column
273 /// against it (native type, honest autoload type, recovery hint) without
274 /// needing `--target` on the CLI — the CLI flag still wins when both are
275 /// present. The Parquet interchange stays target-neutral (ADR-0014 T2);
276 /// `target:` only drives guidance and the future load-schema artifact.
277 ///
278 /// ```yaml
279 /// exports:
280 /// - name: payments
281 /// target: bigquery
282 /// ```
283 #[serde(default)]
284 pub target: Option<String>,
285
286 /// Per-export overrides for the top-level `load:` block (`pk`,
287 /// `cleanup_source`, `gc_orphans`, `cluster_by`, `allow_source_drift`); any
288 /// field omitted here inherits the top-level value. The warehouse `target`
289 /// is shared and stays in the top-level `load:` — it cannot be overridden
290 /// per export.
291 ///
292 /// ```yaml
293 /// load: { target: bigquery, project: p, dataset: d } # shared default
294 /// exports:
295 /// - name: orders
296 /// table: orders
297 /// mode: cdc
298 /// load: { pk: [id] } # this table's pk
299 /// ```
300 ///
301 /// Raw JSON (parsed by the load module) so `config` carries no load types —
302 /// mirrors the top-level [`crate::config::Config::load`].
303 #[serde(default)]
304 pub load: Option<serde_json::Value>,
305
306 /// Policy applied when structural schema drift is detected (column added, removed, or retyped).
307 /// Defaults to `warn`: log a warning and continue.
308 #[serde(default)]
309 pub on_schema_drift: SchemaDriftPolicy,
310
311 /// Growth-factor threshold for data shape drift warnings (Epic 8).
312 /// When a string/binary column's max observed byte length in the current run
313 /// exceeds `stored_max * shape_drift_warn_factor`, Rivet logs a warning.
314 /// `None` uses the default of 2.0. Set to `0.0` to disable shape tracking.
315 ///
316 /// **Scope: single-batch exports only** (`mode: full` / `incremental`). Shape
317 /// tracking needs the run-wide per-column max byte length, which only the
318 /// single sink accumulates; the multi-part runners (chunked, keyset, and
319 /// parallel-Mongo) each write through several sinks and do not aggregate it,
320 /// so this factor is inert there. (Schema drift — added/dropped/retyped
321 /// columns — IS enforced on every path via `on_schema_drift`.)
322 #[serde(default)]
323 pub shape_drift_warn_factor: Option<f64>,
324
325 /// Parquet row group tuning. Only meaningful when `format: parquet`.
326 /// When absent, the parquet library default (1,048,576 rows/group) is used.
327 #[serde(default)]
328 pub parquet: Option<ParquetConfig>,
329}
330
331impl ExportConfig {
332 /// Resolve the effective `(CompressionType, level)` for this export.
333 /// `compression_profile` takes precedence over `compression` + `compression_level`.
334 ///
335 /// L24: when a profile is set *and* a conflicting explicit codec/level was
336 /// written, warn once that the profile wins rather than silently dropping the
337 /// explicit choice. An explicit codec is only detectable when it differs from
338 /// the `#[serde(default)]` (Zstd) — a literal `compression: zstd` alongside a
339 /// profile is indistinguishable from an omitted field and stays silent.
340 pub fn effective_compression(&self) -> (CompressionType, Option<u32>) {
341 if let Some(profile) = self.compression_profile {
342 let explicit_codec =
343 (self.compression != CompressionType::default()).then_some(self.compression);
344 if let Some(msg) = super::format::compression_profile_override_warning(
345 profile,
346 explicit_codec,
347 self.compression_level,
348 ) {
349 log::warn!("export '{}': {}", self.name, msg);
350 }
351 profile.to_codec()
352 } else {
353 (self.compression, self.compression_level)
354 }
355 }
356
357 pub fn max_file_size_bytes(&self) -> Option<u64> {
358 self.max_file_size
359 .as_ref()
360 .and_then(|s| parse_file_size(s).ok())
361 }
362
363 pub fn resolve_query(
364 &self,
365 config_dir: &Path,
366 params: Option<&std::collections::HashMap<String, String>>,
367 ) -> crate::error::Result<String> {
368 // table: shortcut takes precedence — already validated by
369 // `validate_business_rules` to be mutually exclusive with query/query_file.
370 if let Some(tbl) = &self.table {
371 validate_table_shortcut_ident(&self.name, tbl)?;
372 return Ok(format!("SELECT * FROM {tbl}"));
373 }
374 match (&self.query, &self.query_file) {
375 (Some(q), None) => {
376 if params.is_some() {
377 resolve_vars(q, params)
378 } else {
379 Ok(q.clone())
380 }
381 }
382 (None, Some(file)) => {
383 let file_path = std::path::Path::new(file);
384 // SecOps: block absolute paths and `..` traversal components.
385 if file_path.is_absolute() {
386 anyhow::bail!(
387 "export '{}': query_file must be a relative path: '{}'",
388 self.name,
389 file
390 );
391 }
392 if file_path
393 .components()
394 .any(|c| c == std::path::Component::ParentDir)
395 {
396 anyhow::bail!(
397 "export '{}': query_file path must not contain '..': '{}'",
398 self.name,
399 file
400 );
401 }
402 let joined = config_dir.join(file);
403 // Canonicalize-based check catches symlink-based evasion for files
404 // that already exist on disk.
405 if let Ok(canonical) = joined.canonicalize() {
406 let base = config_dir
407 .canonicalize()
408 .unwrap_or_else(|_| config_dir.to_path_buf());
409 if !canonical.starts_with(&base) {
410 anyhow::bail!(
411 "export '{}': query_file '{}' resolves outside the config directory",
412 self.name,
413 file
414 );
415 }
416 }
417 let raw = std::fs::read_to_string(&joined)?;
418 resolve_vars(&raw, params)
419 }
420 (Some(_), Some(_)) => {
421 anyhow::bail!(
422 "export '{}': specify either 'query' or 'query_file', not both",
423 self.name
424 )
425 }
426 (None, None) => {
427 anyhow::bail!(
428 "export '{}': must specify exactly one of 'query', 'query_file', or 'table'",
429 self.name
430 )
431 }
432 }
433 }
434}
435
436/// Validate the value of the `table:` YAML shortcut.
437///
438/// Accepts ASCII identifiers in the form `<table>` or `<schema>.<table>`. Each
439/// segment must match `[A-Za-z_][A-Za-z0-9_]*`. Anything else (quoted
440/// identifiers, exotic chars, three-part names, SQL injection attempts) is
441/// rejected — the user should fall back to `query:` for those cases.
442///
443/// The bound on identifier shape keeps generated SQL safe to interpolate
444/// without quoting and ensures the generated `SELECT * FROM <ident>` form is
445/// recognised by the PG catalog-hint parser ([src/source/postgres.rs]).
446fn validate_table_shortcut_ident(export_name: &str, raw: &str) -> crate::error::Result<()> {
447 let trimmed = raw.trim();
448 if trimmed.is_empty() {
449 anyhow::bail!("export '{export_name}': 'table' is empty");
450 }
451 let parts: Vec<&str> = trimmed.split('.').collect();
452 if parts.len() > 2 {
453 anyhow::bail!(
454 "export '{export_name}': 'table' must be `<name>` or `<schema>.<name>` (got '{raw}')"
455 );
456 }
457 for part in &parts {
458 if part.is_empty() {
459 anyhow::bail!("export '{export_name}': 'table' has an empty segment in '{raw}'");
460 }
461 let mut chars = part.chars();
462 let first = chars.next().unwrap();
463 if !(first.is_ascii_alphabetic() || first == '_') {
464 anyhow::bail!(
465 "export '{export_name}': 'table' segment '{part}' must start with a letter or underscore (use 'query:' for quoted identifiers)"
466 );
467 }
468 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
469 anyhow::bail!(
470 "export '{export_name}': 'table' segment '{part}' contains non-identifier characters (use 'query:' for quoted identifiers)"
471 );
472 }
473 }
474 Ok(())
475}
476
477#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
478#[serde(deny_unknown_fields)]
479pub struct QualityConfig {
480 pub row_count_min: Option<usize>,
481 pub row_count_max: Option<usize>,
482 #[serde(default)]
483 pub null_ratio_max: std::collections::HashMap<String, f64>,
484 #[serde(default)]
485 pub unique_columns: Vec<String>,
486 /// Cap on the number of distinct values tracked per column during uniqueness checks.
487 /// When the limit is hit, a Warn issue is emitted and tracking stops for that column.
488 /// Prevents unbounded HashSet growth on high-cardinality columns.
489 pub unique_max_entries: Option<usize>,
490}
491
492/// What `_rivet_row_hash` covers.
493///
494/// `true` folds every column of the export's result set — the original
495/// behaviour, kept because it is what existing configs say. It is the weaker
496/// form: neither side of a later comparison can state WHICH columns were
497/// hashed, so coverage has to be inferred from the query, and a column whose
498/// value legitimately differs between two systems (a load timestamp, a
499/// surrogate key) cannot be excluded without dropping it from the export.
500///
501/// A declared list fixes both. The covered set is recorded in the run
502/// manifest, so a reader can check coverage instead of assuming it.
503///
504/// ```yaml
505/// meta_columns:
506/// row_hash: [id, status, updated_at] # or: row_hash: true
507/// ```
508#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq, Eq)]
509#[serde(untagged)]
510pub enum RowHash {
511 /// `row_hash: true` / `false` — every column, or none.
512 All(bool),
513 /// `row_hash: [a, b]` — exactly these, in this order. Order matters: it is
514 /// the order the canonical text is built in.
515 Columns(Vec<String>),
516}
517
518impl Default for RowHash {
519 fn default() -> Self {
520 RowHash::All(false)
521 }
522}
523
524impl RowHash {
525 /// Whether a hash column is emitted at all.
526 pub fn enabled(&self) -> bool {
527 match self {
528 RowHash::All(b) => *b,
529 // An empty list is a config error, not "off" — `enabled()` says
530 // yes so validation can reject it with a message, rather than
531 // silently producing an export with no hash the operator asked for.
532 RowHash::Columns(_) => true,
533 }
534 }
535
536 /// The declared column list, if the operator gave one.
537 pub fn declared(&self) -> Option<&[String]> {
538 match self {
539 RowHash::Columns(c) => Some(c),
540 RowHash::All(_) => None,
541 }
542 }
543
544 /// Config-time validation — the shape errors that need no schema.
545 pub fn validate(&self, export_name: &str) -> crate::error::Result<()> {
546 let Some(cols) = self.declared() else {
547 return Ok(());
548 };
549 if cols.is_empty() {
550 anyhow::bail!(
551 "export '{export_name}': meta_columns.row_hash is an empty list — that attests \
552 nothing. Use `row_hash: true` for every column, or name the columns to cover."
553 );
554 }
555 let mut seen = std::collections::HashSet::new();
556 if let Some(dup) = cols.iter().find(|c| !seen.insert(c.as_str())) {
557 anyhow::bail!(
558 "export '{export_name}': meta_columns.row_hash lists '{dup}' twice — the covered \
559 set must be a set"
560 );
561 }
562 Ok(())
563 }
564}
565
566#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
567#[serde(deny_unknown_fields)]
568pub struct MetaColumns {
569 #[serde(default)]
570 pub exported_at: bool,
571 #[serde(default)]
572 pub row_hash: RowHash,
573}
574
575// `any_enabled()` lived here to answer "does the CDC path need to warn that
576// meta columns were dropped". It has no callers since §5h: the two fields no
577// longer share an answer — `exported_at` is still dropped on the CDC leg,
578// `row_hash` is emitted there — so every caller now asks about one of them
579// specifically. Reintroducing a combined predicate would just invite the
580// blanket warning back.
581
582fn default_mode() -> ExportMode {
583 ExportMode::Full
584}
585
586pub(crate) fn default_chunk_size() -> usize {
587 100_000
588}
589
590fn default_parallel() -> usize {
591 1
592}
593
594fn default_time_column_type() -> TimeColumnType {
595 TimeColumnType::Timestamp
596}
597
598/// `until_current` defaults to `true` — the OSS model is the BOUNDED, scheduler-
599/// driven drain ("read to the log end and exit"). Continuous streaming
600/// (`until_current: false`) is an explicit opt-in; making it the default silently
601/// put a hand-written CDC config onto the never-terminating streaming path.
602fn default_true() -> bool {
603 true
604}
605
606#[derive(Debug, Deserialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
607#[serde(rename_all = "snake_case")]
608pub enum ExportMode {
609 Full,
610 Incremental,
611 Chunked,
612 TimeWindow,
613 /// Log-based change data capture (see [`CdcExportConfig`]): stream
614 /// INSERT/UPDATE/DELETE from the source's transaction log instead of querying
615 /// the table. Reuses the export's `table` / `destination` / `format`.
616 Cdc,
617}
618
619/// Default PostgreSQL logical slot when `cdc.slot` is omitted — shared by the
620/// runner ([`crate::pipeline`]'s cdc job) and config validation, so the
621/// same-slot conflict check sees the value that will actually be used.
622pub const DEFAULT_PG_SLOT: &str = "rivet_slot";
623/// Default MySQL replica `server_id` when `cdc.server_id` is omitted (see
624/// [`DEFAULT_PG_SLOT`] for why this is a shared const).
625pub const DEFAULT_MYSQL_SERVER_ID: u32 = 4271;
626
627/// What the FIRST CDC run does before draining changes.
628#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
629#[serde(rename_all = "lowercase")]
630pub enum CdcInitialMode {
631 /// Anchor-then-snapshot: create the resume anchor (PostgreSQL slot /
632 /// MySQL binlog checkpoint / SQL Server LSN checkpoint) FIRST, then run a
633 /// full batch snapshot of each table into `<destination>[/<table>]/snapshot/`,
634 /// then drain CDC. Because the anchor predates the snapshot read, anything
635 /// changed during the snapshot also appears in the change stream — an
636 /// overlap (dedupe by PK + `__op`), never a gap. The safe switch ordering,
637 /// enforced by construction instead of operator discipline.
638 Snapshot,
639 // NOTE: `adopt` (anchor an already-loaded table, move no rows) is NOT an OSS
640 // mode — it belongs to the paid tier, whose audit is what consumes the
641 // anchor (the one-way extension seam, ADR-0026; docs may name the crate,
642 // this tree may not). Adding a variant here would put it in the published
643 // config schema, the generated reference and the OSS binary — irreversibly,
644 // once the crate is published. `oss_rejects_the_pro_only_initial_adopt` is
645 // the guard.
646}
647
648/// Per-export CDC settings, required when `mode: cdc`. The output `table`,
649/// `destination`, and `format` come from the export itself; this carries only the
650/// CDC-specific knobs (resume + per-engine stream params).
651#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
652pub struct CdcExportConfig {
653 /// First-run behaviour: `snapshot` = anchor → full snapshot → drain (see
654 /// [`CdcInitialMode`]). Omitted ⇒ capture changes only, with no anchor step
655 /// (the default; the operator owns the initial load).
656 ///
657 /// `snapshot` anchors, and on engines with no server-side anchor (MySQL, SQL
658 /// Server) that makes `checkpoint:` mandatory — the checkpoint file IS the
659 /// anchor there.
660 ///
661 /// This doc line is what the generated config reference renders, so every
662 /// accepted value must be explained HERE: the reference lists the variants
663 /// from the enum but describes only this sentence, so an explanation left on
664 /// a variant alone documents a value the reader is told exists and never told
665 /// the meaning of.
666 #[serde(default)]
667 pub initial: Option<CdcInitialMode>,
668 /// Persist/resume the source log position to this file. Omit to tail from the
669 /// current position without checkpointing.
670 pub checkpoint: Option<String>,
671 /// Catch up to the source's current end and exit (a bounded run), instead of
672 /// streaming indefinitely — ideal for a scheduler. For MySQL this is a
673 /// non-blocking binlog dump; PostgreSQL / SQL Server already drain-and-exit.
674 /// **Defaults to `true`** (bounded): the OSS model is scheduler-driven, and
675 /// omitting this must NOT silently start a never-terminating stream. Set it to
676 /// `false` to opt into continuous streaming explicitly.
677 #[serde(default = "default_true")]
678 pub until_current: bool,
679 /// Stop after N change events (default: until end of stream / interrupted).
680 pub max_events: Option<usize>,
681 /// Rows per output part file (default 100000). A part also rolls at a
682 /// transaction boundary, so it never splits a transaction. Larger ⇒ fewer,
683 /// bigger files but more drain memory — the PostgreSQL peek reads a part's
684 /// worth per batch, so drain RSS is O(rollover). Tune per workload: raise it
685 /// to cut file count, lower it to cap memory on a small extractor.
686 pub rollover: Option<usize>,
687 /// Roll a part once its buffered changes reach this many MB, whichever comes
688 /// first with `rollover`. Caps the in-memory buffer and the part file size by
689 /// bytes instead of a fixed row count — predictable for tables with wide
690 /// (large JSON / blob) rows, mirroring the batch path's `batch_size_memory_mb`.
691 pub rollover_memory_mb: Option<usize>,
692 /// MySQL replica server-id for the binlog connection (default 4271; must be
693 /// distinct from the source's and any other replica).
694 pub server_id: Option<u32>,
695 /// PostgreSQL logical replication slot name (default `rivet_slot`).
696 pub slot: Option<String>,
697 /// SQL Server CDC capture instance, e.g. `dbo_orders` — required for
698 /// `sqlserver://` sources.
699 pub capture_instance: Option<String>,
700}
701
702// Hand-written so the Rust `Default` MATCHES the serde default: `until_current`
703// must be `true` (bounded). The derived `Default` would use `bool::default()` =
704// `false`, and serde's `default = "default_true"` only affects Deserialize — so
705// `CdcExportConfig::default()` would silently mean `DrainMode::Continuous`. That
706// default is reached on the drain path (`cdc_job.rs`: `export.cdc.clone()
707// .unwrap_or_default()`) whenever a `mode: cdc` export omits the whole `cdc:`
708// block (valid for PG/MySQL), turning a minimal bounded drain into a
709// never-terminating daemon that persists no resume position — the exact footgun
710// `default_true` was added to kill. Mirrors `MongoConfig`'s hand-written Default.
711impl Default for CdcExportConfig {
712 fn default() -> Self {
713 Self {
714 initial: None,
715 checkpoint: None,
716 until_current: true,
717 max_events: None,
718 rollover: None,
719 rollover_memory_mb: None,
720 server_id: None,
721 slot: None,
722 capture_instance: None,
723 }
724 }
725}
726
727#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
728#[serde(rename_all = "lowercase")]
729pub enum TimeColumnType {
730 Timestamp,
731 Unix,
732}
733
734/// Calendar bucket width for date/timestamp output partitioning
735/// ([`ExportConfig::partition_by`]). The partition column must be a DATE or
736/// TIMESTAMP column; this picks how its range is split into contiguous Hive
737/// buckets. It is not a knob for partitioning by arbitrary column values.
738#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
739#[serde(rename_all = "lowercase")]
740pub enum PartitionGranularity {
741 /// One bucket per calendar day (`col=2023-01-01/`). Default.
742 #[default]
743 Day,
744 /// One bucket per calendar month (`col=2023-01/`).
745 Month,
746 /// One bucket per calendar year (`col=2023/`).
747 Year,
748}
749
750/// Canonical fully-populated [`ExportConfig`] for tests across the crate.
751///
752/// One place lists every field, so adding a field is a single-site edit (the
753/// compiler still flags this literal if a field is missing). Test call sites
754/// take this baseline and override only the fields they exercise, rather than
755/// hand-writing the full struct — see `plan::build` and `preflight` tests.
756#[cfg(test)]
757pub(crate) fn sample_export(name: &str) -> ExportConfig {
758 ExportConfig {
759 name: name.into(),
760 target: None,
761 load: None,
762 verify: VerifyMode::Size,
763 query: Some("SELECT 1".into()),
764 query_file: None,
765 table: None,
766 tables: None,
767 mode: ExportMode::Full,
768 cdc: None,
769 cursor_column: None,
770 cursor_fallback_column: None,
771 incremental_cursor_mode: Default::default(),
772 chunk_column: None,
773 chunk_dense: false,
774 chunk_size: 100_000,
775 chunk_size_memory_mb: None,
776 chunk_count: None,
777 chunk_by_days: None,
778 chunk_by_key: None,
779 parallel: 1,
780 wave: None,
781 parallel_safe: None,
782 time_column: None,
783 time_column_type: TimeColumnType::Timestamp,
784 days_window: None,
785 partition_by: None,
786 partition_granularity: PartitionGranularity::Day,
787 format: FormatType::Parquet,
788 compression: CompressionType::None,
789 compression_level: None,
790 compression_profile: None,
791 skip_empty: false,
792 destination: crate::config::DestinationConfig {
793 destination_type: crate::config::DestinationType::Local,
794 path: Some("/tmp".into()),
795 ..Default::default()
796 },
797 meta_columns: MetaColumns::default(),
798 quality: None,
799 max_file_size: None,
800 chunk_checkpoint: false,
801 keyset_incremental: false,
802 chunk_max_attempts: None,
803 tuning: None,
804 source_group: None,
805 reconcile_required: false,
806 columns: Default::default(),
807 on_schema_drift: Default::default(),
808 shape_drift_warn_factor: None,
809 parquet: None,
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 // ── ExportConfig::max_file_size_bytes ───────────────────────────────────
818
819 fn make_export_yaml(name: &str, extra: &str) -> ExportConfig {
820 let yaml = format!(
821 "name: {name}\nquery: \"SELECT 1\"\nformat: parquet\ndestination:\n type: local\n path: /tmp\n{extra}"
822 );
823 serde_yaml_ng::from_str(&yaml).expect("parse ExportConfig")
824 }
825
826 #[test]
827 fn max_file_size_bytes_none_when_unset() {
828 let exp = make_export_yaml("no_limit", "");
829 assert!(exp.max_file_size_bytes().is_none());
830 }
831
832 #[test]
833 fn max_file_size_bytes_parses_mb() {
834 let exp = make_export_yaml("sized", "max_file_size: \"128MB\"\n");
835 assert_eq!(exp.max_file_size_bytes(), Some(128 * 1024 * 1024));
836 }
837
838 #[test]
839 fn max_file_size_bytes_parses_gb() {
840 let exp = make_export_yaml("sized_gb", "max_file_size: \"2GB\"\n");
841 assert_eq!(exp.max_file_size_bytes(), Some(2 * 1024 * 1024 * 1024));
842 }
843
844 #[test]
845 fn max_file_size_bytes_returns_none_on_invalid() {
846 let exp = make_export_yaml("bad_size", "max_file_size: \"notanumber\"\n");
847 assert!(exp.max_file_size_bytes().is_none());
848 }
849
850 // ── ExportConfig::resolve_query ─────────────────────────────────────────
851
852 // Build a minimal ExportConfig directly, bypassing Config::from_yaml validation.
853 // This lets us test the four branches inside resolve_query itself, including
854 // the (both-set / neither-set) error paths that are normally prevented by the
855 // top-level validator.
856 fn make_export_direct(query: Option<&str>, query_file: Option<&str>) -> ExportConfig {
857 ExportConfig {
858 query: query.map(|s| s.to_string()),
859 query_file: query_file.map(|s| s.to_string()),
860 ..sample_export("test")
861 }
862 }
863
864 fn params(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
865 pairs
866 .iter()
867 .map(|(k, v)| (k.to_string(), v.to_string()))
868 .collect()
869 }
870
871 #[test]
872 fn resolve_query_inline_no_params_returns_query_as_is() {
873 let exp = make_export_direct(Some("SELECT id FROM orders"), None);
874 let q = exp.resolve_query(Path::new("/tmp"), None).unwrap();
875 assert_eq!(q, "SELECT id FROM orders");
876 }
877
878 #[test]
879 fn resolve_query_inline_with_params_substitutes_vars() {
880 let exp = make_export_direct(Some("SELECT ${col} FROM ${table}"), None);
881 let p = params(&[("col", "id"), ("table", "orders")]);
882 let q = exp.resolve_query(Path::new("/tmp"), Some(&p)).unwrap();
883 assert_eq!(q, "SELECT id FROM orders");
884 }
885
886 #[test]
887 fn resolve_query_inline_params_empty_map_is_noop() {
888 let exp = make_export_direct(Some("SELECT 1"), None);
889 let p = params(&[]);
890 let q = exp.resolve_query(Path::new("/tmp"), Some(&p)).unwrap();
891 assert_eq!(q, "SELECT 1");
892 }
893
894 #[test]
895 fn resolve_query_inline_missing_var_returns_error() {
896 // SAFETY: test-only; this binary is single-threaded in the test runner context.
897 unsafe { std::env::remove_var("UNSET_RIVET_TEST_VAR") };
898 let exp = make_export_direct(Some("SELECT ${UNSET_RIVET_TEST_VAR}"), None);
899 let p = params(&[]);
900 let result = exp.resolve_query(Path::new("/tmp"), Some(&p));
901 assert!(result.is_err());
902 let msg = format!("{:#}", result.unwrap_err());
903 assert!(
904 msg.contains("UNSET_RIVET_TEST_VAR") || msg.contains("not set"),
905 "got: {msg}"
906 );
907 }
908
909 #[test]
910 fn resolve_query_file_reads_content() {
911 let dir = tempfile::TempDir::new().unwrap();
912 let sql_path = dir.path().join("query.sql");
913 std::fs::write(&sql_path, "SELECT * FROM customers").unwrap();
914 let exp = make_export_direct(None, Some("query.sql"));
915 let q = exp.resolve_query(dir.path(), None).unwrap();
916 assert_eq!(q, "SELECT * FROM customers");
917 }
918
919 #[test]
920 fn resolve_query_file_with_params_substitutes() {
921 let dir = tempfile::TempDir::new().unwrap();
922 let sql_path = dir.path().join("q.sql");
923 std::fs::write(&sql_path, "SELECT ${col} FROM ${tbl}").unwrap();
924 let exp = make_export_direct(None, Some("q.sql"));
925 let p = params(&[("col", "name"), ("tbl", "users")]);
926 let q = exp.resolve_query(dir.path(), Some(&p)).unwrap();
927 assert_eq!(q, "SELECT name FROM users");
928 }
929
930 // ── `table:` shortcut ───────────────────────────────────────────────────
931
932 #[test]
933 fn resolve_query_table_shortcut_qualified() {
934 let mut exp = make_export_direct(None, None);
935 exp.table = Some("public.users".into());
936 let q = exp.resolve_query(Path::new("/tmp"), None).unwrap();
937 assert_eq!(q, "SELECT * FROM public.users");
938 }
939
940 #[test]
941 fn resolve_query_table_shortcut_unqualified() {
942 let mut exp = make_export_direct(None, None);
943 exp.table = Some("orders".into());
944 let q = exp.resolve_query(Path::new("/tmp"), None).unwrap();
945 assert_eq!(q, "SELECT * FROM orders");
946 }
947
948 #[test]
949 fn resolve_query_table_shortcut_rejects_three_part_name() {
950 let mut exp = make_export_direct(None, None);
951 exp.table = Some("db.public.users".into());
952 let err = exp.resolve_query(Path::new("/tmp"), None).unwrap_err();
953 let msg = format!("{err:#}");
954 assert!(msg.contains("<schema>.<name>"), "got: {msg}");
955 }
956
957 #[test]
958 fn resolve_query_table_shortcut_rejects_sql_injection() {
959 for bad in [
960 "users; DROP TABLE x",
961 "users--",
962 "users'",
963 "users\"",
964 "public.\"My Table\"",
965 "0starts_with_digit",
966 "",
967 ".trailing",
968 "leading.",
969 "two..dots",
970 ] {
971 let mut exp = make_export_direct(None, None);
972 exp.table = Some(bad.into());
973 assert!(
974 exp.resolve_query(Path::new("/tmp"), None).is_err(),
975 "should reject `table:` value '{bad}'",
976 );
977 }
978 }
979
980 #[test]
981 fn resolve_query_table_shortcut_takes_precedence_over_query() {
982 let mut exp = make_export_direct(Some("SELECT id FROM x"), None);
983 exp.table = Some("public.y".into());
984 let q = exp.resolve_query(Path::new("/tmp"), None).unwrap();
985 assert_eq!(q, "SELECT * FROM public.y");
986 }
987
988 #[test]
989 fn resolve_query_file_missing_returns_error() {
990 let dir = tempfile::TempDir::new().unwrap();
991 let exp = make_export_direct(None, Some("nonexistent.sql"));
992 let result = exp.resolve_query(dir.path(), None);
993 assert!(result.is_err());
994 let msg = format!("{:#}", result.unwrap_err());
995 assert!(
996 msg.contains("nonexistent.sql") || msg.contains("No such file"),
997 "got: {msg}"
998 );
999 }
1000
1001 #[test]
1002 fn resolve_query_both_set_returns_error() {
1003 let mut exp = make_export_direct(Some("SELECT 1"), None);
1004 exp.query_file = Some("file.sql".into());
1005 let result = exp.resolve_query(Path::new("/tmp"), None);
1006 assert!(result.is_err());
1007 let msg = format!("{:#}", result.unwrap_err());
1008 assert!(
1009 msg.contains("not both") || msg.contains("query_file"),
1010 "got: {msg}"
1011 );
1012 }
1013
1014 #[test]
1015 fn resolve_query_neither_set_returns_error() {
1016 let exp = make_export_direct(None, None);
1017 let result = exp.resolve_query(Path::new("/tmp"), None);
1018 assert!(result.is_err());
1019 let msg = format!("{:#}", result.unwrap_err());
1020 assert!(
1021 msg.contains("query") || msg.contains("query_file"),
1022 "got: {msg}"
1023 );
1024 }
1025
1026 // ── SecOps: query_file path traversal prevention ──────────────────────────
1027
1028 #[test]
1029 fn resolve_query_file_dotdot_is_rejected() {
1030 let dir = tempfile::TempDir::new().unwrap();
1031 let exp = make_export_direct(None, Some("../secret.sql"));
1032 let result = exp.resolve_query(dir.path(), None);
1033 assert!(result.is_err());
1034 let msg = format!("{:#}", result.unwrap_err());
1035 assert!(
1036 msg.contains("..") || msg.contains("traversal"),
1037 "got: {msg}"
1038 );
1039 }
1040
1041 #[test]
1042 fn resolve_query_file_nested_dotdot_is_rejected() {
1043 let dir = tempfile::TempDir::new().unwrap();
1044 let exp = make_export_direct(None, Some("subdir/../../etc/passwd"));
1045 let result = exp.resolve_query(dir.path(), None);
1046 assert!(result.is_err());
1047 let msg = format!("{:#}", result.unwrap_err());
1048 assert!(
1049 msg.contains("..") || msg.contains("traversal"),
1050 "got: {msg}"
1051 );
1052 }
1053
1054 #[test]
1055 fn resolve_query_file_absolute_path_is_rejected() {
1056 let dir = tempfile::TempDir::new().unwrap();
1057 let exp = make_export_direct(None, Some("/etc/passwd"));
1058 let result = exp.resolve_query(dir.path(), None);
1059 assert!(result.is_err());
1060 let msg = format!("{:#}", result.unwrap_err());
1061 assert!(
1062 msg.contains("relative") || msg.contains("absolute"),
1063 "got: {msg}"
1064 );
1065 }
1066
1067 #[test]
1068 fn resolve_query_file_in_subdir_is_allowed() {
1069 let dir = tempfile::TempDir::new().unwrap();
1070 let subdir = dir.path().join("queries");
1071 std::fs::create_dir(&subdir).unwrap();
1072 std::fs::write(subdir.join("orders.sql"), "SELECT * FROM orders").unwrap();
1073 let exp = make_export_direct(None, Some("queries/orders.sql"));
1074 let q = exp.resolve_query(dir.path(), None).unwrap();
1075 assert_eq!(q, "SELECT * FROM orders");
1076 }
1077}