Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ead33ec
Add Testcontainers.CosmosDb integration for automatic emulator startup
Copilot Mar 25, 2026
09480a5
Address code review feedback: add clarifying comments to catch blocks
Copilot Mar 25, 2026
6df36b8
Address review feedback: async init, SkipConnectionCheck throw, resto…
Copilot Mar 25, 2026
5163459
Distill SKILL.md, fix container assignment timing, add disposeHandler…
Copilot Mar 25, 2026
0fa9387
Make DefaultConnection throw if read before InitializeAsync, apply SK…
Copilot Mar 25, 2026
844bf18
Block DefaultConnection/HttpMessageHandler until InitializeAsync comp…
Copilot Mar 25, 2026
b42e171
Remove EnsureInitialized from property getters; call InitializeAsync …
Copilot Mar 26, 2026
eff2931
Fix ManyServiceProvidersCreatedWarning by sharing a single static Htt…
Copilot Mar 26, 2026
a322cbe
Fix HttpClient.Timeout crash: share handler and factory delegate, cre…
Copilot Mar 26, 2026
3d67e46
helix.proj: remove SkipConnectionCheck from Ubuntu XL, switch Windows…
Copilot Mar 26, 2026
d2b56b1
Simplify InitializeAsync: just configured endpoint or testcontainer, …
Copilot Mar 27, 2026
b0ae4a8
Add helpful error message when Cosmos testcontainer startup fails
Copilot Mar 28, 2026
2aa85dd
Restore localhost probing before testcontainer fallback
Copilot Mar 28, 2026
8ea3913
Use xunit assembly fixture for Cosmos emulator container lifecycle
Copilot Apr 1, 2026
3a44583
Store initialization exception in CosmosEmulatorFixture for diagnostics
Copilot Apr 1, 2026
91b538a
Revert xunit assembly fixture changes from 8ea3913 and 3a44583
Copilot Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions .agents/skills/cosmos-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,34 @@ Non-relational provider with its own parallel query pipeline. Uses JSON for docu
- `ETag` for optimistic concurrency
- No cross-container joins

## Azure Cosmos DB Emulator in Docker
## Azure Cosmos DB Emulator for Tests

Cosmos tests on Helix start the emulator from the work item via `PreCommands` that run a Docker container using:
- `eng/testing/run-cosmos-container.ps1`
- `eng/testing/run-cosmos-container.sh`
### Automatic Testcontainer Startup

