EF Core 10 Relational Data · Lesson 7

Async calls, cancellation and provider limits

Forward a caller token while admitting SQLite’s synchronous I/O boundary.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Separate an async contract from the provider’s actual I/O capability

EF exposes Task-based query and save APIs so callers can compose asynchronous operations and pass cancellation. Whether native I/O actually proceeds asynchronously depends on the database provider. Microsoft.Data.Sqlite documents that its asynchronous ADO.NET methods execute synchronously because SQLite does not support asynchronous I/O in that form. The course still uses the standard EF async shape for consistency and cancellation wiring, while explicitly refusing to call it a SQLite nonblocking-performance demonstration. That distinction matters when a desktop sample is later moved to a responsive interface or compared with a network database provider.

Observe a pre-cancelled EF query deterministically

The external program first creates a real row and completes that save. It then cancels the operation source before passing its token to ToListAsync. The expected OperationCanceledException establishes the selected pre-cancellation path without a racing timer or a slow public database. A fresh context verifies that the earlier stored row remains. That verification does not mean cancellation rolled back the previous save; it deliberately demonstrates that the committed row still exists. The test makes no claim about interrupting a native statement halfway through execution or about the number of commands that every provider might start before observing a token.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using var db = new LabDb(options); await db.Database.EnsureCreatedAsync(); db.Items.Add(new Item { Name = "rune" }); await db.SaveChangesAsync();
using var owner = new CancellationTokenSource(); owner.Cancel(); bool cancelled = false;
try { await db.Items.AsNoTracking().ToListAsync(owner.Token); }
catch (OperationCanceledException) { cancelled = true; }
Check(cancelled, "pre-cancelled EF query");
await using var verify = new LabDb(options); Check(await verify.Items.CountAsync() == 1, "stored row unchanged");
Console.WriteLine("cancelled"); Console.WriteLine("rows:1");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }

Keep the token guard’s evidence narrow

The .NET9 companion creates an already cancelled CancellationTokenSource, then calls ThrowIfCancellationRequested before its supplied completion line. The catch prints cancelled. No EF query, database connection or provider is involved. This is a useful control-flow baseline because it proves what the token guard means independently of transport or storage. It does not establish that a provider supports mid-command cancellation or that an async method necessarily yielded execution. Preserve those narrower claims when a test double completes synchronously: a deterministic guard test and a provider integration test answer related but different questions.

An outstanding operation still owns its context

Await each EF operation before starting another on the same DbContext. Cancellation does not make concurrent reuse safe, and abandoning a Task without observing its completion does not release the context lifetime cleanly. An operation owner should pass its token through query and save calls that belong to that operation, while cleanup and transaction reconciliation need their own policies. Disposing a CancellationTokenSource does not request cancellation. A cancelled wait also is not proof that a remote database mutation never committed, especially with a network provider and an uncertain result. Classify the evidence before deciding to retry or report success.

Practice: compare normal completion with pre-cancellation

Use a fresh context for a normal uncancelled query and verify its materialized result, then keep the existing already-cancelled case as an independent assertion. Do not use a fixed sleep to try to catch SQLite in the middle of a statement and present that race as reliable coverage. If production needs a network provider’s cancellation behavior, design a controlled integration test in that environment with a documented command and deadline. Record whether the evidence concerns caller cancellation, provider interruption or committed state, and preserve the distinction in failure messages.

  • Hint: a pre-cancelled token is a deterministic case, not a mid-I/O timing test.
  • Hint: do not issue another operation on the same context before the first completes.

Practice: model cancellation without losing result categories

Extend the BCL practice to return named Completed, Cancelled and InvalidInput outcomes for separate supplied scenarios. Keep invalid data validation independent from the token’s state so a test can identify which contract was exercised. Then use an explicitly coordinated asynchronous operation to request cancellation between two stages without relying on elapsed time. That model still has no database and cannot determine rollback. Its purpose is to make ownership and error classification clear before attaching a provider adapter. Verify that every started task is observed and that an already produced result is not mislabeled as an abandoned operation.

  • Hint: cancelled and invalid are separate outcomes even when both stop useful work.
  • Hint: completion signals make stage ordering clearer than arbitrary delays.

Review cancellation guarantees at the intended provider boundary

The actual SQLite project and the BCL lab both report their controlled cancelled outcomes, but only the external project crosses EF’s query boundary. Neither proves responsive native SQLite I/O or universal server-side cancellation. A useful production review identifies the caller token, context owner, awaited operations, timeout policy and any reconciliation needed after uncertain writes. Repeat relevant integration checks against the provider that will run in production. Keeping those limits explicit prevents a Task-shaped API from being mistaken for a performance or rollback guarantee that its implementation and storage engine do not promise.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

.NET10 / EF Core10 / real SQLite — Async calls, cancellation and provider limits

Set up the SDK project

  1. Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
  2. Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
  3. Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
  4. Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.

Download complete SDK project ↗

Project files

EfLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>
</Project>
Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using var db = new LabDb(options); await db.Database.EnsureCreatedAsync(); db.Items.Add(new Item { Name = "rune" }); await db.SaveChangesAsync();
using var owner = new CancellationTokenSource(); owner.Cancel(); bool cancelled = false;
try { await db.Items.AsNoTracking().ToListAsync(owner.Token); }
catch (OperationCanceledException) { cancelled = true; }
Check(cancelled, "pre-cancelled EF query");
await using var verify = new LabDb(options); Check(await verify.Items.CountAsync() == 1, "stored row unchanged");
Console.WriteLine("cancelled"); Console.WriteLine("rows:1");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.csDownload file ↗
using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
    public LabDb(DbContextOptions<LabDb> options) : base(options) { }
    public DbSet<Item> Items => Set<Item>();
}
public sealed class Item
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

Expected result

The external program must complete its Check assertions and print: cancelled rows:1 It uses actual SQLite materialization and saving; no external service or production database is contacted.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

using var owner = new CancellationTokenSource(); owner.Cancel();
try { owner.Token.ThrowIfCancellationRequested(); Console.WriteLine("completed"); }
catch (OperationCanceledException) { Console.WriteLine("cancelled"); }
Open C# practice

C# practice output

cancelled
Try this
Async-shaped SQLite APIs do not prove asynchronous native I/O or universal interruption.

Further reading