Skip to content

Fix and simplify the native library build - #351

Open
RagingKore wants to merge 3 commits into
Giorgi:developfrom
RagingKore:native-library-build
Open

RagingKore wants to merge 3 commits into
Giorgi:developfrom
RagingKore:native-library-build

Conversation

@RagingKore

Copy link
Copy Markdown

TL;DR

git clone then dotnet test now works. Before, it took two builds, and the second one only worked by accident.

Using DuckDB from a project in this repository used to mean copying a large MSBuild target and glob block into it, plus a loader class to find the library at runtime. Three projects each carried their own copy, and one of them compiled the test project's source file to get it. The DuckDB version was hard coded in three separate places.

Now a project adds a ProjectReference to DuckDB.NET.Bindings and nothing else. The version lives in one property in Directory.Build.props, and the library finds itself through a DllImportResolver in the assembly that declares the imports, which is the mechanism the platform provides for this.

What this fixes

  • The download cache never noticed a version change. The guard was whether a directory existed. Raising the version left every machine that had already built on the old engine. A checkout targeting v1.5.5 ran v1.2.0 for six months here and failed 127 tests with EntryPointNotFoundException. CI clones fresh, so CI never saw it.
  • A failed download disabled the cache permanently. MakeDir ran before DownloadFile. One failure left an empty directory that satisfied the guard, and every later build skipped in silence.
  • A clean clone needed two builds. The libraries were staged under obj, which the SDK lists in DefaultItemExcludes. Every glob over them matched nothing and said nothing about it.
  • dotnet pack shipped a broken package. On a clean clone, DuckDB.NET.Bindings.Full packed with zero native libraries after downloading all five. CI escaped it only because it builds before it packs.
  • DuckDB.NET.Samples never worked on its own. It had no glob at all and depended on another project having been built first.
  • Three projects each carried a loader. A module initializer in the tests, a second copy of the same code in the benchmarks, and a Compile Include in the samples that reached across into the test project.
  • The version was hard coded three times. The artifact URL and both packages' release notes. Nothing checked that they agreed.

What a consumer needed before

<Target Name="CallExecutable" BeforeTargets="GenerateAdditionalSources">
  <MSBuild Projects="..\DuckDB.NET.Bindings\Bindings.csproj" Targets="DownloadNativeLibs" Properties="BuildType=Full;" />
</Target>
<ItemGroup>
  <None Include="..\DuckDB.NET.Bindings\obj\runtimes\**\*.dll;..\DuckDB.NET.Bindings\obj\runtimes\**\*.so;..\DuckDB.NET.Bindings\obj\runtimes\**\*.dylib;">
    <Visible>false</Visible>
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <PackagePath>\runtimes</PackagePath>
    <Link>runtimes\%(RecursiveDir)\%(FileName)%(Extension)</Link>
  </None>
</ItemGroup>

BeforeTargets="GenerateAdditionalSources" names a target that does not exist during restore or in the outer build. MSBuild reported does not exist in the project, and will be ignored and dropped the hook.

How it works now

The download runs during restore, the only phase that precedes evaluation. Item globs expand during evaluation, so anything downloaded later is invisible to them.

The hook is _GenerateRestoreGraphProjectEntry, not the public Restore target. Restore runs only on the project the command names, so a solution build skips every project inside it. The NuGet target runs once per project either way. It carries no compatibility guarantee: if a later SDK renames it, the symptom is a missing library on a clean clone.

Staging moves to artifacts/natives/<version>/<rid>/native, outside obj so globs see it. The version in the path makes the directory the cache key, so a bump lands in a fresh folder and cannot inherit the previous engine. Incrementality follows the SDK cache file convention, where a value rather than a source file drives the decision, as CreateGeneratedAssemblyInfoInputsCacheFile does in Microsoft.NET.GenerateAssemblyInfo.targets. The cache file is written last, so a failure leaves it stale and the next build retries. The fetch also completes before the installed payload is cleared, so a download that fails over a dead network leaves the working engine in place.

Bindings.csproj declares the libraries as Content in the project body. Content flows across a ProjectReference, so consumers inherit the engine from the reference they already had.