These scripts can be invoked locally for testing on machines that don't have the emulator installed, but have docker available.
Cosmos functional tests automatically manage the emulator lifecycle via [Testcontainers](https://testcontainers.com/modules/cosmodb/?language=dotnet) (`Testcontainers.CosmosDb` NuGet package). The async initialization logic in `TestEnvironment.InitializeAsync()` (called from `CosmosTestStore.IsConnectionAvailableAsync()`) follows this order:

The `Test__Cosmos__SkipConnectionCheck=true` env var is set to prevent tests from being skipped when the emulator failed to start.
1. **Configured endpoint**: If `Test__Cosmos__DefaultConnection` env var (or `cosmosConfig.json` / `cosmosConfig.test.json`) is set, it is used directly — no container is started.
2. **Local emulator probe**: A quick HTTPS probe is sent to `https://localhost:8081`. If a running emulator responds, it is used.
3. **Testcontainer fallback**: If neither of the above succeeds, a `CosmosDbContainer` is started with the Linux emulator image (`mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview`). The container is disposed on process exit.
4. **Graceful skip**: If Docker is unavailable and no emulator is reachable, the default endpoint is used and `IsConnectionAvailableAsync()` returns `false`, causing tests to be skipped. However, if `Test__Cosmos__SkipConnectionCheck=true` is set (e.g. in CI), the testcontainer failure is **not** caught so that infrastructure problems surface immediately.

### Linux Emulator Detection

`TestEnvironment.IsLinuxEmulator` is `true` when:
- A testcontainer is running (always the Linux image), **or**
- The OS is not Windows (assumes the local emulator is the Linux Docker image), **or**
- `Test__Cosmos__EmulatorType` is explicitly set to `linux`.

Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
The Linux (vnext) emulator does **not** support transactional batches, so `LinuxEmulatorSaveChangesInterceptor` forces `AutoTransactionBehavior.Never` on every `SaveChanges` call. Tests that require features absent from the Linux emulator are guarded with `[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)]`.

### HttpClient Handling

When a testcontainer is active, `CosmosDbContextOptionsBuilderExtensions.ApplyConfiguration` uses the container's `HttpMessageHandler` (a URI rewriter that routes requests to the mapped container port over HTTP), captured once during initialization. When connecting to a local HTTPS emulator, it uses `DangerousAcceptAnyServerCertificateValidator` instead.

### Key Files

- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/TestEnvironment.cs` — async connection auto-detection and testcontainer lifecycle
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs` — test store creation, seeding, cleanup
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosDbContextOptionsBuilderExtensions.cs` — shared Cosmos options (execution strategy, timeout, HttpClient, Gateway mode)
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/LinuxEmulatorSaveChangesInterceptor.cs` — disables transactional batches for the Linux emulator
- `test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosConditionAttribute.cs` — conditional test execution based on emulator type
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
2 changes: 2 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
- name: Export environment variables for the agent's session
run: |
echo "Test__SqlServer__DefaultConnection=Server=localhost;Database=test;User=SA;Password=PLACEHOLDERPass$$w0rd;Connect Timeout=60;ConnectRetryCount=0;Trust Server Certificate=true" >> "$GITHUB_ENV"
echo "Test__Cosmos__DefaultConnection=https://localhost:8081" >> "$GITHUB_ENV"
Comment thread
AndriySvyryd marked this conversation as resolved.
echo "Test__Cosmos__EmulatorType=linux" >> "$GITHUB_ENV"
Comment thread
AndriySvyryd marked this conversation as resolved.
echo "Test__Cosmos__SkipConnectionCheck=true" >> "$GITHUB_ENV"
echo "DOTNET_ROOT=$PWD/.dotnet/" >> "$GITHUB_ENV"
echo "$PWD/.dotnet/" >> $GITHUB_PATH
7 changes: 3 additions & 4 deletions eng/helix.proj
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<PreCommands>$(PreCommands); SqlLocalDB start</PreCommands>
</XUnitProject>
</ItemGroup>

<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.11`))'">
Comment thread
AndriySvyryd marked this conversation as resolved.
<XUnitProject Update="$(CosmosTests)">
<PreCommands>$(PreCommands); set Test__Cosmos__SkipConnectionCheck=true</PreCommands>
Expand All @@ -80,13 +80,12 @@
</XUnitProject>
</ItemGroup>

<!-- Start Cosmos emulator in Docker on Ubuntu and only run Cosmos tests -->
<!-- Run Cosmos tests on Ubuntu with Docker support (testcontainer auto-starts the emulator) -->
<ItemGroup Condition = "'$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL.Open' OR '$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL'">
<XUnitProject Remove="$(RepoRoot)/test/**/*.csproj"/>
<XUnitProject Remove="$(RepoRoot)/test/**/*.fsproj"/>
<XUnitProject Include="$(CosmosTests)">
<PreCommands>$(PreCommands); chmod +x $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; export Test__Cosmos__DefaultConnection=https://localhost:8081; export Test__Cosmos__SkipConnectionCheck=true; export Test__Cosmos__EmulatorType=linux</PreCommands>
Comment thread
AndriySvyryd marked this conversation as resolved.
<PostCommands>$(PostCommands); docker stop cosmos-emulator || true; docker rm -f cosmos-emulator || true</PostCommands>
<PreCommands>$(PreCommands); export Test__Cosmos__SkipConnectionCheck=true</PreCommands>
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
</XUnitProject>
</ItemGroup>

Expand Down
61 changes: 0 additions & 61 deletions eng/testing/run-cosmos-container.ps1

This file was deleted.

51 changes: 0 additions & 51 deletions eng/testing/run-cosmos-container.sh

This file was deleted.

1 change: 1 addition & 0 deletions test/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="$(OpenTelemetryExporterInMemoryVersion)" />
<PackageVersion Include="SQLitePCLRaw.provider.sqlite3" Version="$(SQLitePCLRawVersion)" />
<PackageVersion Include="SQLitePCLRaw.provider.winsqlite3" Version="$(SQLitePCLRawVersion)" />
<PackageVersion Include="Testcontainers.CosmosDb" Version="4.11.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.ResourceManager.CosmosDB" />
<PackageReference Include="Testcontainers.CosmosDb" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ public static class CosmosDbContextOptionsBuilderExtensions
{
public static CosmosDbContextOptionsBuilder ApplyConfiguration(this CosmosDbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.ExecutionStrategy(d => new TestCosmosExecutionStrategy(d))
.RequestTimeout(TimeSpan.FromMinutes(20))
.HttpClientFactory(() => new HttpClient(
var httpClient = TestEnvironment.HttpMessageHandler != null
? new HttpClient(TestEnvironment.HttpMessageHandler)
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
: new HttpClient(
new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
}))
});

optionsBuilder
.ExecutionStrategy(d => new TestCosmosExecutionStrategy(d))
.RequestTimeout(TimeSpan.FromMinutes(20))
.HttpClientFactory(() => httpClient)
.ConnectionMode(ConnectionMode.Gateway);

return optionsBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuild

public static async ValueTask<bool> IsConnectionAvailableAsync()
{
await TestEnvironment.InitializeAsync().ConfigureAwait(false);

Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated
if (TestEnvironment.SkipConnectionCheck)
{
return true;
Expand Down
117 changes: 110 additions & 7 deletions test/EFCore.Cosmos.FunctionalTests/TestUtilities/TestEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Testcontainers.CosmosDb;

namespace Microsoft.EntityFrameworkCore.TestUtilities;

Expand All @@ -22,15 +23,115 @@ public static class TestEnvironment
.Build()
.GetSection("Test:Cosmos");

public static string DefaultConnection { get; } = string.IsNullOrEmpty(Config["DefaultConnection"])
? "https://localhost:8081"
: Config["DefaultConnection"];
private static CosmosDbContainer _container;
private static bool _initialized;
private static readonly SemaphoreSlim _initSemaphore = new(1, 1);

public static string DefaultConnection { get; private set; }
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated

internal static HttpMessageHandler HttpMessageHandler { get; private set; }

public static async Task InitializeAsync()
{
if (_initialized)
{
return;
}

await _initSemaphore.WaitAsync().ConfigureAwait(false);

try
{
if (_initialized)
{
return;
}

// If a connection string is specified (env var, config.json...), always use that.
var configured = Config["DefaultConnection"];
if (!string.IsNullOrEmpty(configured))
{
DefaultConnection = configured;
_initialized = true;
return;
}

// Try to connect to the default emulator endpoint.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My preferred flow here would be simpler:

  • If the environment variable is defined, we connect to that (no testcontainer)
  • Otherwise, testcontainer

This way, if I want to have an always-on emulator running in docker (or the Windows emulator), it's simply up to me to define the environment variable, rather than rely on probing here with a hard-coded port etc.

Am I missing something, why this more complicated logic is needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d2b56b1. Removed localhost probing entirely. The flow is now: DefaultConnection env var/config set → use it, otherwise → start testcontainer. No intermediate probing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The above approach was simpler for the developer. If they had the emulator running they didn't need to even know about the environment variable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sure, but I'm pretty sure that if someone is taking the trouble to install the emulator locally, just adding an environment variable wouldn't represent an issue (plus if the testcontainer works well, there won't be a huge reason to run a permanent instance anyway). It also doesn't help that the TCP port here is very generic (8081), and some other service could be running on it, generating a weird and unexpected error (i.e. if this were some very standard port reserved for Cosmos I'd be more inclined to like it).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But I'll add that if you have strong feelings here I'm fine going either way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Until the Linux emulator gets feature parity this will be the main way to test Cosmos locally on Windows.
@copilot revert the code back to probing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2aa85dd. Restored localhost probing: the flow is now (1) configured endpoint from env var/config, (2) probe localhost:8081 for an already-running emulator, (3) start testcontainer with helpful error on failure.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Until the Linux emulator gets feature parity this will be the main way to test Cosmos locally on Windows.

That's still basically just you :) And adding an environment variable for that seems very reasonable. But no problem.

if (await TryProbeEmulatorAsync("https://localhost:8081").ConfigureAwait(false))
{
DefaultConnection = "https://localhost:8081";
_initialized = true;
return;
}

// Try to start a testcontainer with the Linux emulator.
try
{
_container = new CosmosDbBuilder("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview")
.Build();
await _container.StartAsync().ConfigureAwait(false);
Comment thread
AndriySvyryd marked this conversation as resolved.
Outdated

AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
try
{
_container.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
catch
{
// Best-effort cleanup: container may already be stopped or Docker daemon
// may have exited before the process exit handler runs.
}
};

DefaultConnection = new UriBuilder(
Uri.UriSchemeHttp,
_container.Hostname,
_container.GetMappedPublicPort(CosmosDbBuilder.CosmosDbPort)).ToString();
HttpMessageHandler = _container.HttpMessageHandler;
}
catch when (!SkipConnectionCheck)
{
// Any failure (Docker not installed, daemon not running, image pull failure, etc.)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we should do this. As above, things should IMHO be super simple, and we shouldn't try to fall back from failures to a default endpoint - that only makes things more difficult to figure out if there's a failure. My preference would be to just always connect to the environment variable endpoint if the variable is defined (failing if there's an issue), or if the variable isn't defined, just always start a testcontainer (failing if there's an issue).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d2b56b1. Removed the catch when (!SkipConnectionCheck) fallback. If the env var is set, we connect to it (failing if there's an issue). If not, we start a testcontainer (failing if there's an issue). No fallback to a default endpoint.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should still add a helpful message saying that either an emulator or docker needs to be running, or an external connection string should be provided.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yep, agreed. @copilot can you add that please?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b0ae4a8. Wrapped the testcontainer startup in a try/catch that throws an InvalidOperationException with a message explaining that Docker must be installed and running, or the Test__Cosmos__DefaultConnection environment variable should be set to connect to an existing emulator.

// falls back to the default endpoint. The connection check in CosmosTestStore will
// determine whether the emulator is actually reachable and skip tests if not.
DefaultConnection = "https://localhost:8081";
}

_initialized = true;
}
finally
{
_initSemaphore.Release();
}
}

private static async Task<bool> TryProbeEmulatorAsync(string endpoint)
{
try
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(3) };
// Any successful response (even 401) means the emulator is up and accepting connections.
using var response = await client.GetAsync(endpoint).ConfigureAwait(false);
return true;
}
catch
{
// Expected: HttpRequestException (connection refused), TaskCanceledException (timeout),
// or SocketException when the emulator is not running.
return false;
}
}

public static string AuthToken { get; } = string.IsNullOrEmpty(Config["AuthToken"])
? _emulatorAuthToken
: Config["AuthToken"];

public static string ConnectionString { get; } = $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";
public static string ConnectionString => $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";

public static bool UseTokenCredential { get; } = string.Equals(Config["UseTokenCredential"], "true", StringComparison.OrdinalIgnoreCase);

Expand All @@ -45,12 +146,14 @@ public static class TestEnvironment
? AzureLocation.WestUS
: Enum.Parse<AzureLocation>(Config["AzureLocation"]);

public static bool IsEmulator { get; } = !UseTokenCredential && (AuthToken == _emulatorAuthToken);
public static bool IsEmulator => !UseTokenCredential && (AuthToken == _emulatorAuthToken);

public static bool SkipConnectionCheck { get; } = string.Equals(Config["SkipConnectionCheck"], "true", StringComparison.OrdinalIgnoreCase);

public static string EmulatorType { get; } = Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");
public static string EmulatorType => _container != null
? "linux"
: Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");

public static bool IsLinuxEmulator { get; } = IsEmulator
public static bool IsLinuxEmulator => IsEmulator
&& EmulatorType.Equals("linux", StringComparison.OrdinalIgnoreCase);
}
2 changes: 1 addition & 1 deletion test/EFCore.Cosmos.FunctionalTests/cosmosConfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"Test": {
"Cosmos": {
"DefaultConnection": "https://localhost:8081",
"DefaultConnection": null,
"AuthToken": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
}
}
Expand Down
Loading