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.
Files changed (34) hide show
  1. {quickhouse-0.3.2 → quickhouse-0.3.3}/Cargo.lock +4 -4
  2. {quickhouse-0.3.2 → quickhouse-0.3.3}/Cargo.toml +1 -1
  3. {quickhouse-0.3.2 → quickhouse-0.3.3}/PKG-INFO +7 -1
  4. {quickhouse-0.3.2 → quickhouse-0.3.3}/README.md +6 -0
  5. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sync.rs +288 -215
  6. {quickhouse-0.3.2 → quickhouse-0.3.3}/pyproject.toml +1 -1
  7. {quickhouse-0.3.2 → quickhouse-0.3.3}/LICENSE +0 -0
  8. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/Cargo.toml +0 -0
  9. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/archive.rs +0 -0
  10. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/config.rs +0 -0
  11. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/ddl.rs +0 -0
  12. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decimal.rs +0 -0
  13. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode.rs +0 -0
  14. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode_bigquery.rs +0 -0
  15. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/decode_mysql.rs +0 -0
  16. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/error.rs +0 -0
  17. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/lib.rs +0 -0
  18. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/memory.rs +0 -0
  19. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/bigquery.rs +0 -0
  20. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/bigquery_proto.rs +0 -0
  21. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/clickhouse.rs +0 -0
  22. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/sink/mod.rs +0 -0
  23. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
  24. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/mod.rs +0 -0
  25. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/mysql.rs +0 -0
  26. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/source/postgres.rs +0 -0
  27. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/transform.rs +0 -0
  28. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-core/src/types.rs +0 -0
  29. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-py/Cargo.toml +0 -0
  30. {quickhouse-0.3.2 → quickhouse-0.3.3}/crates/quickhouse-py/src/lib.rs +0 -0
  31. {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/__init__.py +0 -0
  32. {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/_quickhouse.pyi +0 -0
  33. {quickhouse-0.3.2 → quickhouse-0.3.3}/python/quickhouse/progress.py +0 -0
  34. {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.2"
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.2"
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.0"
3041
+ version = "1.15.1"
3042
3042
  source = "registry+https://github.com/rust-lang/crates.io-index"
3043
- checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
3043
+ checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
3044
3044
  dependencies = [
3045
3045
  "web-time",
3046
3046
  "zeroize",
@@ -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.2"
6
+ version = "0.3.3"
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.2
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
- tracing::info!(
333
- "quickhouse: transferring into {} across {} partition(s), parallelism={}",
334
- target_table,
335
- partitions.len(),
336
- cfg.parallelism
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
- // --- Fan out partitions with bounded concurrency. ---
340
- let counters = Arc::new(Counters::default());
341
- let cfg = Arc::new(cfg);
342
- let extra_filter = Arc::new(extra_filter);
343
- let target_table = Arc::new(target_table);
344
- let ctx = SendCtx {
345
- sink: sink.clone(),
346
- budget: MemoryBudget::new(cfg.max_memory_bytes),
347
- target_table: target_table.clone(),
348
- counters: counters.clone(),
349
- progress: progress.clone(),
350
- started,
351
- archive: archive_info.clone(),
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
- let mut results = futures::stream::iter(partitions.into_iter().map(|part| {
355
- let source = source.clone();
356
- let plan = plan.clone();
357
- let cfg = cfg.clone();
358
- let ctx = ctx.clone();
359
- let extra_filter = extra_filter.clone();
360
- let base_table = base_table.clone();
361
- let base_query = base_query.clone();
362
- async move {
363
- match source.as_ref() {
364
- Source::Postgres(s) => {
365
- transfer_partition_postgres(
366
- s,
367
- &plan,
368
- &cfg,
369
- &ctx,
370
- base_table.as_deref(),
371
- base_query.as_deref(),
372
- extra_filter.as_deref(),
373
- part,
374
- )
375
- .await
376
- }
377
- Source::MySql(s) => {
378
- transfer_partition_mysql(
379
- s,
380
- &plan,
381
- &cfg,
382
- &ctx,
383
- base_table.as_deref(),
384
- base_query.as_deref(),
385
- extra_filter.as_deref(),
386
- part,
387
- )
388
- .await
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
- // --- Full refresh: atomically swap staging into place. ---
405
- if cfg.mode == SyncMode::Full {
406
- tracing::info!("swapping staging table into '{}'", cfg.dest_table);
407
- sink.atomic_swap(&cfg.dest_table, &staging_name(&cfg.dest_table), &plan.dest_columns)
408
- .await?;
409
- sink.drop_table(&staging_name(&cfg.dest_table)).await?;
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
- // --- Incremental: merge staged rows into the destination (destinations
413
- // with no engine-level dedup), then persist the new watermark. ---
414
- if cfg.mode == SyncMode::Incremental {
415
- if sink.requires_staging_for_incremental() {
416
- tracing::info!("merging staged incremental rows into '{}'", cfg.dest_table);
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
- if let Some(w) = &new_watermark {
427
- tracing::info!("persisting new watermark: {w}");
428
- sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
429
- .await?;
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
- let duration_secs = started.elapsed().as_secs_f64();
434
- tracing::info!(
435
- "transfer complete: {} rows in {:.2}s ({:.0} rows/s)",
436
- counters.rows_written.load(Ordering::Relaxed),
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
- tracing::info!(
515
- "quickhouse: transferring into {} via BigQuery Storage Read API, max_stream_count={}",
516
- target_table,
517
- cfg.parallelism
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
- let counters = Arc::new(Counters::default());
521
- let ctx = SendCtx {
522
- sink: sink.clone(),
523
- budget: MemoryBudget::new(cfg.max_memory_bytes),
524
- target_table: Arc::new(target_table),
525
- counters: counters.clone(),
526
- progress: progress.clone(),
527
- started,
528
- archive: archive_info,
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
- let mut iter = source
532
- .read_table::<google_cloud_bigquery::storage::row::Row>(
533
- &client,
534
- &table_ref,
535
- &plan.source_columns,
536
- row_restriction.as_deref(),
537
- cfg.parallelism as i32,
538
- )
539
- .await?;
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
- let mut batcher = BigQueryBatcher::with_batch_bytes(&plan.dest_columns, cfg.batch_rows, cfg.batch_bytes)?;
542
- let schema = batcher.schema();
543
- let mut sends: JoinSet<Result<()>> = JoinSet::new();
544
- // No discrete partitions on this path (see the module docs) — "all" is
545
- // the only file this run will ever archive for this table.
546
- let mut archive_writer = match &ctx.archive {
547
- Some(info) => Some(info.writer_for("all", schema.clone())?),
548
- None => None,
549
- };
550
- while let Some(row) = iter
551
- .next()
552
- .await
553
- .map_err(|e| EtlError::other(format!("bigquery row error: {e}")))?
554
- {
555
- if let Some(batch) = batcher.append_row(&row)? {
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
- if let Some(batch) = batcher.finish()? {
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
- ctx.spawn_upload(&mut sends, schema.clone(), batch).await;
568
- }
569
- if let Some(w) = archive_writer.take() {
570
- w.close().await?;
571
- }
572
- reap(&mut sends, true).await?;
573
- counters
574
- .rows_read
575
- .fetch_add(batcher.rows_total, Ordering::Relaxed);
576
- emit_progress(&counters, &progress, started);
577
- warn_coerced_dates("bigquery read", batcher.invalid_dates_total);
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
- if cfg.mode == SyncMode::Full {
585
- tracing::info!("swapping staging table into '{}'", cfg.dest_table);
586
- sink.atomic_swap(&cfg.dest_table, &staging_name(&cfg.dest_table), &plan.dest_columns)
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 let Some(w) = &new_watermark {
603
- tracing::info!("persisting new watermark: {w}");
604
- sink.persist_watermark(&cfg, w, counters.rows_written.load(Ordering::Relaxed))
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
- let duration_secs = started.elapsed().as_secs_f64();
610
- tracing::info!(
611
- "transfer complete: {} rows in {:.2}s ({:.0} rows/s)",
612
- counters.rows_written.load(Ordering::Relaxed),
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 (dropped first in case a prior run crashed).
981
- let staging = staging_name(&cfg.dest_table);
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.drop_table(&staging).await?;
984
- sink.create_table(&staging, dest_columns, cfg).await?;
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.drop_table(&staging).await?;
1021
- sink.create_table(&staging, dest_columns, cfg).await?;
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
- fn staging_name(dest: &str) -> String {
1120
- format!("{dest}{STAGING_SUFFIX}")
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.2"
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