Skip to content

Commit c973e22

Browse files
committed
Download pre-built DuckDB libraries
How this works: When `DUCKDB_DOWNLOAD_LIB` is set, we map `CARGO_TARGET_DIR` to `target/duckdb-download/<target>/<version>`, download the matching DuckDB release archive, unzip it, and extend link-search/rpath to that directory. We also copy the downloaded library into `target/<profile>/deps` so test binaries can load it without extra env vars, matching the `DUCKDB_LIB_DIR` flow. Inspired by - https://github.com/vortex-data/vortex/blob/develop/vortex-duckdb/build.rs - https://github.com/nbigaouette/onnxruntime-rs/blob/master/onnxruntime-sys/build.rs
1 parent 6cefb40 commit c973e22

File tree

4 files changed

+255
-6
lines changed

4 files changed

+255
-6
lines changed

Cargo.lock

Lines changed: 29 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,20 @@ You can adjust this behavior in a number of ways:
204204
cargo build --examples
205205
```
206206

207-
3. Installing the duckdb development packages will usually be all that is required, but
207+
3. _Experimental:_ Setting `DUCKDB_DOWNLOAD_LIB=1` makes the build script download pre-built DuckDB binaries from GitHub Releases. This always links against the dynamic library in the archive (setting `DUCKDB_STATIC` has no effect), and it effectively automates the manual steps above. The archives are cached in `target/duckdb-download/<target>/<version>` and that directory is automatically added to the linker search path. The downloaded version always matches the `libduckdb-sys` crate version.
208+
209+
```shell
210+
DUCKDB_DOWNLOAD_LIB=1 cargo test
211+
```
212+
213+
4. Installing the duckdb development packages will usually be all that is required, but
208214
the build helpers for [pkg-config](https://github.com/alexcrichton/pkg-config-rs)
209215
and [vcpkg](https://github.com/mcgoo/vcpkg-rs) have some additional configuration
210216
options. The default when using vcpkg is to dynamically link,
211217
which must be enabled by setting `VCPKGRS_DYNAMIC=1` environment variable before build.
212218

219+
When none of the options above are used, the build script falls back to this discovery path and will emit the appropriate `cargo:rustc-link-lib` directives if DuckDB is found on your system.
220+
213221
### ICU extension and the bundled feature
214222

215223
When using the `bundled` feature, the ICU extension is not included due to crates.io's 10MB package size limit. This means some date/time operations (like `now() - interval '1 day'` or `ts::date` casts) will fail. You can load ICU at runtime:

crates/libduckdb-sys/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@ flate2 = { workspace = true }
3232
pkg-config = { workspace = true, optional = true }
3333
prettyplease = { workspace = true, optional = true }
3434
quote = { workspace = true, optional = true }
35+
reqwest = { version = "0.12", default-features = false, features = [
36+
"blocking",
37+
"rustls-tls",
38+
] }
3539
serde = { workspace = true, features = ["derive"] }
3640
serde_json = { workspace = true }
3741
syn = { workspace = true, optional = true }
3842
tar = { workspace = true }
3943
vcpkg = { workspace = true, optional = true }
44+
zip = { version = "6", default-features = false, features = ["deflate"] }
4045

4146
[dev-dependencies]
4247
arrow = { workspace = true, features = ["ffi"] }

crates/libduckdb-sys/build.rs

Lines changed: 212 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,15 @@ mod build_linked {
235235
use super::bindings;
236236

237237
use super::{env_prefix, is_compiler, lib_name, win_target, HeaderLocation};
238-
use std::{env, path::Path};
238+
use std::{
239+
env, fs, io,
240+
path::{Path, PathBuf},
241+
};
239242

240-
pub fn main(_out_dir: &str, out_path: &Path) {
243+
pub fn main(out_dir: &str, out_path: &Path) {
241244
// We need this to config the LD_LIBRARY_PATH
242245
#[allow(unused_variables)]
243-
let header = find_duckdb();
246+
let header = find_duckdb(out_dir);
244247

245248
#[cfg(not(feature = "buildtime_bindgen"))]
246249
{
@@ -269,9 +272,10 @@ mod build_linked {
269272
}
270273
}
271274
// Prints the necessary cargo link commands and returns the path to the header.
272-
fn find_duckdb() -> HeaderLocation {
275+
fn find_duckdb(out_dir: &str) -> HeaderLocation {
273276
let link_lib = lib_name();
274277

278+
println!("cargo:rerun-if-env-changed={}_DOWNLOAD_LIB", env_prefix());
275279
if !cfg!(feature = "loadable-extension") {
276280
println!("cargo:rerun-if-env-changed={}_INCLUDE_DIR", env_prefix());
277281
println!("cargo:rerun-if-env-changed={}_LIB_DIR", env_prefix());
@@ -314,6 +318,10 @@ mod build_linked {
314318
return HeaderLocation::FromEnvironment;
315319
}
316320

321+
if should_download_libduckdb() {
322+
return download_libduckdb(out_dir).unwrap_or_else(|err| panic!("Failed to download libduckdb: {err}"));
323+
}
324+
317325
if let Some(header) = try_vcpkg() {
318326
return header;
319327
}
@@ -364,6 +372,206 @@ mod build_linked {
364372
}
365373
None
366374
}
375+
376+
fn should_download_libduckdb() -> bool {
377+
env::var(format!("{}_DOWNLOAD_LIB", env_prefix()))
378+
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true"))
379+
.unwrap_or(false)
380+
}
381+
382+
fn download_libduckdb(out_dir: &str) -> Result<HeaderLocation, Box<dyn std::error::Error>> {
383+
let target = env::var("TARGET")?;
384+
let archive = LibduckdbArchive::for_target(&target)
385+
.ok_or_else(|| format!("No pre-built libduckdb available for target '{target}'"))?;
386+
387+
let version = env!("CARGO_PKG_VERSION").to_string();
388+
389+
// Cache downloads in target/duckdb-download/<target>/<version> so successive builds reuse them
390+
let download_dir = workspace_download_dir(out_dir)?.join(&target).join(&version);
391+
fs::create_dir_all(&download_dir)?;
392+
393+
let archive_path = download_dir.join(archive.archive_name);
394+
let lib_marker = download_dir.join(archive.dynamic_lib);
395+
396+
if lib_marker.exists() {
397+
println!("cargo:warning=Reusing libduckdb from {}", download_dir.display());
398+
} else {
399+
let client = http_client()?;
400+
let url = archive.download_url(&version);
401+
ensure_libduckdb(&client, &url, &archive_path)?;
402+
extract_libduckdb(&archive_path, &download_dir)?;
403+
if !lib_marker.exists() {
404+
return Err(format!(
405+
"Downloaded archive did not contain expected library '{}'",
406+
archive.dynamic_lib
407+
)
408+
.into());
409+
}
410+
}
411+
412+
configure_link_search(&download_dir);
413+
414+
copy_libduckdb(&download_dir, archive.dynamic_lib, out_dir)?;
415+
416+
Ok(HeaderLocation::FromPath(download_dir.to_string_lossy().into_owned()))
417+
}
418+
419+
fn configure_link_search(lib_dir: &Path) {
420+
println!("cargo:rustc-link-search=native={}", lib_dir.display());
421+
if !cfg!(feature = "loadable-extension") {
422+
println!("cargo:rustc-link-lib={}={}", find_link_mode(), lib_name());
423+
}
424+
if !win_target() {
425+
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
426+
}
427+
}
428+
429+
// Ensures the libduckdb archive exists: reuses an existing zip or
430+
// downloads it into a temp file and atomically renames it into place.
431+
fn ensure_libduckdb(
432+
client: &reqwest::blocking::Client,
433+
url: &str,
434+
archive_path: &Path,
435+
) -> Result<(), Box<dyn std::error::Error>> {
436+
if archive_path.exists() {
437+
println!("cargo:warning=libduckdb already present at {}", archive_path.display());
438+
return Ok(());
439+
}
440+
let tmp_path = archive_path.with_extension("download");
441+
if let Some(parent) = archive_path.parent() {
442+
fs::create_dir_all(parent)?;
443+
}
444+
let mut response = client.get(url).send()?.error_for_status()?;
445+
let mut tmp_file = fs::File::create(&tmp_path)?;
446+
io::copy(&mut response, &mut tmp_file)?;
447+
fs::rename(&tmp_path, archive_path)?;
448+
println!("cargo:warning=Downloaded libduckdb from {url}");
449+
Ok(())
450+
}
451+
452+
fn extract_libduckdb(archive_path: &Path, destination: &Path) -> Result<(), Box<dyn std::error::Error>> {
453+
let file = fs::File::open(archive_path)?;
454+
let mut archive = zip::ZipArchive::new(file)?;
455+
archive.extract(destination)?;
456+
println!("cargo:warning=Extracted libduckdb to {}", destination.display());
457+
Ok(())
458+
}
459+
460+
// Copy libduckdb into target/<profile>/deps so executables/tests can load it via
461+
// the default Cargo rpath, just like when DUCKDB_LIB_DIR is set.
462+
fn copy_libduckdb(
463+
download_dir: &Path,
464+
lib_filename: &str,
465+
out_dir: &str,
466+
) -> Result<(), Box<dyn std::error::Error>> {
467+
let Some(deps_dir) = profile_deps_dir(out_dir) else {
468+
println!("cargo:warning=Could not determine target/deps directory, skipping runtime copy");
469+
return Ok(());
470+
};
471+
fs::create_dir_all(&deps_dir)?;
472+
let source = download_dir.join(lib_filename);
473+
let dest = deps_dir.join(lib_filename);
474+
if dest.exists() {
475+
fs::remove_file(&dest)?;
476+
}
477+
fs::copy(&source, &dest)?;
478+
println!("cargo:warning=Copied libduckdb to {}", dest.display());
479+
Ok(())
480+
}
481+
482+
// Finds target/ so downloads survive rebuilds. Respects CARGO_TARGET_DIR if set.
483+
fn workspace_download_dir(out_dir: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
484+
if let Ok(dir) = env::var("CARGO_TARGET_DIR") {
485+
return Ok(PathBuf::from(dir).join("duckdb-download"));
486+
}
487+
let target_root = Path::new(out_dir)
488+
.ancestors()
489+
.find(|ancestor| {
490+
ancestor
491+
.file_name()
492+
.and_then(|name| name.to_str())
493+
.is_some_and(|name| name == "target")
494+
})
495+
.map(Path::to_path_buf)
496+
.unwrap_or_else(|| PathBuf::from("target"));
497+
Ok(target_root.join("duckdb-download"))
498+
}
499+
500+
// Mirrors Cargo's target/<profile>/deps layout (optionally with CARGO_TARGET_DIR / target triple).
501+
fn profile_deps_dir(out_dir: &str) -> Option<PathBuf> {
502+
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
503+
let mut target_root = env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| {
504+
Path::new(out_dir)
505+
.ancestors()
506+
.find(|ancestor| {
507+
ancestor
508+
.file_name()
509+
.and_then(|name| name.to_str())
510+
.is_some_and(|name| name == "target")
511+
})
512+
.map(Path::to_path_buf)
513+
.unwrap_or_else(|| PathBuf::from("target"))
514+
});
515+
if env::var("HOST").ok() != env::var("TARGET").ok() {
516+
if let Ok(target) = env::var("TARGET") {
517+
target_root.push(target);
518+
}
519+
}
520+
target_root.push(profile);
521+
target_root.push("deps");
522+
Some(target_root)
523+
}
524+
525+
struct LibduckdbArchive {
526+
archive_name: &'static str,
527+
dynamic_lib: &'static str,
528+
}
529+
530+
impl LibduckdbArchive {
531+
fn for_target(target: &str) -> Option<Self> {
532+
match target {
533+
t if t.ends_with("apple-darwin") => Some(Self {
534+
archive_name: "libduckdb-osx-universal.zip",
535+
dynamic_lib: "libduckdb.dylib",
536+
}),
537+
"x86_64-unknown-linux-gnu" => Some(Self {
538+
archive_name: "libduckdb-linux-amd64.zip",
539+
dynamic_lib: "libduckdb.so",
540+
}),
541+
"aarch64-unknown-linux-gnu" => Some(Self {
542+
archive_name: "libduckdb-linux-arm64.zip",
543+
dynamic_lib: "libduckdb.so",
544+
}),
545+
"x86_64-pc-windows-msvc" => Some(Self {
546+
archive_name: "libduckdb-windows-amd64.zip",
547+
dynamic_lib: "duckdb.dll",
548+
}),
549+
"aarch64-pc-windows-msvc" => Some(Self {
550+
archive_name: "libduckdb-windows-arm64.zip",
551+
dynamic_lib: "duckdb.dll",
552+
}),
553+
_ => None,
554+
}
555+
}
556+
557+
fn download_url(&self, version: &str) -> String {
558+
format!(
559+
"https://github.com/duckdb/duckdb/releases/download/v{version}/{}",
560+
self.archive_name
561+
)
562+
}
563+
}
564+
565+
fn http_client() -> Result<reqwest::blocking::Client, reqwest::Error> {
566+
let timeout = env::var("CARGO_HTTP_TIMEOUT")
567+
.or_else(|_| env::var("HTTP_TIMEOUT"))
568+
.ok()
569+
.and_then(|value| value.parse().ok())
570+
.unwrap_or(90);
571+
reqwest::blocking::Client::builder()
572+
.timeout(std::time::Duration::from_secs(timeout))
573+
.build()
574+
}
367575
}
368576

369577
#[cfg(feature = "buildtime_bindgen")]

0 commit comments

Comments
 (0)