DuckDBNativeLibrary registers a DllImportResolver for the bindings assembly. A NuGet package writes a runtimeTargets entry into deps.json and the host probes runtimes/{rid}/native on its own; a project reference produces no such entry, which is why each project had its own loader. The resolver returns zero for a name it does not own and for a file it cannot find, so default probing still applies and a library already on the system still loads. Package consumers see no change: with and without the resolver, a PackageReference app resolves through deps.json as before.

Verification

Check Result
Clean clone, single dotnet test 7021 passed, 0 failed
dotnet pack with BuildType=Full 5 native libraries in the package
dotnet pack without BuildType 0 native libraries, as intended
All four samples exit 0, including the raw NativeMethods P/Invoke sample and a 100000 row appender load
dotnet restore, then dotnet build --no-restore 0 errors
Three consecutive cold solution builds 0 errors, one download each
Raise DuckDbVersion to 1.4.1, then build new engine on disk, previous version untouched
New warnings none

No CI workflow changes. Every command in ci.yml, Sonar.yml and codeql-analysis.yml was run against this branch.

A clean clone needed two builds. The first downloaded the libraries and copied
none of them; a second build then picked them up. Three separate defects
produced that.

The download guard was the existence of obj/runtimes/<rid>/native. It recorded
no version, so raising the DuckDB release left every machine that had built once
on the old engine, and a checkout targeting v1.5.5 kept running v1.2.0 and
failed 127 tests with EntryPointNotFoundException. MakeDir also ran before
DownloadFile, so a download that failed left an empty directory that satisfied
the guard and every later build skipped in silence.

The libraries were staged under obj, which the SDK lists in DefaultItemExcludes.
Every item glob over them matched nothing and reported nothing, so the files
reached no output directory and a cold dotnet pack shipped
DuckDB.NET.Bindings.Full with no native library in it.

The download now runs during restore, which is the only phase that precedes
evaluation, where item globs expand. The hook is a NuGet target rather than the
public Restore target, because Restore only runs on the project the command
names and a solution build would skip every project inside it. It carries no
compatibility guarantee, so if a later SDK renames it the symptom is a missing
library on a clean clone.

Staging moves to artifacts/natives/<version>/<rid>/native. That is outside obj,
so globs see it, and the version in the path makes the directory the cache key:
a bump lands in a fresh folder and cannot inherit the previous engine.
Incrementality follows the SDK cache file convention, where a value rather than
a source file drives the decision, as CreateGeneratedAssemblyInfoInputsCacheFile
does in Microsoft.NET.GenerateAssemblyInfo.targets. The download cache file is
written last, so a failure leaves it older than the inputs and the next build
retries. The fetch also completes before the installed payload is cleared, so a
download that fails over a dead network leaves the working engine in place.

Bindings declares the libraries as Content in the project body. Content flows
across a ProjectReference, so the test, benchmark and sample projects each get
the engine from the reference they already had and their own globs are gone.
Copying is unconditional because every project that runs needs the engine beside
its assembly; only packing stays gated on BuildType.

DuckDbVersion moves to Directory.Build.props and drives the artifact URL, the
staging path and both packages' release notes, which each carried the version as
hand typed prose. The release and nightly download targets differed in one step
and are now one target. DuckDbArtifactRoot still wins when set, which the
nightly CI job does through the environment.
A NuGet package writes a runtimeTargets entry into deps.json and the host then
probes runtimes/{rid}/native on its own. A project reference produces no such
entry, so every project in this repository loaded the library itself: the test
project through a module initializer, the benchmarks through a second copy of
the same code, and the samples by compiling the test project's file through a
Compile Include that reached across projects.

DuckDBNativeLibrary registers a DllImportResolver for the Bindings assembly,
which is where DuckDbLibrary and its imports live. One copy of the runtime
identifier logic now serves every consumer, in this repository and downstream.

The resolver returns zero for a name it does not own and for a file it cannot
find, so default probing still applies and a library already present on the
system still loads. It passes a relative path with
DllImportSearchPath.AssemblyDirectory, which keeps it working under single file
publish where Assembly.Location is empty, and the four argument overload applies
the platform naming convention so one name matches libduckdb.dylib,
libduckdb.so and duckdb.dll.
Raising the version used to touch three files and nothing checked that they
agreed. Commit fd45dbb updated the artifact URL and left both packages' release
notes on the previous version. It is one property now, so say where it is.
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 38.88889% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.32%. Comparing base (e56cdb8) to head (75dedb3).

