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.
- {quickhouse-0.3.4 → quickhouse-0.4.0}/Cargo.lock +6 -6
- {quickhouse-0.3.4 → quickhouse-0.4.0}/Cargo.toml +1 -1
- {quickhouse-0.3.4 → quickhouse-0.4.0}/PKG-INFO +34 -1
- {quickhouse-0.3.4 → quickhouse-0.4.0}/README.md +33 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/config.rs +128 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/ddl.rs +4 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode.rs +52 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode_bigquery.rs +44 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decode_mysql.rs +62 -2
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/lib.rs +1 -1
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/bigquery.rs +62 -10
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/clickhouse.rs +3 -11
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/mod.rs +2 -1
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sync.rs +59 -9
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/transform.rs +4 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-py/src/lib.rs +26 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/pyproject.toml +1 -1
- {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/_quickhouse.pyi +44 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/LICENSE +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/Cargo.toml +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/archive.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/decimal.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/error.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/memory.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/sink/bigquery_proto.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/mod.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/mysql.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/source/postgres.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-core/src/types.rs +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/crates/quickhouse-py/Cargo.toml +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/__init__.py +0 -0
- {quickhouse-0.3.4 → quickhouse-0.4.0}/python/quickhouse/progress.py +0 -0
- {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.
|
|
558
|
+
version = "1.4.0"
|
|
559
559
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
560
|
-
checksum = "
|
|
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.
|
|
952
|
+
version = "1.17.0"
|
|
953
953
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
954
|
-
checksum = "
|
|
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.
|
|
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.
|
|
2673
|
+
version = "0.4.0"
|
|
2674
2674
|
dependencies = [
|
|
2675
2675
|
"pyo3",
|
|
2676
2676
|
"quickhouse-core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickhouse
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
63
|
-
|
|
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(
|
|
393
|
-
|
|
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 `(
|
|
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.
|
|
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.
|
|
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 `(
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
.
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|