quickhouse 0.3.2__tar.gz → 0.3.3__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.2 → quickhouse-0.3.3}/Cargo.lock +4 -4
- {quickhouse-0.3.2 → quickhouse-0.3.3}/Cargo.toml +1 -1
- {quickhouse-0.3.2 → quickhouse-0.3.3}/PKG-INFO +7 -1
- {quickhouse-0.3.2 → quickhouse-0.3.3}/README.md +6 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sync.rs +288 -215
- {quickhouse-0.3.2 → quickhouse-0.3.3}/pyproject.toml +1 -1
- {quickhouse-0.3.2 → quickhouse-0.3.3}/LICENSE +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/Cargo.toml +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/archive.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/config.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/ddl.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decimal.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode_bigquery.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode_mysql.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/error.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/lib.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/memory.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/bigquery.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/bigquery_proto.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/clickhouse.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/mod.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/mod.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/mysql.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/postgres.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/transform.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/types.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-py/Cargo.toml +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-py/src/lib.rs +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/__init__.py +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/_quickhouse.pyi +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/progress.py +0 -0
- {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/py.typed +0 -0
|
@@ -2634,7 +2634,7 @@ dependencies = [
|
|
|
2634
2634
|
|
|
2635
2635
|
[[package]]
|
|
2636
2636
|
name = "quickhouse-core"
|
|
2637
|
-
version = "0.3.
|
|
2637
|
+
version = "0.3.3"
|
|
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.
|
|
2673
|
+
version = "0.3.3"
|
|
2674
2674
|
dependencies = [
|
|
2675
2675
|
"pyo3",
|
|
2676
2676
|
"quickhouse-core",
|
|
@@ -3038,9 +3038,9 @@ dependencies = [
|
|
|
3038
3038
|
|
|
3039
3039
|
[[package]]
|
|
3040
3040
|
name = "rustls-pki-types"
|
|
3041
|
-
version = "1.15.
|
|
3041
|
+
version = "1.15.1"
|
|
3042
3042
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3043
|
-
checksum = "
|
|
3043
|
+
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
|
3044
3044
|
dependencies = [
|
|
3045
3045
|
"web-time",
|
|
3046
3046
|
"zeroize",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickhouse
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -180,6 +180,12 @@ deduplicated on `key` — via ClickHouse's `ReplacingMergeTree`, or a `MERGE`
|
|
|
180
180
|
upsert on BigQuery (where `key` is therefore required). Re-running with no new
|
|
181
181
|
data does nothing.
|
|
182
182
|
|
|
183
|
+
Both modes stage through a per-run-unique table (`{dest}_quickhouse_tmp_<id>`)
|
|
184
|
+
that's dropped when the run finishes, including on failure. The unique name is
|
|
185
|
+
what makes rapid re-runs and whole-call retries safe on BigQuery, whose
|
|
186
|
+
streaming ingestion rejects writes into a table recently recreated under the
|
|
187
|
+
same name.
|
|
188
|
+
|
|
183
189
|
For daily syncs that need to catch late-arriving or edited rows, set
|
|
184
190
|
`lookback_seconds` to re-scan a trailing window (e.g. `3 * 86400` for the last
|
|
185
191
|
three days) — the dedup above keeps that overlap from creating duplicates.
|
|
@@ -148,6 +148,12 @@ deduplicated on `key` — via ClickHouse's `ReplacingMergeTree`, or a `MERGE`
|
|
|
148
148
|
upsert on BigQuery (where `key` is therefore required). Re-running with no new
|
|
149
149
|
data does nothing.
|
|
150
150
|
|
|
151
|
+
Both modes stage through a per-run-unique table (`{dest}_quickhouse_tmp_<id>`)
|
|
152
|
+
that's dropped when the run finishes, including on failure. The unique name is
|
|
153
|
+
what makes rapid re-runs and whole-call retries safe on BigQuery, whose
|
|
154
|
+
streaming ingestion rejects writes into a table recently recreated under the
|
|
155
|
+
same name.
|
|
156
|
+
|
|
151
157
|
For daily syncs that need to catch late-arriving or edited rows, set
|
|
152
158
|
`lookback_seconds` to re-scan a trailing window (e.g. `3 * 86400` for the last
|
|
153
159
|
three days) — the dedup above keeps that overlap from creating duplicates.
|
|
@@ -243,6 +243,11 @@ async fn run_transfer_impl(
|
|
|
243
243
|
};
|
|
244
244
|
let archive_info = build_archive_run_info(s3_archive_cfg, &cfg.dest_table)?;
|
|
245
245
|
|
|
246
|
+
// Per-run-unique staging table name (see `staging_name`), computed once
|
|
247
|
+
// and reused at every create/swap/merge/drop site this run.
|
|
248
|
+
let run_id = new_run_id();
|
|
249
|
+
let staging = staging_name(&cfg.dest_table, &run_id);
|
|
250
|
+
|
|
246
251
|
// BigQuery has a genuinely different execution model (no discrete
|
|
247
252
|
// range-partitions to fan out; a single read session that streams via
|
|
248
253
|
// BigQuery-managed parallel streams, drained sequentially on our side —
|
|
@@ -252,7 +257,7 @@ async fn run_transfer_impl(
|
|
|
252
257
|
if let SourceConfig::BigQuery(bq) = &source_cfg {
|
|
253
258
|
let source = BigQuerySource::new(bq.project_id.clone(), bq.credentials_file.clone());
|
|
254
259
|
let sink = Sink::new(dest).await?;
|
|
255
|
-
return run_transfer_bigquery(&source, sink, cfg, progress, started, archive_info).await;
|
|
260
|
+
return run_transfer_bigquery(&source, sink, cfg, progress, started, archive_info, staging).await;
|
|
256
261
|
}
|
|
257
262
|
|
|
258
263
|
let source = Arc::new(match &source_cfg {
|
|
@@ -327,123 +332,134 @@ async fn run_transfer_impl(
|
|
|
327
332
|
};
|
|
328
333
|
|
|
329
334
|
// --- Ensure destination / staging tables exist. ---
|
|
330
|
-
let target_table = prepare_target(&sink, &cfg, &plan.dest_columns).await?;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
let target_table = prepare_target(&sink, &cfg, &plan.dest_columns, &staging).await?;
|
|
336
|
+
// Whether this run actually created a staging table (vs. inserting straight
|
|
337
|
+
// into the destination, as ClickHouse incremental does) — decides whether
|
|
338
|
+
// the error path below has anything to clean up.
|
|
339
|
+
let used_staging = target_table != cfg.dest_table;
|
|
340
|
+
let cleanup_sink = sink.clone();
|
|
341
|
+
let cleanup_staging_name = staging.clone();
|
|
342
|
+
|
|
343
|
+
// The whole fallible tail runs inside this block so that, on ANY error, we
|
|
344
|
+
// best-effort drop the per-run staging table below — a unique-per-run name
|
|
345
|
+
// is never reclaimed by a later run (unlike the old fixed name), so a
|
|
346
|
+
// failed run would otherwise leak it forever.
|
|
347
|
+
let outcome: Result<TransferResult> = async move {
|
|
348
|
+
tracing::info!(
|
|
349
|
+
"quickhouse: transferring into {} across {} partition(s), parallelism={}",
|
|
350
|
+
target_table,
|
|
351
|
+
partitions.len(),
|
|
352
|
+
cfg.parallelism
|
|
353
|
+
);
|
|
338
354
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
355
|
+
// --- Fan out partitions with bounded concurrency. ---
|
|
356
|
+
let counters = Arc::new(Counters::default());
|
|
357
|
+
let cfg = Arc::new(cfg);
|
|
358
|
+
let extra_filter = Arc::new(extra_filter);
|
|
359
|
+
let target_table = Arc::new(target_table);
|
|
360
|
+
let ctx = SendCtx {
|
|
361
|
+
sink: sink.clone(),
|
|
362
|
+
budget: MemoryBudget::new(cfg.max_memory_bytes),
|
|
363
|
+
target_table: target_table.clone(),
|
|
364
|
+
counters: counters.clone(),
|
|
365
|
+
progress: progress.clone(),
|
|
366
|
+
started,
|
|
367
|
+
archive: archive_info.clone(),
|
|
368
|
+
};
|
|
353
369
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
370
|
+
let mut results = futures::stream::iter(partitions.into_iter().map(|part| {
|
|
371
|
+
let source = source.clone();
|
|
372
|
+
let plan = plan.clone();
|
|
373
|
+
let cfg = cfg.clone();
|
|
374
|
+
let ctx = ctx.clone();
|
|
375
|
+
let extra_filter = extra_filter.clone();
|
|
376
|
+
let base_table = base_table.clone();
|
|
377
|
+
let base_query = base_query.clone();
|
|
378
|
+
async move {
|
|
379
|
+
match source.as_ref() {
|
|
380
|
+
Source::Postgres(s) => {
|
|
381
|
+
transfer_partition_postgres(
|
|
382
|
+
s,
|
|
383
|
+
&plan,
|
|
384
|
+
&cfg,
|
|
385
|
+
&ctx,
|
|
386
|
+
base_table.as_deref(),
|
|
387
|
+
base_query.as_deref(),
|
|
388
|
+
extra_filter.as_deref(),
|
|
389
|
+
part,
|
|
390
|
+
)
|
|
391
|
+
.await
|
|
392
|
+
}
|
|
393
|
+
Source::MySql(s) => {
|
|
394
|
+
transfer_partition_mysql(
|
|
395
|
+
s,
|
|
396
|
+
&plan,
|
|
397
|
+
&cfg,
|
|
398
|
+
&ctx,
|
|
399
|
+
base_table.as_deref(),
|
|
400
|
+
base_query.as_deref(),
|
|
401
|
+
extra_filter.as_deref(),
|
|
402
|
+
part,
|
|
403
|
+
)
|
|
404
|
+
.await
|
|
405
|
+
}
|
|
406
|
+
Source::BigQuery(_) => unreachable!("BigQuery is handled via the early return in run_transfer"),
|
|
389
407
|
}
|
|
390
|
-
Source::BigQuery(_) => unreachable!("BigQuery is handled via the early return in run_transfer"),
|
|
391
408
|
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
.buffer_unordered(cfg.parallelism);
|
|
395
|
-
|
|
396
|
-
while let Some(r) = results.next().await {
|
|
397
|
-
r?; // propagate the first partition error
|
|
398
|
-
}
|
|
399
|
-
tracing::info!(
|
|
400
|
-
"all partitions read: {} rows written",
|
|
401
|
-
counters.rows_written.load(Ordering::Relaxed)
|
|
402
|
-
);
|
|
409
|
+
}))
|
|
410
|
+
.buffer_unordered(cfg.parallelism);
|
|
403
411
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
412
|
+
while let Some(r) = results.next().await {
|
|
413
|
+
r?; // propagate the first partition error
|
|
414
|
+
}
|
|
415
|
+
tracing::info!(
|
|
416
|
+
"all partitions read: {} rows written",
|
|
417
|
+
counters.rows_written.load(Ordering::Relaxed)
|
|
418
|
+
);
|
|
411
419
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
sink.merge_into(
|
|
418
|
-
&cfg.dest_table,
|
|
419
|
-
&staging_name(&cfg.dest_table),
|
|
420
|
-
&cfg.key,
|
|
421
|
-
&plan.dest_columns,
|
|
422
|
-
)
|
|
423
|
-
.await?;
|
|
424
|
-
sink.drop_table(&staging_name(&cfg.dest_table)).await?;
|
|
420
|
+
// --- Full refresh: atomically swap staging into place. ---
|
|
421
|
+
if cfg.mode == SyncMode::Full {
|
|
422
|
+
tracing::info!("swapping staging table into '{}'", cfg.dest_table);
|
|
423
|
+
sink.atomic_swap(&cfg.dest_table, &staging, &plan.dest_columns).await?;
|
|
424
|
+
sink.drop_table(&staging).await?;
|
|
425
425
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
426
|
+
|
|
427
|
+
// --- Incremental: merge staged rows into the destination (destinations
|
|
428
|
+
// with no engine-level dedup), then persist the new watermark. ---
|
|
429
|
+
if cfg.mode == SyncMode::Incremental {
|
|
430
|
+
if sink.requires_staging_for_incremental() {
|
|
431
|
+
tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
|
|
432
|
+
sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns).await?;
|
|
433
|
+
sink.drop_table(&staging).await?;
|
|
434
|
+
}
|
|
435
|
+
if let Some(w) = &new_watermark {
|
|
436
|
+
tracing::info!("persisting new watermark: {w}");
|
|
437
|
+
sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
|
|
438
|
+
.await?;
|
|
439
|
+
}
|
|
430
440
|
}
|
|
441
|
+
|
|
442
|
+
let duration_secs = started.elapsed().as_secs_f64();
|
|
443
|
+
tracing::info!(
|
|
444
|
+
"transfer complete: {} rows in {:.2}s ({:.0} rows/s)",
|
|
445
|
+
counters.rows_written.load(Ordering::Relaxed),
|
|
446
|
+
duration_secs,
|
|
447
|
+
counters.rows_written.load(Ordering::Relaxed) as f64 / duration_secs.max(0.001)
|
|
448
|
+
);
|
|
449
|
+
Ok(TransferResult {
|
|
450
|
+
rows_read: counters.rows_read.load(Ordering::Relaxed),
|
|
451
|
+
rows_written: counters.rows_written.load(Ordering::Relaxed),
|
|
452
|
+
bytes_written: counters.bytes_written.load(Ordering::Relaxed),
|
|
453
|
+
duration_secs,
|
|
454
|
+
new_watermark,
|
|
455
|
+
})
|
|
431
456
|
}
|
|
457
|
+
.await;
|
|
432
458
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
duration_secs,
|
|
438
|
-
counters.rows_written.load(Ordering::Relaxed) as f64 / duration_secs.max(0.001)
|
|
439
|
-
);
|
|
440
|
-
Ok(TransferResult {
|
|
441
|
-
rows_read: counters.rows_read.load(Ordering::Relaxed),
|
|
442
|
-
rows_written: counters.rows_written.load(Ordering::Relaxed),
|
|
443
|
-
bytes_written: counters.bytes_written.load(Ordering::Relaxed),
|
|
444
|
-
duration_secs,
|
|
445
|
-
new_watermark,
|
|
446
|
-
})
|
|
459
|
+
if outcome.is_err() && used_staging {
|
|
460
|
+
cleanup_staging(&cleanup_sink, &cleanup_staging_name).await;
|
|
461
|
+
}
|
|
462
|
+
outcome
|
|
447
463
|
}
|
|
448
464
|
|
|
449
465
|
/// BigQuery's whole transfer flow: no discrete range-partitions (see the
|
|
@@ -457,6 +473,7 @@ async fn run_transfer_bigquery(
|
|
|
457
473
|
progress: Option<ProgressCb>,
|
|
458
474
|
started: Instant,
|
|
459
475
|
archive_info: Option<Arc<ArchiveRunInfo>>,
|
|
476
|
+
staging: String,
|
|
460
477
|
) -> Result<TransferResult> {
|
|
461
478
|
let (client, project_id) = source.connect().await?;
|
|
462
479
|
tracing::info!("authenticated with bigquery, project_id={project_id}");
|
|
@@ -509,117 +526,123 @@ async fn run_transfer_bigquery(
|
|
|
509
526
|
(None, None)
|
|
510
527
|
};
|
|
511
528
|
|
|
512
|
-
let target_table = prepare_target(&sink, &cfg, &plan.dest_columns).await?;
|
|
529
|
+
let target_table = prepare_target(&sink, &cfg, &plan.dest_columns, &staging).await?;
|
|
530
|
+
let used_staging = target_table != cfg.dest_table;
|
|
531
|
+
let cleanup_sink = sink.clone();
|
|
532
|
+
let cleanup_staging_name = staging.clone();
|
|
513
533
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
534
|
+
// Fallible tail wrapped so any error triggers best-effort staging cleanup
|
|
535
|
+
// below (a unique-per-run staging name is never reclaimed by a later run).
|
|
536
|
+
let outcome: Result<TransferResult> = async move {
|
|
537
|
+
tracing::info!(
|
|
538
|
+
"quickhouse: transferring into {} via BigQuery Storage Read API, max_stream_count={}",
|
|
539
|
+
target_table,
|
|
540
|
+
cfg.parallelism
|
|
541
|
+
);
|
|
519
542
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
543
|
+
let counters = Arc::new(Counters::default());
|
|
544
|
+
let ctx = SendCtx {
|
|
545
|
+
sink: sink.clone(),
|
|
546
|
+
budget: MemoryBudget::new(cfg.max_memory_bytes),
|
|
547
|
+
target_table: Arc::new(target_table),
|
|
548
|
+
counters: counters.clone(),
|
|
549
|
+
progress: progress.clone(),
|
|
550
|
+
started,
|
|
551
|
+
archive: archive_info,
|
|
552
|
+
};
|
|
530
553
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
554
|
+
let mut iter = source
|
|
555
|
+
.read_table::<google_cloud_bigquery::storage::row::Row>(
|
|
556
|
+
&client,
|
|
557
|
+
&table_ref,
|
|
558
|
+
&plan.source_columns,
|
|
559
|
+
row_restriction.as_deref(),
|
|
560
|
+
cfg.parallelism as i32,
|
|
561
|
+
)
|
|
562
|
+
.await?;
|
|
540
563
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
564
|
+
let mut batcher = BigQueryBatcher::with_batch_bytes(&plan.dest_columns, cfg.batch_rows, cfg.batch_bytes)?;
|
|
565
|
+
let schema = batcher.schema();
|
|
566
|
+
let mut sends: JoinSet<Result<()>> = JoinSet::new();
|
|
567
|
+
// No discrete partitions on this path (see the module docs) — "all" is
|
|
568
|
+
// the only file this run will ever archive for this table.
|
|
569
|
+
let mut archive_writer = match &ctx.archive {
|
|
570
|
+
Some(info) => Some(info.writer_for("all", schema.clone())?),
|
|
571
|
+
None => None,
|
|
572
|
+
};
|
|
573
|
+
while let Some(row) = iter
|
|
574
|
+
.next()
|
|
575
|
+
.await
|
|
576
|
+
.map_err(|e| EtlError::other(format!("bigquery row error: {e}")))?
|
|
577
|
+
{
|
|
578
|
+
if let Some(batch) = batcher.append_row(&row)? {
|
|
579
|
+
if let Some(w) = archive_writer.as_mut() {
|
|
580
|
+
w.write(&batch).await?;
|
|
581
|
+
}
|
|
582
|
+
ctx.spawn_upload(&mut sends, schema.clone(), batch).await;
|
|
583
|
+
reap(&mut sends, false).await?;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if let Some(batch) = batcher.finish()? {
|
|
556
587
|
if let Some(w) = archive_writer.as_mut() {
|
|
557
588
|
w.write(&batch).await?;
|
|
558
589
|
}
|
|
559
590
|
ctx.spawn_upload(&mut sends, schema.clone(), batch).await;
|
|
560
|
-
reap(&mut sends, false).await?;
|
|
561
591
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if let Some(w) = archive_writer.as_mut() {
|
|
565
|
-
w.write(&batch).await?;
|
|
592
|
+
if let Some(w) = archive_writer.take() {
|
|
593
|
+
w.close().await?;
|
|
566
594
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
warn_coerced_decimals("bigquery read", batcher.invalid_decimals_total);
|
|
579
|
-
tracing::info!(
|
|
580
|
-
"bigquery read complete: {} rows written",
|
|
581
|
-
counters.rows_written.load(Ordering::Relaxed)
|
|
582
|
-
);
|
|
595
|
+
reap(&mut sends, true).await?;
|
|
596
|
+
counters
|
|
597
|
+
.rows_read
|
|
598
|
+
.fetch_add(batcher.rows_total, Ordering::Relaxed);
|
|
599
|
+
emit_progress(&counters, &progress, started);
|
|
600
|
+
warn_coerced_dates("bigquery read", batcher.invalid_dates_total);
|
|
601
|
+
warn_coerced_decimals("bigquery read", batcher.invalid_decimals_total);
|
|
602
|
+
tracing::info!(
|
|
603
|
+
"bigquery read complete: {} rows written",
|
|
604
|
+
counters.rows_written.load(Ordering::Relaxed)
|
|
605
|
+
);
|
|
583
606
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
.await?;
|
|
588
|
-
sink.drop_table(&staging_name(&cfg.dest_table)).await?;
|
|
589
|
-
}
|
|
590
|
-
if cfg.mode == SyncMode::Incremental {
|
|
591
|
-
if sink.requires_staging_for_incremental() {
|
|
592
|
-
tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
|
|
593
|
-
sink.merge_into(
|
|
594
|
-
&cfg.dest_table,
|
|
595
|
-
&staging_name(&cfg.dest_table),
|
|
596
|
-
&cfg.key,
|
|
597
|
-
&plan.dest_columns,
|
|
598
|
-
)
|
|
599
|
-
.await?;
|
|
600
|
-
sink.drop_table(&staging_name(&cfg.dest_table)).await?;
|
|
607
|
+
if cfg.mode == SyncMode::Full {
|
|
608
|
+
tracing::info!("swapping staging table into '{}'", cfg.dest_table);
|
|
609
|
+
sink.atomic_swap(&cfg.dest_table, &staging, &plan.dest_columns).await?;
|
|
610
|
+
sink.drop_table(&staging).await?;
|
|
601
611
|
}
|
|
602
|
-
if
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
.await?;
|
|
612
|
+
if cfg.mode == SyncMode::Incremental {
|
|
613
|
+
if sink.requires_staging_for_incremental() {
|
|
614
|
+
tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
|
|
615
|
+
sink.merge_into(&cfg.dest_table, &staging, &cfg.key, &plan.dest_columns).await?;
|
|
616
|
+
sink.drop_table(&staging).await?;
|
|
617
|
+
}
|
|
618
|
+
if let Some(w) = &new_watermark {
|
|
619
|
+
tracing::info!("persisting new watermark: {w}");
|
|
620
|
+
sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
|
|
621
|
+
.await?;
|
|
622
|
+
}
|
|
606
623
|
}
|
|
624
|
+
|
|
625
|
+
let duration_secs = started.elapsed().as_secs_f64();
|
|
626
|
+
tracing::info!(
|
|
627
|
+
"transfer complete: {} rows in {:.2}s ({:.0} rows/s)",
|
|
628
|
+
counters.rows_written.load(Ordering::Relaxed),
|
|
629
|
+
duration_secs,
|
|
630
|
+
counters.rows_written.load(Ordering::Relaxed) as f64 / duration_secs.max(0.001)
|
|
631
|
+
);
|
|
632
|
+
Ok(TransferResult {
|
|
633
|
+
rows_read: counters.rows_read.load(Ordering::Relaxed),
|
|
634
|
+
rows_written: counters.rows_written.load(Ordering::Relaxed),
|
|
635
|
+
bytes_written: counters.bytes_written.load(Ordering::Relaxed),
|
|
636
|
+
duration_secs,
|
|
637
|
+
new_watermark,
|
|
638
|
+
})
|
|
607
639
|
}
|
|
640
|
+
.await;
|
|
608
641
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
duration_secs,
|
|
614
|
-
counters.rows_written.load(Ordering::Relaxed) as f64 / duration_secs.max(0.001)
|
|
615
|
-
);
|
|
616
|
-
Ok(TransferResult {
|
|
617
|
-
rows_read: counters.rows_read.load(Ordering::Relaxed),
|
|
618
|
-
rows_written: counters.rows_written.load(Ordering::Relaxed),
|
|
619
|
-
bytes_written: counters.bytes_written.load(Ordering::Relaxed),
|
|
620
|
-
duration_secs,
|
|
621
|
-
new_watermark,
|
|
622
|
-
})
|
|
642
|
+
if outcome.is_err() && used_staging {
|
|
643
|
+
cleanup_staging(&cleanup_sink, &cleanup_staging_name).await;
|
|
644
|
+
}
|
|
645
|
+
outcome
|
|
623
646
|
}
|
|
624
647
|
|
|
625
648
|
async fn setup_postgres(
|
|
@@ -960,6 +983,7 @@ async fn prepare_target(
|
|
|
960
983
|
sink: &Sink,
|
|
961
984
|
cfg: &TransferConfig,
|
|
962
985
|
dest_columns: &[ColumnType],
|
|
986
|
+
staging: &str,
|
|
963
987
|
) -> Result<String> {
|
|
964
988
|
match cfg.mode {
|
|
965
989
|
SyncMode::Full => {
|
|
@@ -977,12 +1001,14 @@ async fn prepare_target(
|
|
|
977
1001
|
} else {
|
|
978
1002
|
tracing::debug!("destination table '{}' already exists", cfg.dest_table);
|
|
979
1003
|
}
|
|
980
|
-
// Fresh staging table
|
|
981
|
-
|
|
1004
|
+
// Fresh, per-run-unique staging table. The unique name (see
|
|
1005
|
+
// `staging_name`) is why we can create it without first dropping a
|
|
1006
|
+
// prior one: a name that's never been used can't collide with a
|
|
1007
|
+
// crashed run's orphan, and — for BigQuery — never enters the
|
|
1008
|
+
// "recently deleted/recreated" state that blocks streaming inserts.
|
|
982
1009
|
tracing::info!("creating staging table '{staging}'");
|
|
983
|
-
sink.
|
|
984
|
-
|
|
985
|
-
Ok(staging)
|
|
1010
|
+
sink.create_table(staging, dest_columns, cfg).await?;
|
|
1011
|
+
Ok(staging.to_string())
|
|
986
1012
|
}
|
|
987
1013
|
SyncMode::Incremental => {
|
|
988
1014
|
// Checked first, before any network I/O: MERGE has nothing to
|
|
@@ -1015,11 +1041,9 @@ async fn prepare_target(
|
|
|
1015
1041
|
sink.ensure_state_table().await?;
|
|
1016
1042
|
|
|
1017
1043
|
if sink.requires_staging_for_incremental() {
|
|
1018
|
-
let staging = staging_name(&cfg.dest_table);
|
|
1019
1044
|
tracing::info!("creating staging table '{staging}' for incremental merge");
|
|
1020
|
-
sink.
|
|
1021
|
-
|
|
1022
|
-
Ok(staging)
|
|
1045
|
+
sink.create_table(staging, dest_columns, cfg).await?;
|
|
1046
|
+
Ok(staging.to_string())
|
|
1023
1047
|
} else {
|
|
1024
1048
|
Ok(cfg.dest_table.clone())
|
|
1025
1049
|
}
|
|
@@ -1116,8 +1140,38 @@ async fn compute_partitions_mysql(
|
|
|
1116
1140
|
.await
|
|
1117
1141
|
}
|
|
1118
1142
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1143
|
+
/// Per-run-unique staging table name: `{dest}_quickhouse_tmp_{run_id}`.
|
|
1144
|
+
///
|
|
1145
|
+
/// The `run_id` makes the name **never reused across runs** — this is a
|
|
1146
|
+
/// correctness requirement, not cosmetic. BigQuery blocks streaming inserts
|
|
1147
|
+
/// into a table that was dropped and recreated under the same name within a
|
|
1148
|
+
/// (minutes-long, eventually-consistent) metadata window; a fixed staging
|
|
1149
|
+
/// name made every rapid re-run / whole-call retry recreate-then-stream and
|
|
1150
|
+
/// hit that block. A never-before-used name can't be in the "recently
|
|
1151
|
+
/// recreated" state, so the window never applies. It also means a crashed
|
|
1152
|
+
/// run's orphaned staging table can't poison the next run (which uses a
|
|
1153
|
+
/// different name) — at the cost that orphans are no longer auto-reclaimed by
|
|
1154
|
+
/// the next run, so callers drop staging on the error path too.
|
|
1155
|
+
fn staging_name(dest: &str, run_id: &str) -> String {
|
|
1156
|
+
format!("{dest}{STAGING_SUFFIX}_{run_id}")
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/// A run id unique enough that a staging table name built from it is never
|
|
1160
|
+
/// reused across runs (including seconds-apart retries) — nanosecond wall
|
|
1161
|
+
/// clock, matching `sink::bigquery`'s `unique_job_id` idiom.
|
|
1162
|
+
fn new_run_id() -> String {
|
|
1163
|
+
time::OffsetDateTime::now_utc().unix_timestamp_nanos().to_string()
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/// Best-effort drop of a per-run staging table after a failed transfer. A
|
|
1167
|
+
/// unique-per-run name isn't reclaimed by any later run, so without this a
|
|
1168
|
+
/// failed run would leak its staging table (a full data copy, for
|
|
1169
|
+
/// full-refresh). Deliberately swallows its own error (logs a warning) so it
|
|
1170
|
+
/// never masks the real transfer error being propagated.
|
|
1171
|
+
async fn cleanup_staging(sink: &Sink, staging: &str) {
|
|
1172
|
+
if let Err(e) = sink.drop_table(staging).await {
|
|
1173
|
+
tracing::warn!("failed to drop staging table '{staging}' after a failed transfer: {e}");
|
|
1174
|
+
}
|
|
1121
1175
|
}
|
|
1122
1176
|
|
|
1123
1177
|
fn build_watermark_filter_pg(
|
|
@@ -1301,6 +1355,25 @@ mod tests {
|
|
|
1301
1355
|
use super::*;
|
|
1302
1356
|
use arrow_schema::DataType;
|
|
1303
1357
|
|
|
1358
|
+
#[test]
|
|
1359
|
+
fn staging_name_includes_dest_suffix_and_run_id() {
|
|
1360
|
+
let name = staging_name("orders", "12345");
|
|
1361
|
+
assert_eq!(name, "orders_quickhouse_tmp_12345");
|
|
1362
|
+
assert!(name.starts_with("orders"));
|
|
1363
|
+
assert!(name.contains(STAGING_SUFFIX));
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
#[test]
|
|
1367
|
+
fn staging_name_is_distinct_per_run_id() {
|
|
1368
|
+
// The core property that defeats bug 10: the same destination table
|
|
1369
|
+
// never yields the same staging name across runs, so BigQuery can't
|
|
1370
|
+
// see a drop+recreate of a recently-used name. (run_id itself comes
|
|
1371
|
+
// from a nanosecond wall clock — not asserted here to avoid a
|
|
1372
|
+
// clock-resolution-dependent flaky test; the naming logic is what
|
|
1373
|
+
// matters and is deterministic given distinct run_ids.)
|
|
1374
|
+
assert_ne!(staging_name("orders", "1"), staging_name("orders", "2"));
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1304
1377
|
fn col(name: &str) -> ColumnType {
|
|
1305
1378
|
col_typed(name, DataType::Int64)
|
|
1306
1379
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quickhouse"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
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"
|
|
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
|
|
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
|