Files with missing lines Patch % Lines
DuckDB.NET.Bindings/DuckDBNativeLibrary.cs 38.88% 6 Missing and 5 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #351      +/-   ##
===========================================
- Coverage    86.56%   86.32%   -0.25%     
===========================================
  Files           78       79       +1     
  Lines         3492     3510      +18     
  Branches       548      557       +9     
===========================================
+ Hits          3023     3030       +7     
- Misses         331      337       +6     
- Partials       138      143       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 33379933153

Coverage decreased (-0.1%) to 88.181%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 4 uncovered changes across 1 file (14 of 18 lines covered, 77.78%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
DuckDB.NET.Bindings/DuckDBNativeLibrary.cs 18 14 77.78%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3510
Covered Lines: 3175
Line Coverage: 90.46%
Relevant Branches: 1905
Covered Branches: 1600
Branch Coverage: 83.99%
Branches in Coverage %: Yes
Coverage Strength: 373797.7 hits per line

💛 - Coveralls

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Windows ARM64 resolution is incorrect, and mutable nightly artifacts can remain cached indefinitely.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Centralizes native DuckDB download, caching, propagation, and runtime resolution in the bindings project.

Changes:

  • Adds version-aware native staging and packaging.
  • Introduces an assembly-level native resolver.
  • Removes duplicated consumer loaders and build targets.
File summaries
File Description
Directory.Build.props Centralizes version and staging properties.
README.md Documents version updates.
DuckDB.NET.Bindings/Bindings.csproj Integrates restore-time downloads and native content.
DuckDB.NET.Bindings/DownloadNativeLibs.targets Implements download caching and extraction.
DuckDB.NET.Bindings/DuckDBNativeLibrary.cs Adds centralized runtime resolution.
DuckDB.NET.Bindings/NativeMethods/NativeMethods.All.cs Exposes the native library name internally.
DuckDB.NET.Data/Data.csproj Uses the centralized version in release notes.
DuckDB.NET.Test/Test.csproj Removes duplicated native staging.
DuckDB.NET.Test/ModuleInit.cs Removes test-local loading.
DuckDB.NET.Test/Helpers/NativeLibraryHelper.cs Deletes the obsolete test loader.
DuckDB.NET.Samples/Samples.csproj Removes linked loader source.
DuckDB.NET.Samples/Program.cs Relies on centralized resolution.
DuckDB.NET.Benchmarks/Benchmarks.csproj Removes duplicated native staging.
DuckDB.NET.Benchmarks/NativeLibraryLoader.cs Deletes the obsolete benchmark loader.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +57 to +60
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return Environment.Is64BitProcess ? "win-x64" : "win-x86";
}
Comment on lines +29 to +32
<WriteLinesToFile Lines="$(LibUrl)"
File="$(NativeInputsCacheFile)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
@Giorgi

Giorgi commented Oct 6, 2026

Copy link
Copy Markdown
Owner

@RagingKore Can you resolve the conflicts?

@Giorgi

Giorgi commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Thanks for this. I replayed the PR onto current develop in a local branch and tested it in fresh clones. Here is what I found.

What works

  • A fresh clone, one dotnet test: the tests run and pass, so the engine for all five platforms was downloaded and loaded on the way (35 s here).
  • dotnet pack -p:BuildType=Full straight after a clone: DuckDB.NET.Bindings.Full has all five platforms. On develop today the same pack has none. The plain packages have none, as intended, and nothing leaks in as content files.
  • The version-keyed folder, the stamp written last and the old engine kept until the new one is in hand: a failed download is retried, a version change downloads into its own folder.
  • The samples and the benchmark project's Local job run without their own loaders.

What needs changing

1. Visual Studio never runs the download

Visual Studio restores packages through its own NuGet service and does not run MSBuild's restore targets, so the hook on _GenerateRestoreGraphProjectEntry never runs there. I built a fresh clone of my local rebase of the PR with devenv.com /Rebuild Release: all five projects build, nothing is downloaded, and the tests fail with DllNotFoundException (50 of 50 in TemporalConversionTests, the class I used as a smoke test). Today's develop should at least work on the second build in Visual Studio; with the PR it does not work until someone runs dotnet restore or dotnet build once from a terminal. I expect the same in Rider, which restores through its own NuGet integration, but I have not tested it.

