Skip to content

feat(csharp): collect garbage when an exported driver goes idle - #4917

Merged
CurtHagenlocher merged 5 commits into
apache:mainfrom
CurtHagenlocher:gc-aot
Oct 18, 2026
Merged

CurtHagenlocher merged 5 commits into
apache:mainfrom
CurtHagenlocher:gc-aot

Conversation

@CurtHagenlocher

@CurtHagenlocher CurtHagenlocher commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What's changed

A driver published with NativeAOT gets its own GC heap, and the runtime only collects that heap in response to allocations. Nothing collects it on a timer, and the low-memory notification doesn't collect an idle heap either. So once a caller releases its databases, the memory the driver last used stays committed for the rest of the process's life.

CAdbcDriverExporter now counts the databases it has open. A collection is scheduled when the last database is released, or when the driver is released while no databases are open. It runs on a thread-pool thread after IdleCollectionDelay, and only if no database was opened in the meantime. The collection is Collect + WaitForPendingFinalizers + an aggressive, compacting gen2 collection.

  • IdleCollectionDelay is a new public static TimeSpan?, and null turns the feature off.
  • It defaults to one second when running under NativeAOT (!RuntimeFeature.IsDynamicCodeSupported). It defaults to null under JIT, where the driver shares its heap with the host, and on the netstandard2.0 build.
  • Waiting for finalizers is needed for now because exported record batches are freed by their finalizers rather than when the consumer releases them (CArrowArrayStreamExporter never disposes exported record batches, leaving native memory to the finalizer arrow-dotnet#464).
  • A plain GC.Collect() empties the heap but leaves the memory committed. GCCollectionMode.Aggressive decommits it.

Measurements

A NativeAOT driver built on the exporter was loaded by a C host, which ran a workload and then released everything. The host never asks for a collection. Numbers are process private bytes; the baseline is about 3 MB.

Workload After release 10 s later, with this change
2 × 6 batches × 4M rows 419 MB 5.9 MB
3 × 50 batches × 64K rows 24.5 MB 4.4 MB

Without this change, the 64K-row run was sampled every 10 s for 60 s after release, and its memory never went down.

The forced collection took 2–32 ms in these runs.

NativeAOT workaround

AdbcDriverInit now evaluates Encoding.UTF8 on the caller's thread. Starting the idle timer starts a thread in the driver's runtime, and on Linux that can hit a NativeAOT bug that fails the process fast (dotnet/runtime#136031). The thread runs UTF8Encoding's static constructor while it sets its name, and if it contends on another type's constructor lock at that moment, Lock's statics initialization re-enters the encoding while its static instance is still null. Without the workaround, the AOT fixture tests crashed on every run on Linux.

Tests

IdleCollectorTests covers:

  • collecting after the last database is released;
  • collecting only once when the driver is released after the databases;
  • skipping when a database is reopened during the delay;
  • collecting again after a later idle period;
  • disabling with a null delay, and rejecting a negative delay;
  • the JIT default.

🤖 Generated with Claude Code

A driver published with NativeAOT has its own GC heap, and the runtime
only collects it in response to allocations. Once a caller releases its
databases, the memory the driver last used stays committed for the life
of the process. CAdbcDriverExporter now counts open databases and, once
the last one is released and none is opened for IdleCollectionDelay,
forces a full, compacting collection on a thread-pool thread.

IdleCollectionDelay defaults to one second under NativeAOT and to null
(disabled) under JIT, where the heap is shared with the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Collection scheduling has unresolved lifecycle and concurrency issues, and unsupported delays can cause release operations to report failure.

4 open issues
What changed in this PR

Adds delayed garbage collection to reclaim memory retained by idle NativeAOT C# drivers.

Changes:

  • Adds configurable idle collection, enabled by default under NativeAOT.
  • Tracks database lifetimes and driver releases to schedule collection.
  • Adds tests for scheduling, reopening, disabling, and defaults.
File Description
csharp/​test/​Apache.Arrow.Adbc.Tests/​IdleCollectorTests.cs Tests idle collection behavior and configuration.
csharp/​src/​Apache.Arrow.Adbc/​C/​IdleCollector.cs Tracks activity and schedules delayed collection.
csharp/​src/​Apache.Arrow.Adbc/​C/​CAdbcDriverExporter.cs Integrates lifecycle tracking and garbage collection.

Review effort: Balanced

Comment thread csharp/src/Apache.Arrow.Adbc/C/IdleCollector.cs Outdated
Comment thread csharp/src/Apache.Arrow.Adbc/C/IdleCollector.cs Outdated
Comment thread csharp/src/Apache.Arrow.Adbc/C/IdleCollector.cs Outdated
Comment thread csharp/src/Apache.Arrow.Adbc/C/IdleCollector.cs Outdated
CurtHagenlocher and others added 2 commits October 18, 2026 07:58
- Reject delays longer than Task.Delay supports, so that scheduling
  can't make a completed release report a failure.
- Recheck the delay before collecting, so that disabling the feature
  stops a pending collection, and store it so it's read atomically.
- Start a new idle period when the driver is released, so that its
  collection isn't skipped after the last database's has already run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Releasing an exported driver now starts a timer, so a NativeAOT driver
starts a thread of its own. On Linux, that thread's first UTF-8
marshalling (to set its name) can run UTF8Encoding's static constructor
while contending on another type's constructor lock. Lock's slow path
then reads an environment variable, re-entering UTF8Encoding while its
static instance is still null, and the runtime fails fast. This crashed
the AOT fixture test host on every run in WSL. Running the constructor
in AdbcDriverInit, on the caller's thread, avoids it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

A pending callback can execute after an unloadable driver is freed, with additional test and scheduling issues unresolved.

1 open issue
4 resolved since last review

Review effort: Lite

Comment thread csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
CurtHagenlocher and others added 2 commits October 18, 2026 08:33
…workaround

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A collection still pending when the driver is released runs after the
release returns, so a host that unloads the library then would crash.
NativeAOT libraries can't be safely unloaded anyway; say so on the
importer's canUnload parameter as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CurtHagenlocher
CurtHagenlocher merged commit 248eada into apache:main Oct 18, 2026
9 checks passed
@CurtHagenlocher
CurtHagenlocher deleted the gc-aot branch October 18, 2026 15:45
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.

2 participants