quickhouse 0.3.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {quickhouse-0.3.4 → quickhouse-0.4.0}/Cargo.lock +6 -6
  2. {quickhouse-0.3.4 → quickhouse-0.4.0}/Cargo.toml +1 -1
  3. {quickhouse-0.3.4 → quickhouse-0.4.0}/PKG-INFO +34 -1
  4. {quickhouse-0.3.4 → quickhouse-0.4.0}/README.md +33 -0
  5. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/config.rs +128 -0
  6. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/ddl.rs +4 -0
  7. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode.rs +52 -0
  8. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode_bigquery.rs +44 -0
  9. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode_mysql.rs +62 -2
  10. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/lib.rs +1 -1
  11. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/bigquery.rs +62 -10
  12. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/clickhouse.rs +3 -11
  13. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/mod.rs +2 -1
  14. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sync.rs +59 -9
  15. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/transform.rs +4 -0
  16. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-py/src/lib.rs +26 -0
  17. {quickhouse-0.3.4 → quickhouse-0.4.0}/pyproject.toml +1 -1
  18. {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/_quickhouse.pyi +44 -0
  19. {quickhouse-0.3.4 → quickhouse-0.4.0}/LICENSE +0 -0
  20. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/Cargo.toml +0 -0
  21. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/archive.rs +0 -0
  22. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decimal.rs +0 -0
  23. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/error.rs +0 -0
  24. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/memory.rs +0 -0
  25. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/bigquery_proto.rs +0 -0
  26. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
  27. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/mod.rs +0 -0
  28. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/mysql.rs +0 -0
  29. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/postgres.rs +0 -0
  30. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/types.rs +0 -0
  31. {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-py/Cargo.toml +0 -0
  32. {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/__init__.py +0 -0
  33. {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/progress.py +0 -0
  34. {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/py.typed +0 -0
@@ -555,9 +555,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
555
555
 
556
556
  [[package]]
557
557
  name = "cc"
558
- version = "1.3.0"
558
+ version = "1.4.0"
559
559
  source = "registry+https://github.com/rust-lang/crates.io-index"
560
- checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
560
+ checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
561
561
  dependencies = [
562
562
  "find-msvc-tools",
563
563
  "jobserver",
@@ -949,9 +949,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
949
949
 
950
950
  [[package]]
951
951
  name = "either"
952
- version = "1.16.0"
952
+ version = "1.17.0"
953
953
  source = "registry+https://github.com/rust-lang/crates.io-index"
954
- checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
954
+ checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
955
955
 
956
956
  [[package]]
957
957
  name = "encoding_rs"
@@ -2634,7 +2634,7 @@ dependencies = [
2634
2634
 
2635
2635
  [[package]]
2636
2636
  name = "quickhouse-core"
2637
- version = "0.3.4"
2637
+ version = "0.4.0"
2638
2638
  dependencies = [
2639
2639
  "anyhow",
2640
2640
  "arrow",
@@ -2670,7 +2670,7 @@ dependencies = [
2670
2670
 
2671
2671
  [[package]]
2672
2672
  name = "quickhouse-py"
2673
- version = "0.3.4"
2673
+ version = "0.4.0"
2674
2674
  dependencies = [
2675
2675
  "pyo3",
2676
2676
  "quickhouse-core",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/quickhouse-core", "crates/quickhouse-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.4"
6
+ version = "0.4.0"
7
7
  edition = "2021"
8
8
  license = "MIT"
9
9
  authors = ["quickhouse contributors"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickhouse
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -200,6 +200,35 @@ For daily syncs that need to catch late-arriving or edited rows, set
200
200
  `lookback_seconds` to re-scan a trailing window (e.g. `3 * 86400` for the last
201
201
  three days) — the dedup above keeps that overlap from creating duplicates.
202
202
 
203
+ **Cursor control.** A few knobs make the incremental cursor robust in the real
204
+ world:
205
+
206
+ - `state_key` pins the cursor's identity. By default it's keyed by the source
207
+ table (or query text) + destination — so editing a `source_query`'s `WHERE`
208
+ would silently start a fresh full pull, and two syncs into one destination
209
+ tracking different `watermark` columns would clobber each other's cursor. Set
210
+ `state_key="orders:updated_at"` to give each a stable, distinct identity.
211
+ - `skip_to_max=True` (or `seed_watermark="<value>"`) seeds the cursor on the
212
+ **first** run only, then self-retires. Use it when the destination already
213
+ holds complete data from a previous pipeline and a full first pull would be a
214
+ waste — it starts the cursor at the source's current max instead of scanning
215
+ everything.
216
+ - `advance_watermark=False` reads and merges a window *without* moving the
217
+ cursor — for loading a historical backfill without rewinding your regular
218
+ schedule.
219
+
220
+ **MERGE cost on large BigQuery tables.** By default an incremental `MERGE`
221
+ scans the whole destination table (it joins on `key` only), so upserting a few
222
+ delta rows into a huge partitioned table bills the whole table each run. Set
223
+ `merge_prune_partition_by="create_date"` to bound the scan to the staging
224
+ batch's range and let BigQuery prune partitions. **Only do this for an
225
+ *immutable* column** — one whose value never changes for a given `key`, i.e. a
226
+ `create_date`/inserted-at column that is also the partition column. Do **not**
227
+ point it at a `write_date`/updated-at column: an updated row's new value lands
228
+ in a different partition than the existing row, so pruning would miss it and
229
+ insert a duplicate key instead of updating. quickhouse can't detect mutability,
230
+ so this is a deliberate opt-in; the default full scan is always correct.
231
+
203
232
  ### Watching progress and diagnosing failures
204
233
 
205
234
  `on_progress` is a plain callback you can point at anything; `qh.progress_bar()`
@@ -222,6 +251,10 @@ errors are surfaced verbatim rather than wrapped in something generic.
222
251
  | `mode` | `"full"` or `"incremental"` |
223
252
  | `watermark` | Monotonic column for incremental (e.g. `updated_at`); ignored in full mode |
224
253
  | `lookback_seconds` | Re-scan a trailing window of the watermark to catch late/edited rows; `0` disables (default) |
254
+ | `state_key` | Pin the incremental cursor's identity (stable across `source_query` edits; distinct per watermark column). Default derives it from source+dest |
255
+ | `seed_watermark` / `skip_to_max` | Seed the cursor on the first run only (explicit floor, or the source's current max) — skips a doomed first full pull; mutually exclusive |
256
+ | `advance_watermark` | `False` reads+merges a window without advancing the cursor (backfill without rewinding the schedule); default `True` |
257
+ | `merge_prune_partition_by` | BigQuery incremental: prune the MERGE's destination scan to the staging range on this column. Only safe for an *immutable* partition column (e.g. `create_date`) — never a mutable `write_date` (would insert dup keys) |
225
258
  | `key` | Dedup key (required for BigQuery incremental) |
226
259
  | `create_if_missing` | Auto-create the destination table (default `True`) |
227
260
  | `engine`, `order_by`, `partition_by`, `primary_key` | DDL knobs, interpreted per destination |
@@ -168,6 +168,35 @@ For daily syncs that need to catch late-arriving or edited rows, set
168
168
  `lookback_seconds` to re-scan a trailing window (e.g. `3 * 86400` for the last
169
169
  three days) — the dedup above keeps that overlap from creating duplicates.
170
170
 
171
+ **Cursor control.** A few knobs make the incremental cursor robust in the real
172
+ world:
173
+
174
+ - `state_key` pins the cursor's identity. By default it's keyed by the source
175
+ table (or query text) + destination — so editing a `source_query`'s `WHERE`
176
+ would silently start a fresh full pull, and two syncs into one destination
177
+ tracking different `watermark` columns would clobber each other's cursor. Set
178
+ `state_key="orders:updated_at"` to give each a stable, distinct identity.
179
+ - `skip_to_max=True` (or `seed_watermark="<value>"`) seeds the cursor on the
180
+ **first** run only, then self-retires. Use it when the destination already
181
+ holds complete data from a previous pipeline and a full first pull would be a
182
+ waste — it starts the cursor at the source's current max instead of scanning
183
+ everything.
184
+ - `advance_watermark=False` reads and merges a window *without* moving the
185
+ cursor — for loading a historical backfill without rewinding your regular
186
+ schedule.
187
+
188
+ **MERGE cost on large BigQuery tables.** By default an incremental `MERGE`
189
+ scans the whole destination table (it joins on `key` only), so upserting a few
190
+ delta rows into a huge partitioned table bills the whole table each run. Set
191
+ `merge_prune_partition_by="create_date"` to bound the scan to the staging
192
+ batch's range and let BigQuery prune partitions. **Only do this for an
193
+ *immutable* column** — one whose value never changes for a given `key`, i.e. a
194
+ `create_date`/inserted-at column that is also the partition column. Do **not**
195
+ point it at a `write_date`/updated-at column: an updated row's new value lands
196
+ in a different partition than the existing row, so pruning would miss it and
197
+ insert a duplicate key instead of updating. quickhouse can't detect mutability,
198
+ so this is a deliberate opt-in; the default full scan is always correct.
199
+
171
200
  ### Watching progress and diagnosing failures
172
201
 
173
202
  `on_progress` is a plain callback you can point at anything; `qh.progress_bar()`
@@ -190,6 +219,10 @@ errors are surfaced verbatim rather than wrapped in something generic.
190
219
  | `mode` | `"full"` or `"incremental"` |
191
220
  | `watermark` | Monotonic column for incremental (e.g. `updated_at`); ignored in full mode |
192
221
  | `lookback_seconds` | Re-scan a trailing window of the watermark to catch late/edited rows; `0` disables (default) |
222
+ | `state_key` | Pin the incremental cursor's identity (stable across `source_query` edits; distinct per watermark column). Default derives it from source+dest |
223
+ | `seed_watermark` / `skip_to_max` | Seed the cursor on the first run only (explicit floor, or the source's current max) — skips a doomed first full pull; mutually exclusive |
224
+ | `advance_watermark` | `False` reads+merges a window without advancing the cursor (backfill without rewinding the schedule); default `True` |
225
+ | `merge_prune_partition_by` | BigQuery incremental: prune the MERGE's destination scan to the staging range on this column. Only safe for an *immutable* partition column (e.g. `create_date`) — never a mutable `write_date` (would insert dup keys) |
193
226
  | `key` | Dedup key (required for BigQuery incremental) |
194
227
  | `create_if_missing` | Auto-create the destination table (default `True`) |
195
228
  | `engine`, `order_by`, `partition_by`, `primary_key` | DDL knobs, interpreted per destination |
@@ -182,6 +182,25 @@ pub enum SyncMode {
182
182
  Incremental,
183
183
  }
184
184
 
185
+ /// How to seed the incremental cursor on the **first** run for a state
186
+ /// identity (i.e. when `_quickhouse_state` has no row yet). Once a real
187
+ /// watermark has been persisted this is ignored, so it self-retires after the
188
+ /// first successful run — a safe no-op thereafter.
189
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
190
+ pub enum WatermarkSeed {
191
+ /// First run reads the whole table (today's behavior).
192
+ #[default]
193
+ None,
194
+ /// First run reads only rows past this explicit watermark floor.
195
+ Value(String),
196
+ /// First run seeds the cursor to the source's current MAX(watermark),
197
+ /// reading (almost) nothing — for when the destination already holds
198
+ /// complete data from a prior/legacy pipeline and a full first pull would
199
+ /// be a doomed waste. Replaces the manual `INSERT INTO _quickhouse_state`
200
+ /// hack.
201
+ CurrentMax,
202
+ }
203
+
185
204
  /// One table transfer.
186
205
  #[derive(Debug, Clone)]
187
206
  pub struct TransferConfig {
@@ -192,6 +211,16 @@ pub struct TransferConfig {
192
211
  /// Destination table name (a bare name within the ClickHouse `database` or
193
212
  /// BigQuery `dataset_id`).
194
213
  pub dest_table: String,
214
+ /// Stable identity for the persisted incremental cursor in
215
+ /// `_quickhouse_state`. `None` (default) derives it from `source_table`,
216
+ /// else the `source_query` text (byte-identical to pre-`state_key`
217
+ /// behavior — so existing state is never orphaned). Set it to (a) keep the
218
+ /// cursor stable when you edit a `source_query`'s WHERE/SELECT (whose text
219
+ /// would otherwise change the derived key and silently reset the cursor),
220
+ /// and (b) give two syncs that share a `dest_table` but track different
221
+ /// `watermark` columns distinct cursors (which otherwise collide on one
222
+ /// state row). See [`TransferConfig::effective_state_key`].
223
+ pub state_key: Option<String>,
195
224
 
196
225
  pub mode: SyncMode,
197
226
 
@@ -206,6 +235,17 @@ pub struct TransferConfig {
206
235
  /// duplicate them) and a `watermark` column that resolves to a date or
207
236
  /// timestamp type.
208
237
  pub lookback_seconds: u64,
238
+ /// How to seed the incremental cursor on the first run for this state
239
+ /// identity (default [`WatermarkSeed::None`] = read the whole table, as
240
+ /// before). Only meaningful in incremental mode; ignored once a real
241
+ /// watermark has been persisted. See [`WatermarkSeed`].
242
+ pub seed_watermark: WatermarkSeed,
243
+ /// Whether a successful incremental run persists (advances) the watermark.
244
+ /// `true` (default) is today's behavior. Set `false` to read+merge a
245
+ /// window WITHOUT moving the scheduled cursor — the primitive a bounded
246
+ /// backfill needs so it doesn't rewind the regular schedule. Incremental
247
+ /// mode only.
248
+ pub advance_watermark: bool,
209
249
  /// Business/dedup key. ClickHouse: contributes to `ORDER BY` when no
210
250
  /// explicit `order_by` is given. BigQuery: contributes to `Clustering`
211
251
  /// alongside `order_by` (see its docs) — BigQuery has no dedicated key
@@ -230,6 +270,24 @@ pub struct TransferConfig {
230
270
  /// resolve to one of those types.
231
271
  pub partition_by: Option<String>,
232
272
  pub primary_key: Vec<String>,
273
+ /// BigQuery-destination incremental only: prune the `MERGE`'s destination
274
+ /// scan to the staging batch's range on this column, so BigQuery reads only
275
+ /// the touched partitions instead of full-scanning the (possibly huge)
276
+ /// destination table on every merge. `None` (default) full-scans, as before.
277
+ ///
278
+ /// **Correctness contract — read before setting.** This is ONLY safe when
279
+ /// the named column is IMMUTABLE for a given merge `key` (its value never
280
+ /// changes across updates to the same row), and it should be the table's
281
+ /// partition column. A `create_date`/inserted-at column is safe: a row's
282
+ /// value stays put, so the existing target row always lives in the
283
+ /// partition the staging row implies. A `write_date`/updated-at column is
284
+ /// **NOT** safe: an updated row's new `write_date` points at a different
285
+ /// partition than where the old row lives, so pruning would miss it, fall
286
+ /// through to `WHEN NOT MATCHED`, and INSERT A DUPLICATE KEY instead of
287
+ /// updating. (This is the historical `merge_query_filter` duplicate-id bug;
288
+ /// do not "optimize" a mutable partition column into this field.) quickhouse
289
+ /// cannot detect mutability, so this is a deliberate per-table opt-in.
290
+ pub merge_prune_partition_by: Option<String>,
233
291
 
234
292
  // ---- parallelism / batching ----
235
293
  pub parallelism: usize,
@@ -301,9 +359,23 @@ impl TransferConfig {
301
359
  pub fn normalize(&mut self) {
302
360
  if self.mode == SyncMode::Full {
303
361
  self.watermark = None;
362
+ // The seed only meaningfully floors an incremental cursor; a
363
+ // full refresh has none, so clear it (mirrors `watermark`).
364
+ self.seed_watermark = WatermarkSeed::None;
304
365
  }
305
366
  }
306
367
 
368
+ /// Identity of the persisted watermark row in `_quickhouse_state`.
369
+ /// Overridable via `state_key`; otherwise the source table name, else the
370
+ /// query text (unchanged default — so existing state rows keep matching).
371
+ pub fn effective_state_key(&self) -> String {
372
+ self.state_key
373
+ .clone()
374
+ .or_else(|| self.source_table.clone())
375
+ .or_else(|| self.source_query.clone())
376
+ .unwrap_or_default()
377
+ }
378
+
307
379
  pub fn validate(&self) -> crate::error::Result<()> {
308
380
  use crate::error::EtlError;
309
381
  if self.source_table.is_none() && self.source_query.is_none() {
@@ -321,6 +393,16 @@ impl TransferConfig {
321
393
  "lookback_seconds only applies to incremental mode",
322
394
  ));
323
395
  }
396
+ if self.seed_watermark != WatermarkSeed::None && self.mode != SyncMode::Incremental {
397
+ return Err(EtlError::config(
398
+ "seed_watermark only applies to incremental mode",
399
+ ));
400
+ }
401
+ if !self.advance_watermark && self.mode != SyncMode::Incremental {
402
+ return Err(EtlError::config(
403
+ "advance_watermark=false only applies to incremental mode",
404
+ ));
405
+ }
324
406
  if self.lookback_seconds > 0 && self.key.is_empty() && self.order_by.is_empty() {
325
407
  return Err(EtlError::config(
326
408
  "lookback_seconds requires key or order_by (otherwise the re-synced \
@@ -369,15 +451,19 @@ mod tests {
369
451
  source_table: Some("t".into()),
370
452
  source_query: None,
371
453
  dest_table: "t".into(),
454
+ state_key: None,
372
455
  mode,
373
456
  watermark: watermark.map(str::to_string),
374
457
  lookback_seconds: 0,
458
+ seed_watermark: WatermarkSeed::None,
459
+ advance_watermark: true,
375
460
  key: vec!["id".into()],
376
461
  create_if_missing: true,
377
462
  engine: None,
378
463
  order_by: vec![],
379
464
  partition_by: None,
380
465
  primary_key: vec![],
466
+ merge_prune_partition_by: None,
381
467
  parallelism: 1,
382
468
  batch_rows: 1000,
383
469
  batch_bytes: 0,
@@ -457,4 +543,46 @@ mod tests {
457
543
  c.read_max_rows_per_sec = Some(10_000);
458
544
  assert!(c.validate().is_ok());
459
545
  }
546
+
547
+ #[test]
548
+ fn effective_state_key_prefers_override_then_table_then_query() {
549
+ let mut c = cfg(SyncMode::Incremental, Some("write_date"));
550
+ // Default: source_table.
551
+ assert_eq!(c.effective_state_key(), "t");
552
+ // source_query wins over nothing but loses to source_table.
553
+ c.source_table = None;
554
+ c.source_query = Some("SELECT * FROM t WHERE x".into());
555
+ assert_eq!(c.effective_state_key(), "SELECT * FROM t WHERE x");
556
+ // explicit override wins over both — and is stable across query edits.
557
+ c.state_key = Some("orders:write_date".into());
558
+ assert_eq!(c.effective_state_key(), "orders:write_date");
559
+ c.source_query = Some("SELECT * FROM t WHERE y /* edited */".into());
560
+ assert_eq!(c.effective_state_key(), "orders:write_date");
561
+ }
562
+
563
+ #[test]
564
+ fn validate_rejects_seed_and_freeze_outside_incremental() {
565
+ let mut c = cfg(SyncMode::Full, None);
566
+ c.seed_watermark = WatermarkSeed::CurrentMax;
567
+ assert!(c.validate().unwrap_err().to_string().contains("seed_watermark"));
568
+ c.seed_watermark = WatermarkSeed::None;
569
+ c.advance_watermark = false;
570
+ assert!(c.validate().unwrap_err().to_string().contains("advance_watermark"));
571
+ }
572
+
573
+ #[test]
574
+ fn validate_accepts_seed_and_freeze_in_incremental() {
575
+ let mut c = cfg(SyncMode::Incremental, Some("write_date"));
576
+ c.seed_watermark = WatermarkSeed::Value("2026-01-01".into());
577
+ c.advance_watermark = false;
578
+ assert!(c.validate().is_ok());
579
+ }
580
+
581
+ #[test]
582
+ fn normalize_clears_seed_in_full_mode() {
583
+ let mut c = cfg(SyncMode::Full, Some("write_date"));
584
+ c.seed_watermark = WatermarkSeed::CurrentMax;
585
+ c.normalize();
586
+ assert_eq!(c.seed_watermark, WatermarkSeed::None);
587
+ }
460
588
  }
@@ -142,12 +142,16 @@ mod tests {
142
142
  order_by: vec![],
143
143
  partition_by: None,
144
144
  primary_key: vec![],
145
+ merge_prune_partition_by: None,
145
146
  parallelism: 4,
146
147
  batch_rows: 1000,
147
148
  batch_bytes: 0,
148
149
  max_memory_bytes: 0,
149
150
  partition_column: None,
150
151
  read_max_rows_per_sec: None,
152
+ state_key: None,
153
+ seed_watermark: crate::config::WatermarkSeed::None,
154
+ advance_watermark: true,
151
155
  type_overrides: HashMap::new(),
152
156
  rename: HashMap::new(),
153
157
  include: vec![],
@@ -613,6 +613,58 @@ mod tests {
613
613
  v
614
614
  }
615
615
 
616
+ /// Golden type matrix for the PostgreSQL source: for every OID `map_oid`
617
+ /// produces, assert the full type chain END-TO-END from the source type —
618
+ /// (1) mapping to Arrow + ClickHouse inner, (2) the decoder's output array
619
+ /// type carries the correct timezone, (3) the batch-vs-schema check
620
+ /// (`RecordBatch::try_new`, the literal `flush_batch` validation) passes,
621
+ /// and (4) the Arrow→BigQuery destination-type bridge. Starting from the
622
+ /// OID (not a hand-built Arrow type) is what makes this catch a mapping
623
+ /// revert — the exact class of regression that shipped in 0.3.4 for MySQL
624
+ /// (there was no equivalent Postgres tz coverage at all before this).
625
+ #[test]
626
+ fn postgres_type_golden_matrix() {
627
+ use crate::types::bigquery::arrow_to_bigquery_type as a2b;
628
+ use crate::types::{map_oid, oid};
629
+ use arrow_schema::{Field, Schema, TimeUnit};
630
+ use google_cloud_bigquery::http::table::TableFieldType as Bq;
631
+ use std::sync::Arc;
632
+
633
+ let ts = |tz: Option<&str>| DataType::Timestamp(TimeUnit::Microsecond, tz.map(Arc::from));
634
+ // (oid, expected Arrow, expected ClickHouse inner, expected BigQuery type)
635
+ let rows: Vec<(u32, DataType, &str, Bq)> = vec![
636
+ (oid::BOOL, DataType::Boolean, "Bool", Bq::Boolean),
637
+ (oid::INT2, DataType::Int16, "Int16", Bq::Integer),
638
+ (oid::INT4, DataType::Int32, "Int32", Bq::Integer),
639
+ (oid::INT8, DataType::Int64, "Int64", Bq::Integer),
640
+ (oid::OID, DataType::UInt32, "UInt32", Bq::Integer),
641
+ (oid::FLOAT4, DataType::Float32, "Float32", Bq::Float),
642
+ (oid::FLOAT8, DataType::Float64, "Float64", Bq::Float),
643
+ (oid::NUMERIC, DataType::Float64, "Float64", Bq::Float),
644
+ (oid::TEXT, DataType::Utf8, "String", Bq::String),
645
+ (oid::UUID, DataType::Utf8, "UUID", Bq::String),
646
+ (oid::BYTEA, DataType::Binary, "String", Bq::Bytes),
647
+ (oid::DATE, DataType::Date32, "Date32", Bq::Date),
648
+ (oid::TIMESTAMP, ts(None), "DateTime64(6)", Bq::Datetime),
649
+ (oid::TIMESTAMPTZ, ts(Some("UTC")), "DateTime64(6, 'UTC')", Bq::Timestamp),
650
+ (oid::TIME, DataType::Utf8, "String", Bq::String),
651
+ ];
652
+ for (o, arrow, ch, bq) in rows {
653
+ let (mapped_arrow, mapped_ch) = map_oid(o).unwrap_or_else(|| panic!("oid {o} unmapped"));
654
+ assert_eq!(mapped_arrow, arrow, "oid {o}: Arrow type");
655
+ assert_eq!(mapped_ch, ch, "oid {o}: ClickHouse inner");
656
+ // Decoder output type (incl. tz) must equal the resolved schema type.
657
+ let mut b = ColBuilder::new(&arrow).unwrap();
658
+ let out = b.finish();
659
+ assert_eq!(out.data_type(), &arrow, "oid {o}: decoder output type/tz");
660
+ // The literal flush_batch validation must accept it.
661
+ let schema = Arc::new(Schema::new(vec![Field::new("c", arrow.clone(), true)]));
662
+ RecordBatch::try_new(schema, vec![out]).unwrap_or_else(|e| panic!("oid {o}: batch: {e}"));
663
+ // Destination bridge (Arrow -> BigQuery column type).
664
+ assert_eq!(a2b(&arrow), Some(bq), "oid {o}: BigQuery type");
665
+ }
666
+ }
667
+
616
668
  #[test]
617
669
  fn decodes_rows_and_nulls() {
618
670
  let cols = vec![
@@ -379,6 +379,50 @@ mod tests {
379
379
  Row::decode_arrow(&[arr], 0).unwrap()
380
380
  }
381
381
 
382
+ /// Golden type matrix for the BigQuery source: for every `TableFieldType`
383
+ /// `map_type` handles, assert the chain from the BQ type through (1) mapping
384
+ /// to (type_id, Arrow, ClickHouse inner), (2) the decoder's output array
385
+ /// type carrying the correct timezone — the TIMESTAMP(→UTC-aware) vs
386
+ /// DATETIME(→naive) split is the exact cell whose MySQL analogue broke in
387
+ /// 0.3.4 and had no BigQuery coverage — (3) the batch-vs-schema check, and
388
+ /// (4) the Arrow→BigQuery bridge (lossy for TIME/NUMERIC, asserted as such).
389
+ #[test]
390
+ fn bigquery_type_golden_matrix() {
391
+ use crate::types::bigquery::arrow_to_bigquery_type as a2b;
392
+ use crate::types::bigquery::{map_type, type_id as id};
393
+ use arrow_schema::{Field, Schema, TimeUnit};
394
+ use google_cloud_bigquery::http::table::TableFieldType as Bq;
395
+
396
+ let ts = |tz: Option<&str>| DataType::Timestamp(TimeUnit::Microsecond, tz.map(Arc::from));
397
+ // (input BQ type, expected type_id, expected Arrow, expected CH inner, expected a2b bridge)
398
+ let rows: Vec<(Bq, u32, DataType, &str, Bq)> = vec![
399
+ (Bq::String, id::STRING, DataType::Utf8, "String", Bq::String),
400
+ (Bq::Bytes, id::BYTES, DataType::Binary, "String", Bq::Bytes),
401
+ (Bq::Integer, id::INTEGER, DataType::Int64, "Int64", Bq::Integer),
402
+ (Bq::Float, id::FLOAT, DataType::Float64, "Float64", Bq::Float),
403
+ (Bq::Boolean, id::BOOLEAN, DataType::Boolean, "Bool", Bq::Boolean),
404
+ (Bq::Timestamp, id::TIMESTAMP, ts(Some("UTC")), "DateTime64(6, 'UTC')", Bq::Timestamp),
405
+ (Bq::Date, id::DATE, DataType::Date32, "Date32", Bq::Date),
406
+ (Bq::Time, id::TIME, DataType::Utf8, "String", Bq::String), // lossy: Time -> String
407
+ (Bq::Datetime, id::DATETIME, ts(None), "DateTime64(6)", Bq::Datetime),
408
+ (Bq::Numeric, id::NUMERIC, DataType::Float64, "Float64", Bq::Float), // lossy
409
+ (Bq::Bignumeric, id::BIGNUMERIC, DataType::Float64, "Float64", Bq::Float), // lossy
410
+ ];
411
+ for (input, tid, arrow, ch, bridge) in rows {
412
+ let (mapped_id, mapped_arrow, mapped_ch) =
413
+ map_type(&input).unwrap_or_else(|| panic!("{input:?} unmapped"));
414
+ assert_eq!(mapped_id, tid, "{input:?}: type_id");
415
+ assert_eq!(mapped_arrow, arrow, "{input:?}: Arrow type");
416
+ assert_eq!(mapped_ch, ch, "{input:?}: ClickHouse inner");
417
+ let mut b = ColBuilder::new(&arrow).unwrap();
418
+ let out = b.finish();
419
+ assert_eq!(out.data_type(), &arrow, "{input:?}: decoder output type/tz");
420
+ let schema = Arc::new(Schema::new(vec![Field::new("c", arrow.clone(), true)]));
421
+ RecordBatch::try_new(schema, vec![out]).unwrap_or_else(|e| panic!("{input:?}: batch: {e}"));
422
+ assert_eq!(a2b(&arrow), Some(bridge), "{input:?}: Arrow->BigQuery bridge");
423
+ }
424
+ }
425
+
382
426
  #[test]
383
427
  fn decimal_decodes_exact_text_value() {
384
428
  let mut b = ColBuilder::new(&DataType::Decimal128(10, 4)).unwrap();
@@ -59,8 +59,16 @@ impl ColBuilder {
59
59
  DataType::Utf8 => ColBuilder::Str(StringBuilder::new()),
60
60
  DataType::Binary => ColBuilder::Bin(BinaryBuilder::new()),
61
61
  DataType::Date32 => ColBuilder::Date(Date32Builder::new()),
62
- DataType::Timestamp(TimeUnit::Microsecond, _) => {
63
- ColBuilder::Ts(TimestampMicrosecondBuilder::new())
62
+ // Carry the resolved timezone onto the builder so `finish()`
63
+ // produces an array whose type matches the batch schema exactly.
64
+ // MySQL datetimes now resolve to `Timestamp(µs, Some("UTC"))` (see
65
+ // types::map_mysql_type); dropping the tz here (as this used to)
66
+ // built a naive array that failed the batch-vs-schema check with
67
+ // "expected Timestamp(.., Some(\"UTC\")) but found Timestamp(..,
68
+ // None)". The stored micros are unaffected — this only sets the
69
+ // array's tz metadata. Mirrors the Postgres decoder (decode.rs).
70
+ DataType::Timestamp(TimeUnit::Microsecond, tz) => {
71
+ ColBuilder::Ts(TimestampMicrosecondBuilder::new().with_timezone_opt(tz.clone()))
64
72
  }
65
73
  DataType::Decimal128(p, s) => ColBuilder::Decimal128(
66
74
  Decimal128Builder::new().with_precision_and_scale(*p, *s)?,
@@ -446,6 +454,58 @@ mod tests {
446
454
  assert!(arr.is_null(2));
447
455
  }
448
456
 
457
+ /// End-to-end reproduction of the reported v0.3.4 failure, hermetic (no
458
+ /// live MySQL needed). The crash was a batch-vs-schema mismatch in
459
+ /// `RecordBatch::try_new` inside `flush_batch`; this rebuilds that exact
460
+ /// step — the schema field from the resolved Arrow type (as
461
+ /// `MySqlBatcher::with_batch_bytes` does via `Field::new(c.arrow, ...)`)
462
+ /// and the column array from `ColBuilder::finish()` — for a UTC-mapped
463
+ /// MySQL timestamp column. Before the decoder fix, `try_new` returned
464
+ /// "column types must match schema types, expected Timestamp(..,
465
+ /// Some(\"UTC\")) but found Timestamp(.., None)"; now the batch builds and
466
+ /// the column keeps its UTC type.
467
+ #[test]
468
+ fn utc_timestamp_column_builds_a_valid_batch() {
469
+ let arrow = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()));
470
+ let schema = Arc::new(Schema::new(vec![Field::new("created_date", arrow.clone(), true)]));
471
+ let mut b = ColBuilder::new(&arrow).unwrap();
472
+ b.append_value(Value::Date(2024, 5, 1, 10, 0, 0, 0)).unwrap();
473
+ let batch = RecordBatch::try_new(schema, vec![b.finish()])
474
+ .expect("v0.3.4 failed here: naive array vs UTC schema");
475
+ assert_eq!(batch.column(0).data_type(), &arrow);
476
+ assert_eq!(batch.num_rows(), 1);
477
+ }
478
+
479
+ /// Regression test for the 0.3.4 follow-up bug: the finished array's *type*
480
+ /// must carry the timezone from the resolved schema, not merely the correct
481
+ /// micros. MySQL datetimes resolve to `Timestamp(µs, Some("UTC"))`; a naive
482
+ /// array (tz `None`) built here failed the downstream batch-vs-schema check
483
+ /// with "expected Timestamp(.., Some(\"UTC\")) but found Timestamp(.., None)".
484
+ /// The earlier 0.3.4 tests asserted the mapping and the value, but not this
485
+ /// — the exact gap that let the regression ship.
486
+ #[test]
487
+ fn ts_builder_output_type_carries_timezone() {
488
+ // UTC-aware (the MySQL datetime default): finished array is tz-aware.
489
+ let mut utc =
490
+ ColBuilder::new(&DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))).unwrap();
491
+ utc.append_value(Value::Date(2024, 5, 1, 10, 0, 0, 0)).unwrap();
492
+ assert_eq!(
493
+ utc.finish().data_type(),
494
+ &DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
495
+ "MySQL datetime array must carry the UTC timezone to match the resolved schema"
496
+ );
497
+ // Naive (the per-column `type_overrides={col: \"DATETIME\"}` opt-out)
498
+ // must still produce a naive array — the reverse mismatch.
499
+ let mut naive =
500
+ ColBuilder::new(&DataType::Timestamp(TimeUnit::Microsecond, None)).unwrap();
501
+ naive.append_value(Value::Date(2024, 5, 1, 10, 0, 0, 0)).unwrap();
502
+ assert_eq!(
503
+ naive.finish().data_type(),
504
+ &DataType::Timestamp(TimeUnit::Microsecond, None),
505
+ "a naive-mapped datetime column must stay naive"
506
+ );
507
+ }
508
+
449
509
  #[test]
450
510
  fn decimal_decodes_exact_text_value() {
451
511
  let mut b = ColBuilder::new(&DataType::Decimal128(10, 4)).unwrap();
@@ -28,7 +28,7 @@ pub mod types;
28
28
  pub use config::{
29
29
  BigQueryConfig, BigQueryDestConfig, BigQueryWriteMethod, ClickHouseConfig, Compression,
30
30
  DestinationConfig, MySqlConfig, ParquetCompression, PostgresConfig, S3ArchiveConfig,
31
- SourceConfig, SyncMode, TransferConfig, TransferResult,
31
+ SourceConfig, SyncMode, TransferConfig, TransferResult, WatermarkSeed,
32
32
  };
33
33
  pub use error::{EtlError, Result};
34
34
  pub use sync::{run_transfer, Progress, ProgressCb};
@@ -389,8 +389,15 @@ impl BigQuerySink {
389
389
  /// naturally idempotent: re-running the same merge (e.g. after a crash,
390
390
  /// before the watermark advances) re-applies the same key-matched rows
391
391
  /// rather than duplicating them.
392
- pub async fn merge_into(&self, dest: &str, staging: &str, key: &[String], columns: &[ColumnType]) -> Result<()> {
393
- let query = build_merge_sql(&self.project_id, &self.dataset_id, dest, staging, key, columns)?;
392
+ pub async fn merge_into(
393
+ &self,
394
+ dest: &str,
395
+ staging: &str,
396
+ key: &[String],
397
+ columns: &[ColumnType],
398
+ prune_partition: Option<&str>,
399
+ ) -> Result<()> {
400
+ let query = build_merge_sql(&self.project_id, &self.dataset_id, dest, staging, key, columns, prune_partition)?;
394
401
  let job = Job {
395
402
  job_reference: JobReference {
396
403
  project_id: self.project_id.clone(),
@@ -466,12 +473,12 @@ impl BigQuerySink {
466
473
  Ok(())
467
474
  }
468
475
 
469
- /// Read the last persisted watermark for this `(source, dest_table)` pair.
476
+ /// Read the last persisted watermark for this `(state_key, dest_table)` pair.
470
477
  pub async fn read_last_watermark(&self, cfg: &TransferConfig) -> Result<Option<String>> {
471
478
  if !self.table_exists(STATE_TABLE).await? {
472
479
  return Ok(None);
473
480
  }
474
- let source_id = cfg.source_table.clone().or_else(|| cfg.source_query.clone()).unwrap_or_default();
481
+ let source_id = cfg.effective_state_key();
475
482
  let query = format!(
476
483
  "SELECT last_watermark FROM `{}`.`{}`.`{STATE_TABLE}` \
477
484
  WHERE source_table = '{}' AND dest_table = '{}' \
@@ -499,7 +506,7 @@ impl BigQuerySink {
499
506
  /// DML `INSERT` run as a query job (BigQuery executes DML through the
500
507
  /// same job mechanism as `SELECT`).
501
508
  pub async fn persist_watermark(&self, cfg: &TransferConfig, watermark: &str, rows: u64) -> Result<()> {
502
- let source_id = cfg.source_table.clone().or_else(|| cfg.source_query.clone()).unwrap_or_default();
509
+ let source_id = cfg.effective_state_key();
503
510
  let query = build_persist_watermark_sql(&self.project_id, &self.dataset_id, &source_id, &cfg.dest_table, watermark, rows);
504
511
  let job = Job {
505
512
  job_reference: JobReference {
@@ -693,6 +700,7 @@ fn build_merge_sql(
693
700
  staging: &str,
694
701
  key: &[String],
695
702
  columns: &[ColumnType],
703
+ prune_partition: Option<&str>,
696
704
  ) -> Result<String> {
697
705
  if key.is_empty() {
698
706
  // Should already be caught by sync.rs's prepare_target validation
@@ -701,7 +709,21 @@ fn build_merge_sql(
701
709
  "build_merge_sql called with an empty key (should have been validated before staging began)",
702
710
  ));
703
711
  }
704
- let on_clause = key.iter().map(|k| format!("T.`{k}` = S.`{k}`")).collect::<Vec<_>>().join(" AND ");
712
+ let mut on_clause = key.iter().map(|k| format!("T.`{k}` = S.`{k}`")).collect::<Vec<_>>().join(" AND ");
713
+ // Optional partition pruning: bound the destination scan to the staging
714
+ // batch's range on an IMMUTABLE partition column, so BigQuery reads only
715
+ // the touched partitions instead of the whole table. Scalar subqueries over
716
+ // the (small) staging table give BigQuery the pruning bounds; the caller
717
+ // guarantees the column is immutable-per-key (see the correctness contract
718
+ // on `TransferConfig::merge_prune_partition_by` — a mutable column here
719
+ // silently inserts duplicate keys).
720
+ if let Some(pcol) = prune_partition {
721
+ on_clause.push_str(&format!(
722
+ " AND T.`{pcol}` BETWEEN \
723
+ (SELECT MIN(`{pcol}`) FROM `{project_id}`.`{dataset_id}`.`{staging}`) \
724
+ AND (SELECT MAX(`{pcol}`) FROM `{project_id}`.`{dataset_id}`.`{staging}`)"
725
+ ));
726
+ }
705
727
 
706
728
  let all_cols: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
707
729
  let update_cols: Vec<&str> = all_cols.iter().copied().filter(|c| !key.iter().any(|k| k == c)).collect();
@@ -940,12 +962,16 @@ mod tests {
940
962
  order_by: vec![],
941
963
  partition_by: None,
942
964
  primary_key: vec![],
965
+ merge_prune_partition_by: None,
943
966
  parallelism: 1,
944
967
  batch_rows: 1000,
945
968
  batch_bytes: 0,
946
969
  max_memory_bytes: 0,
947
970
  partition_column: None,
948
971
  read_max_rows_per_sec: None,
972
+ state_key: None,
973
+ seed_watermark: crate::config::WatermarkSeed::None,
974
+ advance_watermark: true,
949
975
  type_overrides: HashMap::new(),
950
976
  rename: HashMap::new(),
951
977
  include: vec![],
@@ -1113,10 +1139,12 @@ mod tests {
1113
1139
  col("amount", DataType::Float64, true),
1114
1140
  ];
1115
1141
  let key = vec!["id".to_string()];
1116
- let sql = build_merge_sql("proj", "ds", "orders", "orders_quickhouse_tmp", &key, &cols).unwrap();
1142
+ let sql = build_merge_sql("proj", "ds", "orders", "orders_quickhouse_tmp", &key, &cols, None).unwrap();
1117
1143
 
1118
1144
  assert!(sql.starts_with("MERGE INTO `proj`.`ds`.`orders` T USING `proj`.`ds`.`orders_quickhouse_tmp` S"));
1119
1145
  assert!(sql.contains("ON T.`id` = S.`id`"));
1146
+ // No prune column -> no partition-bound predicate (full-scan, correct default).
1147
+ assert!(!sql.contains("BETWEEN"), "unexpected prune predicate without an immutable column: {sql}");
1120
1148
  assert!(sql.contains("WHEN MATCHED THEN UPDATE SET `name` = S.`name`, `amount` = S.`amount`"));
1121
1149
  assert!(sql.contains("WHEN NOT MATCHED THEN INSERT (`id`, `name`, `amount`) VALUES (S.`id`, S.`name`, S.`amount`)"));
1122
1150
  // The key column must never appear in the UPDATE SET list.
@@ -1127,15 +1155,39 @@ mod tests {
1127
1155
  fn build_merge_sql_composite_key() {
1128
1156
  let cols = vec![col("a", DataType::Int64, false), col("b", DataType::Int64, false)];
1129
1157
  let key = vec!["a".to_string(), "b".to_string()];
1130
- let sql = build_merge_sql("p", "d", "t", "s", &key, &cols).unwrap();
1158
+ let sql = build_merge_sql("p", "d", "t", "s", &key, &cols, None).unwrap();
1131
1159
  assert!(sql.contains("ON T.`a` = S.`a` AND T.`b` = S.`b`"));
1132
1160
  }
1133
1161
 
1162
+ #[test]
1163
+ fn build_merge_sql_prunes_on_immutable_partition_column() {
1164
+ let cols = vec![
1165
+ col("id", DataType::Int64, false),
1166
+ col("create_date", DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, Some("UTC".into())), false),
1167
+ col("amount", DataType::Float64, true),
1168
+ ];
1169
+ let key = vec!["id".to_string()];
1170
+ let sql = build_merge_sql("p", "d", "orders", "orders_tmp", &key, &cols, Some("create_date")).unwrap();
1171
+ // The join still matches on the key, AND the destination is bounded to
1172
+ // the staging batch's create_date range so BigQuery prunes partitions.
1173
+ assert!(sql.contains("ON T.`id` = S.`id`"), "{sql}");
1174
+ assert!(
1175
+ sql.contains(
1176
+ "AND T.`create_date` BETWEEN \
1177
+ (SELECT MIN(`create_date`) FROM `p`.`d`.`orders_tmp`) \
1178
+ AND (SELECT MAX(`create_date`) FROM `p`.`d`.`orders_tmp`)"
1179
+ ),
1180
+ "missing partition-prune predicate: {sql}"
1181
+ );
1182
+ // The pruned column is still upserted like any other non-key column.
1183
+ assert!(sql.contains("`create_date` = S.`create_date`"), "{sql}");
1184
+ }
1185
+
1134
1186
  #[test]
1135
1187
  fn build_merge_sql_all_columns_are_key_becomes_insert_only() {
1136
1188
  let cols = vec![col("id", DataType::Int64, false)];
1137
1189
  let key = vec!["id".to_string()];
1138
- let sql = build_merge_sql("p", "d", "t", "s", &key, &cols).unwrap();
1190
+ let sql = build_merge_sql("p", "d", "t", "s", &key, &cols, None).unwrap();
1139
1191
  assert!(!sql.contains("WHEN MATCHED"), "no columns left to update: {sql}");
1140
1192
  assert!(sql.contains("WHEN NOT MATCHED THEN INSERT (`id`) VALUES (S.`id`)"));
1141
1193
  }
@@ -1143,7 +1195,7 @@ mod tests {
1143
1195
  #[test]
1144
1196
  fn build_merge_sql_rejects_empty_key() {
1145
1197
  let cols = vec![col("id", DataType::Int64, false)];
1146
- let err = build_merge_sql("p", "d", "t", "s", &[], &cols).unwrap_err().to_string();
1198
+ let err = build_merge_sql("p", "d", "t", "s", &[], &cols, None).unwrap_err().to_string();
1147
1199
  assert!(err.contains("empty key"), "{err}");
1148
1200
  assert!(err.contains("quickhouse bug"), "must be framed as internal, not a config error: {err}");
1149
1201
  }
@@ -85,17 +85,13 @@ impl ClickHouseSink {
85
85
  self.execute(&crate::ddl::create_state_table(&self.cfg.database)).await
86
86
  }
87
87
 
88
- /// Read the last persisted watermark for this `(source, dest_table)` pair.
88
+ /// Read the last persisted watermark for this `(state_key, dest_table)` pair.
89
89
  pub async fn read_last_watermark(&self, cfg: &TransferConfig) -> Result<Option<String>> {
90
90
  // The state table may not exist yet on the very first incremental run.
91
91
  if !self.table_exists("_quickhouse_state").await? {
92
92
  return Ok(None);
93
93
  }
94
- let source_id = cfg
95
- .source_table
96
- .clone()
97
- .or_else(|| cfg.source_query.clone())
98
- .unwrap_or_default();
94
+ let source_id = cfg.effective_state_key();
99
95
  let sql = format!(
100
96
  "SELECT last_watermark FROM {}.`_quickhouse_state` FINAL \
101
97
  WHERE source_table = '{}' AND dest_table = '{}' \
@@ -109,11 +105,7 @@ impl ClickHouseSink {
109
105
 
110
106
  /// Persist a new watermark after a successful incremental run.
111
107
  pub async fn persist_watermark(&self, cfg: &TransferConfig, watermark: &str, rows: u64) -> Result<()> {
112
- let source_id = cfg
113
- .source_table
114
- .clone()
115
- .or_else(|| cfg.source_query.clone())
116
- .unwrap_or_default();
108
+ let source_id = cfg.effective_state_key();
117
109
  let sql = format!(
118
110
  "INSERT INTO {}.`_quickhouse_state` (source_table, dest_table, last_watermark, rows) \
119
111
  VALUES ('{}', '{}', '{}', {})",
@@ -165,13 +165,14 @@ impl Sink {
165
165
  staging: &str,
166
166
  key: &[String],
167
167
  columns: &[ColumnType],
168
+ prune_partition: Option<&str>,
168
169
  ) -> Result<()> {
169
170
  match self {
170
171
  Sink::ClickHouse(_) => Err(EtlError::internal(
171
172
  "merge_into called on a ClickHouse sink — unreachable, since ClickHouse never \
172
173
  reports requires_staging_for_incremental()",
173
174
  )),
174
- Sink::BigQuery(s) => s.merge_into(dest, staging, key, columns).await,
175
+ Sink::BigQuery(s) => s.merge_into(dest, staging, key, columns, prune_partition).await,
175
176
  }
176
177
  }
177
178
  }
@@ -19,7 +19,7 @@ use tokio::task::JoinSet;
19
19
  use crate::archive::{archive_object_key, build_s3_store, S3ArchiveWriter};
20
20
  use crate::config::{
21
21
  DestinationConfig, ParquetCompression, S3ArchiveConfig, SourceConfig, SyncMode, TransferConfig,
22
- TransferResult,
22
+ TransferResult, WatermarkSeed,
23
23
  };
24
24
  use crate::decode::CopyDecoder;
25
25
  use crate::decode_bigquery::BigQueryBatcher;
@@ -368,6 +368,10 @@ async fn run_transfer_impl(
368
368
  let (extra_filter, new_watermark) = if cfg.mode == SyncMode::Incremental {
369
369
  let watermark = cfg.watermark.as_ref().unwrap();
370
370
  let last = sink.read_last_watermark(&cfg).await?;
371
+ // First run only (no persisted cursor): apply the configured seed so a
372
+ // fresh table can skip a doomed whole-table pull. Self-retires once a
373
+ // real watermark is persisted (`last` is then Some).
374
+ let last = last.or_else(|| seed_value(&cfg.seed_watermark, snapshot_max.as_deref()));
371
375
  tracing::info!(
372
376
  "incremental watermark on '{}': last synced={:?}, current source max={:?}",
373
377
  watermark,
@@ -492,13 +496,19 @@ async fn run_transfer_impl(
492
496
  if cfg.mode == SyncMode::Incremental {
493
497
  if sink.requires_staging_for_incremental() {
494
498
  tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
495
- sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns).await?;
499
+ sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns, cfg.merge_prune_partition_by.as_deref()).await?;
496
500
  sink.drop_table(&staging).await?;
497
501
  }
498
502
  if let Some(w) = &new_watermark {
499
- tracing::info!("persisting new watermark: {w}");
500
- sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
501
- .await?;
503
+ if cfg.advance_watermark {
504
+ tracing::info!("persisting new watermark: {w}");
505
+ sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
506
+ .await?;
507
+ } else {
508
+ tracing::info!(
509
+ "advance_watermark=false: computed watermark {w} NOT persisted (cursor left unchanged)"
510
+ );
511
+ }
502
512
  }
503
513
  }
504
514
 
@@ -571,6 +581,9 @@ async fn run_transfer_bigquery(
571
581
  let snapshot_max = source
572
582
  .max_watermark(&client, &project_id, &table_sql, watermark)
573
583
  .await?;
584
+ // First run only: apply the seed (needs snapshot_max, computed above,
585
+ // for the CurrentMax variant). Self-retires once a cursor is persisted.
586
+ let last = last.or_else(|| seed_value(&cfg.seed_watermark, snapshot_max.as_deref()));
574
587
  tracing::info!(
575
588
  "incremental watermark on '{}': last synced={:?}, current source max={:?}",
576
589
  watermark,
@@ -679,13 +692,19 @@ async fn run_transfer_bigquery(
679
692
  if cfg.mode == SyncMode::Incremental {
680
693
  if sink.requires_staging_for_incremental() {
681
694
  tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
682
- sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns).await?;
695
+ sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns, cfg.merge_prune_partition_by.as_deref()).await?;
683
696
  sink.drop_table(&staging).await?;
684
697
  }
685
698
  if let Some(w) = &new_watermark {
686
- tracing::info!("persisting new watermark: {w}");
687
- sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
688
- .await?;
699
+ if cfg.advance_watermark {
700
+ tracing::info!("persisting new watermark: {w}");
701
+ sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
702
+ .await?;
703
+ } else {
704
+ tracing::info!(
705
+ "advance_watermark=false: computed watermark {w} NOT persisted (cursor left unchanged)"
706
+ );
707
+ }
689
708
  }
690
709
  }
691
710
 
@@ -1253,6 +1272,19 @@ async fn cleanup_staging(sink: &Sink, staging: &str) {
1253
1272
  }
1254
1273
  }
1255
1274
 
1275
+ /// Resolve the first-run seed into the effective `last` watermark. Only ever
1276
+ /// consulted when no cursor is persisted yet (`read_last_watermark` returned
1277
+ /// `None`), so it self-retires after the first successful run. `CurrentMax`
1278
+ /// seeds to the source's current MAX (reading ~nothing — for a destination
1279
+ /// already backfilled by a prior pipeline).
1280
+ fn seed_value(seed: &WatermarkSeed, snapshot_max: Option<&str>) -> Option<String> {
1281
+ match seed {
1282
+ WatermarkSeed::None => None,
1283
+ WatermarkSeed::Value(v) => Some(v.clone()),
1284
+ WatermarkSeed::CurrentMax => snapshot_max.map(str::to_string),
1285
+ }
1286
+ }
1287
+
1256
1288
  fn build_watermark_filter_pg(
1257
1289
  watermark: &str,
1258
1290
  last: Option<&str>,
@@ -1453,6 +1485,24 @@ mod tests {
1453
1485
  assert_ne!(staging_name("orders", "1"), staging_name("orders", "2"));
1454
1486
  }
1455
1487
 
1488
+ #[test]
1489
+ fn seed_value_resolves_first_run_floor() {
1490
+ // None seed -> no floor (whole-table first pull).
1491
+ assert_eq!(seed_value(&WatermarkSeed::None, Some("2026-01-01")), None);
1492
+ // Explicit floor is used verbatim.
1493
+ assert_eq!(
1494
+ seed_value(&WatermarkSeed::Value("2026-01-01".into()), Some("2026-06-01")),
1495
+ Some("2026-01-01".to_string())
1496
+ );
1497
+ // CurrentMax seeds to the source's snapshot max (skip the first pull).
1498
+ assert_eq!(
1499
+ seed_value(&WatermarkSeed::CurrentMax, Some("2026-06-01")),
1500
+ Some("2026-06-01".to_string())
1501
+ );
1502
+ // CurrentMax on an empty source (no max) is a safe no-op.
1503
+ assert_eq!(seed_value(&WatermarkSeed::CurrentMax, None), None);
1504
+ }
1505
+
1456
1506
  #[test]
1457
1507
  fn throttle_reserve_zero_is_immediate() {
1458
1508
  let t = ReadThrottle::new(1000);
@@ -273,12 +273,16 @@ mod tests {
273
273
  order_by: vec![],
274
274
  partition_by: None,
275
275
  primary_key: vec![],
276
+ merge_prune_partition_by: None,
276
277
  parallelism: 1,
277
278
  batch_rows: 1,
278
279
  batch_bytes: 0,
279
280
  max_memory_bytes: 0,
280
281
  partition_column: None,
281
282
  read_max_rows_per_sec: None,
283
+ state_key: None,
284
+ seed_watermark: crate::config::WatermarkSeed::None,
285
+ advance_watermark: true,
282
286
  type_overrides: HashMap::new(),
283
287
  rename: HashMap::new(),
284
288
  include: vec![],
@@ -465,15 +465,20 @@ fn parse_parquet_compression(c: &str) -> PyResult<core::ParquetCompression> {
465
465
  *,
466
466
  source_table=None,
467
467
  source_query=None,
468
+ state_key=None,
468
469
  mode="full".to_string(),
469
470
  watermark=None,
470
471
  lookback_seconds=0,
472
+ seed_watermark=None,
473
+ skip_to_max=false,
474
+ advance_watermark=true,
471
475
  key=None,
472
476
  create_if_missing=true,
473
477
  engine=None,
474
478
  order_by=None,
475
479
  partition_by=None,
476
480
  primary_key=None,
481
+ merge_prune_partition_by=None,
477
482
  parallelism=4,
478
483
  batch_rows=100_000,
479
484
  batch_bytes=4_194_304,
@@ -494,15 +499,20 @@ fn sync(
494
499
  dest_table: String,
495
500
  source_table: Option<String>,
496
501
  source_query: Option<String>,
502
+ state_key: Option<String>,
497
503
  mode: String,
498
504
  watermark: Option<String>,
499
505
  lookback_seconds: u64,
506
+ seed_watermark: Option<String>,
507
+ skip_to_max: bool,
508
+ advance_watermark: bool,
500
509
  key: Option<Vec<String>>,
501
510
  create_if_missing: bool,
502
511
  engine: Option<String>,
503
512
  order_by: Option<Vec<String>>,
504
513
  partition_by: Option<String>,
505
514
  primary_key: Option<Vec<String>>,
515
+ merge_prune_partition_by: Option<String>,
506
516
  parallelism: usize,
507
517
  batch_rows: usize,
508
518
  batch_bytes: usize,
@@ -518,19 +528,35 @@ fn sync(
518
528
  init_logging();
519
529
  let source_cfg: core::SourceConfig = source.into();
520
530
  let dest_cfg = target.into_config()?;
531
+ // `seed_watermark` (an explicit floor) and `skip_to_max` (seed to the
532
+ // source's current MAX) are mutually exclusive ways to seed the first run.
533
+ let seed_watermark = match (seed_watermark, skip_to_max) {
534
+ (Some(_), true) => {
535
+ return Err(PyRuntimeError::new_err(
536
+ "seed_watermark and skip_to_max are mutually exclusive",
537
+ ))
538
+ }
539
+ (Some(v), false) => core::WatermarkSeed::Value(v),
540
+ (None, true) => core::WatermarkSeed::CurrentMax,
541
+ (None, false) => core::WatermarkSeed::None,
542
+ };
521
543
  let cfg = core::TransferConfig {
522
544
  source_table,
523
545
  source_query,
524
546
  dest_table,
547
+ state_key,
525
548
  mode: parse_mode(&mode)?,
526
549
  watermark,
527
550
  lookback_seconds,
551
+ seed_watermark,
552
+ advance_watermark,
528
553
  key: key.unwrap_or_default(),
529
554
  create_if_missing,
530
555
  engine,
531
556
  order_by: order_by.unwrap_or_default(),
532
557
  partition_by,
533
558
  primary_key: primary_key.unwrap_or_default(),
559
+ merge_prune_partition_by,
534
560
  parallelism,
535
561
  batch_rows,
536
562
  batch_bytes,
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "quickhouse"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "Fast PostgreSQL/MySQL/BigQuery -> ClickHouse ETL with a Rust engine (parallel, bounded-memory, Arrow-based)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -218,15 +218,20 @@ def sync(
218
218
  *,
219
219
  source_table: Optional[str] = None,
220
220
  source_query: Optional[str] = None,
221
+ state_key: Optional[str] = None,
221
222
  mode: str = "full",
222
223
  watermark: Optional[str] = None,
223
224
  lookback_seconds: int = 0,
225
+ seed_watermark: Optional[str] = None,
226
+ skip_to_max: bool = False,
227
+ advance_watermark: bool = True,
224
228
  key: Optional[Sequence[str]] = None,
225
229
  create_if_missing: bool = True,
226
230
  engine: Optional[str] = None,
227
231
  order_by: Optional[Sequence[str]] = None,
228
232
  partition_by: Optional[str] = None,
229
233
  primary_key: Optional[Sequence[str]] = None,
234
+ merge_prune_partition_by: Optional[str] = None,
230
235
  parallelism: int = 4,
231
236
  batch_rows: int = 100_000,
232
237
  batch_bytes: int = 4_194_304,
@@ -265,6 +270,32 @@ def sync(
265
270
  a whole day. Default ``0`` disables lookback entirely (byte-identical to
266
271
  the plain watermark filter).
267
272
 
273
+ Incremental cursor control (all incremental-mode only):
274
+
275
+ - ``state_key`` pins the identity of the persisted cursor in the internal
276
+ ``_quickhouse_state`` table. By default the cursor is keyed by
277
+ ``source_table`` (or the ``source_query`` text) + ``dest_table``. Set
278
+ ``state_key`` to (a) keep the cursor stable when you edit a
279
+ ``source_query``'s WHERE/SELECT — whose changed text would otherwise
280
+ derive a new key and silently trigger a fresh full pull — and (b) give
281
+ two syncs that share a ``dest_table`` but track different ``watermark``
282
+ columns *distinct* cursors (they otherwise collide on one state row and
283
+ clobber each other). Default ``None`` reproduces the pre-existing key
284
+ exactly, so existing state is never orphaned.
285
+ - ``seed_watermark`` / ``skip_to_max`` seed the cursor on the **first** run
286
+ only (when no cursor is persisted yet), then self-retire. ``seed_watermark``
287
+ is an explicit floor — the first run reads only rows past it.
288
+ ``skip_to_max=True`` seeds to the source's current ``MAX(watermark)``,
289
+ reading (almost) nothing — for when the destination already holds
290
+ complete data from a prior/legacy pipeline and a full first pull would be
291
+ a doomed waste. The two are mutually exclusive. Once a real watermark is
292
+ persisted both are ignored, so they are safe to leave set.
293
+ - ``advance_watermark=False`` reads and merges a window WITHOUT persisting
294
+ (advancing) the cursor — the primitive a bounded backfill needs so it
295
+ doesn't rewind the regular schedule. The computed watermark is still
296
+ returned in ``TransferResult.new_watermark`` for observability but is not
297
+ written to ``_quickhouse_state``.
298
+
268
299
  ``engine``/``order_by``/``partition_by``/``primary_key``/``key`` are
269
300
  interpreted per destination: for ClickHouse they drive `MergeTree`-family
270
301
  DDL as before; for BigQuery, ``engine`` is ignored, ``partition_by`` must
@@ -282,6 +313,19 @@ def sync(
282
313
  incremental run re-applies the same key-matched rows rather than
283
314
  duplicating them.
284
315
 
316
+ By default that ``MERGE`` full-scans the destination table every run (it
317
+ joins on ``key`` only), which on a large partitioned table bills the whole
318
+ table to upsert a few delta rows. ``merge_prune_partition_by="<col>"``
319
+ bounds the destination to the staging batch's range on ``<col>`` so
320
+ BigQuery reads only the touched partitions. **Only safe when ``<col>`` is
321
+ immutable per ``key``** (its value never changes across updates to a row)
322
+ and it is the table's partition column — e.g. a ``create_date``/inserted-at
323
+ column. Do **not** use a ``write_date``/updated-at column: an updated row's
324
+ new value points at a different partition than the existing row, so pruning
325
+ would miss it and INSERT A DUPLICATE KEY instead of updating (the classic
326
+ merge-filter dup bug). quickhouse can't detect mutability — this is a
327
+ deliberate per-table opt-in. Default ``None`` keeps the safe full scan.
328
+
285
329
  Memory vs. batch sizing:
286
330
 
287
331
  - ``batch_rows`` / ``batch_bytes`` control how big each individual Arrow
File without changes