The download needs to run in the build. A static glob in the project body cannot see files downloaded during the build, as the description says, but items added inside a target can: the wildcard expands when the target runs. Something like:

<Target Name="IncludeDuckDbNativeLibs" BeforeTargets="AssignTargetPaths;_GetPackageFiles">
  <MSBuild Projects="$(MSBuildProjectFullPath)" Targets="DownloadNativeLibs" RemoveProperties="TargetFramework" />
  <ItemGroup>
    <_DuckDbNativeFile Include="$(DuckDbNativeRoot)**\*.dll;$(DuckDbNativeRoot)**\*.so;$(DuckDbNativeRoot)**\*.dylib" />
    <Content Include="@(_DuckDbNativeFile)">
      <Visible>false</Visible>
      <CopyToOutputDirectory>IfDifferent</CopyToOutputDirectory>
      <Pack>$(_PackDuckDbNatives)</Pack>
      <PackagePath>\runtimes</PackagePath>
      <Link>runtimes\%(_DuckDbNativeFile.RecursiveDir)%(_DuckDbNativeFile.Filename)%(_DuckDbNativeFile.Extension)</Link>
    </Content>
  </ItemGroup>
</Target>

AssignTargetPaths comes before both the copy to this project's output and GetCopyToOutputDirectoryItems, through which every referencing project gets the files; _GetPackageFiles is where pack collects them. It still hooks one internal NuGet target, _GetPackageFiles, for pack; adding the target to $(BeforePack) would avoid that, but I have not tried it. The link has to come from a helper item: in a target, %(RecursiveDir) on the item being created does not refer to that item. PackagePath stays \runtimes, because pack appends the item's RecursiveDir itself; adding it there as well packs runtimes/win-x64/native/win-x64/native/duckdb.dll.

In a parallel solution build MSBuild can build this project as more than one instance (the global properties differ), so two downloads of the same platform can run at once. They collided on native.zip in my test; the download needs file and folder names of its own per run (a GUID works) and a copy into place with retries.

I tried this in my local branch, each check in a fresh clone:

  • devenv.com /Rebuild Release: the engine is downloaded during the build and copied to every output, and the tests then pass without another build (50 of 50 in TemporalConversionTests, where the PR as it is fails all 50).
  • dotnet test straight after the clone: passes.
  • dotnet pack -p:BuildType=Full: DuckDB.NET.Bindings.Full has all five platforms under runtimes/<rid>/native/, and DuckDB.NET.Data.Full depends on it; the plain package has none.
  • A parallel build of the whole solution (-m, node reuse off): no collisions, every project gets the engine.

I did not test Visual Studio's design-time builds. If they reach AssignTargetPaths, opening the solution would start the download (and fail offline); a Condition="'$(DesignTimeBuild)' != 'true'" on the target would prevent that.

2. The resolver takes the application's slot

NativeLibrary.SetDllImportResolver allows one resolver per assembly. With the module initializer, an application that sets its own resolver for DuckDB.NET.Bindings, to load DuckDB from a place of its own, now gets InvalidOperationException: A resolver is already set for the assembly. I reproduced this with a small console app. It runs as soon as the assembly loads, so it affects package users too, who do not need the resolver at all: deps.json already points the host at runtimes/<rid>/native.

Two ways out, in the order I'd prefer:

  • Keep the loader out of the shipped package: a new Directory.Build.targets that compiles one shared source file into Test, Samples and Benchmarks. That still removes the three copies.
  • Or, if it stays in the library, the ResolvingUnmanagedDll event of the assembly's load context instead of SetDllImportResolver: it takes any number of handlers and runs only after the default probing found nothing, so package users never reach it. I tried this: the application's own resolver is asked first, the fallback then loads DuckDB, and the tests pass.

3. Switching back to an older engine leaves the newer one in the output

