aspire run
Then run the data migration from the Aspire dashboard.
Swagger UI
Top-level IAsyncEnumerable<T>
- The serializer streams the sequence directly.
- Enumeration completes normally.
DisposeAsync()is invoked immediately when enumeration ends.- Always works.
Object containing IAsyncEnumerable<T> properties
- The serializer enumerates each EF Core sequence and produces JSON output.
- However, even after enumeration completes,
DisposeAsync()is not invoked. - The underlying EF Core database connection therefore remains open.
- When the next enumeration begins, it collides with the still-open connection and fails.
→ Only this endpoint fails when maxRetryCount = 0
When maxRetryCount >= 1, EF Core’s retry logic closes the connection once,
which accidentally allows the next enumeration to succeed.
Object containing async-iterator–wrapped IAsyncEnumerable<T>
- Each sequence is wrapped in an async iterator using explicit
await foreach. await foreachguarantees that the inner EF Core enumerator is fully consumed and itsDisposeAsync()is invoked immediately when enumeration completes.- The wrapper itself is enumerable by the serializer, but the important part is that the underlying database enumerator has already been disposed.
- Always works.
Object containing buffered arrays (ToArrayAsync)
- Fully buffered before returning.
- No streaming and no async enumeration.
- Always works.
Only /product_2 fails when maxRetryCount = 0.
The root cause is:
The serializer does not invoke
DisposeAsync()after finishing enumeration
for property-basedIAsyncEnumerable<T>.
As a result, the EF Core database connection remains open,
and the next enumeration attempt fails.
For EF Core:
IAsyncEnumerable<T>keeps the database connection open during enumeration.- The connection is closed only when
DisposeAsync()is invoked. - In
/product_2,DisposeAsync()is never called, leaving the connection open. - The next
MoveNextAsync()collides with the still-open connection and fails.
When maxRetryCount >= 1, EF Core’s retry logic closes the connection once,
which accidentally avoids the failure.
| maxRetryCount | 0 | 1+ |
|---|---|---|
/product_1 |
ok | ok |
/product_2 |
ng | ok |
/product_3 |
ok | ok |
/product_4 |
ok | ok |
The failure is not caused by concurrency or parallel JSON writing.
The real issue is:
For property-based
IAsyncEnumerable<T>, the serializer delaysDisposeAsync()
until after JSON serialization completes.
EF Core requiresDisposeAsync()to close the database connection,
so delaying it causes the next enumeration to fail.
These diagnostic endpoints show the exact timing of DisposeAsync().
- The serializer streams the sequence.
DisposeAsync()is invoked immediately when enumeration ends.
Observed log:
[MOVE] ...
[DISPOSE]
- The serializer enumerates both sequences.
- However,
DisposeAsync()is not invoked when enumeration ends. - Both enumerators are disposed only after JSON serialization finishes.
Observed log:
Num1: [MOVE] ...
Num2: [MOVE] ...
Num1: [DISPOSE]
Num2: [DISPOSE]
This confirms:
property-based IAsyncEnumerable<T> → delayed DisposeAsync.
- The inner enumerator is consumed via
await foreach. DisposeAsync()is invoked immediately when enumeration ends.- Matches the behavior of
/product_3.
Observed log:
Num1: [MOVE] ...
[DISPOSE] ← immediately after enumeration
Num2: [MOVE] ...
[DISPOSE]
/product_2 fails because:
- enumeration completes but
DisposeAsync()is not invoked - the EF Core database connection remains open
- the next enumeration attempt fails due to the still-open connection
/dispose_1, /dispose_2, and /dispose_3 clearly show that:
- Top-level sequences → immediate DisposeAsync
- Property-based sequences → delayed DisposeAsync
- Async iterator wrapper → immediate DisposeAsync
This fully explains all observed behavior.