The copy uses PreserveNewest, and the files unpacked from DuckDB's zips keep their release dates. After a build with 1.5.6, a build with -p:DuckDbVersion=1.5.5 downloads 1.5.5 into its own folder but leaves 1.5.6 in bin. CopyToOutputDirectory=IfDifferent (supported by every SDK that global.json allows) fixes it: 1.5.5 → 1.5.6 → 1.5.5 → 1.5.6 put the right engine in place each time.

4. Rebase on develop

Since this PR was opened, develop moved to DuckDB 1.5.6, rewrote the benchmark project (it has its own loader and copy items again), changed the release notes to "Built on DuckDB vX" when DuckDB does not change, and added ReleaseVersion to Directory.Build.props. Most importantly, the nightly download changed: DuckDB stopped publishing duckdb-binaries-*.zip, so develop downloads duckdb-shared-libs-<platform>.tar.gz and unpacks it with tar (a981b79). A rebase that keeps the PR's DownloadNativeLibs.targets would break the nightly build: it still downloads duckdb-binaries-*.zip. In my local branch I folded the tarball into your cache design; the nightly path then works (v2.0.0-alpha48444 loaded, and back to 1.5.6 afterwards).

Two more things the rebase has to handle:

  • ci.yml now runs the connection stress tests only when certain files change, and one of them is Bindings.csproj, for a DuckDB version change. With the version in Directory.Build.props, that file needs adding to the list.
  • The generated - Updated to DuckDB v$(DuckDbVersion) release note cannot say "Built on DuckDB vX" for a release on the same DuckDB version, such as 1.5.6.1. Please keep the release notes written by hand.

Smaller points

  • Nightlies are staged in the folder of the release they are named after (artifacts/natives/1.5.6/), so switching between nightly and release downloads again each time, and since a nightly URL never changes, a local nightly is never refreshed. A folder of their own, refreshed after a day for example, would fix both.
  • Every fresh clone or CI job now downloads about 140 MB (480 MB unpacked), also for a ManagedOnly build of Bindings or Data alone, and every project that references Bindings gets all five platforms copied to its output (about 630 MB for Data's two frameworks). Copying only the running platform to outputs, and still packing all five for .Full, would save most of that.
  • The new README section is for contributors, but the README ships to nuget.org as the package readme. It would fit better in a contributing file. Its example still says 1.5.5, and "switching back downloads nothing" reads as if switching back just works, which point 3 shows it doesn't yet.

@Giorgi

Giorgi commented Oct 16, 2026

Copy link
Copy Markdown
Owner

A correction to point 1, after looking into the parallel download. The GUID names are not needed. Two instances of Bindings ran in the parallel build because Samples.csproj references Bindings with properties="BuildType=Full". That makes BuildType a global property of a second Bindings instance, and the MSBuild task passes on every global property except the ones removed, so RemoveProperties="TargetFramework" was not enough. With the GUID, the build no longer fails, but it still downloads some platforms twice: in my test, 3 of the 5.

What I'd suggest instead:

  • Call DownloadNativeLibs.targets directly from the target, with RemoveProperties="TargetFramework;RuntimeIdentifier;SelfContained;Configuration;Platform;BuildType". Every caller then asks for the same project instance, and MSBuild runs each download once per build. The download can keep its fixed file names.
  • Drop properties="BuildType=Full" from Samples' reference. It is no longer needed and builds Bindings twice into the same folders.
  • Add Condition="'$(DesignTimeBuild)' != 'true'" to the download, but not to the items. Visual Studio's design-time builds reach AssignTargetPaths (through CollectCopyToOutputDirectoryItemDesignTime); in my test they downloaded everything without the condition.
  • Use <BeforePack>$(BeforePack);IncludeDuckDbNativeLibs</BeforePack> instead of BeforeTargets="_GetPackageFiles", so nothing hooks an internal NuGet target.
  • Give the full PackagePath (runtimes\%(_DuckDbNativeFile.RecursiveDir)%(_DuckDbNativeFile.Filename)%(_DuckDbNativeFile.Extension)) rather than relying on pack adding RecursiveDir.

With these changes, in fresh clones: dotnet test, a parallel solution build (one download per platform), .Full and plain packs, and devenv /Rebuild followed by the tests all work.

This branch had an error being deployed

1 failed deployment
external — 75dedb3d Deployed Aug 31, 2026 by RagingKore via Authorize #584
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants