Add async counterparts to the enclave provider hierarchy (Phase 3, partial) - #4541
Add async counterparts to the enclave provider hierarchy (Phase 3, partial)#4541cheenamalhotra wants to merge 4 commits into
Conversation
…rtial)
Implements the enclave-provider portion of Phase 3 of the async Always
Encrypted spec (specs/002-async-always-encrypted/spec.md).
SqlColumnEncryptionEnclaveProvider gains four `virtual` async counterparts
whose default implementations defer to the existing sync overloads, mirroring
the pattern already established for SqlColumnEncryptionKeyStoreProvider in
Phase 1. Because C# forbids `out` parameters on async methods, the two members
that report multiple values return tuples instead (spec Design Decision 4).
The two providers that perform real network I/O explicitly override the
defaults rather than inheriting the blocking fallback, which removes both
sync-over-async blocking calls in this hierarchy:
* AzureAttestationBasedEnclaveProvider now awaits
ConfigurationManager.GetConfigurationAsync instead of blocking on .Result.
* HostGuardianServiceEnclaveProvider now awaits GetStreamAsync,
JsonSerializer.DeserializeAsync and the retry backoff instead of blocking
on .GetAwaiter().GetResult() and Thread.Sleep.
FR-015: the attestation gate (AutoResetEvent) has no awaitable wait, so the
async path gets its own SemaphoreSlim gate via GetEnclaveSessionHelperAsync.
The gates are deliberately independent so that a synchronous caller can never
block a thread for the duration of an awaited attestation round trip. The five
`lock` statements called out in the spec are left as-is: their bodies only
touch MemoryCache and flags, and the awaited attestation happens before session
storage rather than inside those regions.
Unlike the sync path, the async gate is also released when the caller cancels,
since a cancelled caller never goes on to create the session.
FR-010: no existing sync code path is modified. Every change is an insertion.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cf57fdc0-1c77-40f1-a1d5-7c0bd98c1a89
There was a problem hiding this comment.
Pull request overview
This PR adds internal async counterparts to the Always Encrypted enclave provider hierarchy, enabling non-blocking attestation/session creation (notably for the HTTP-based providers) while preserving existing sync behavior via default virtual fallbacks. It lays groundwork for later phases to wire async enclave attestation into the command execution flow without introducing public API surface changes.
Changes:
- Added
GetEnclaveSessionAsync,GetAttestationParametersAsync,CreateEnclaveSessionAsync, andInvalidateEnclaveSessionAsynctoSqlColumnEncryptionEnclaveProvider, with default implementations delegating to the sync members and supporting cancellation. - Introduced an async attestation gate in
EnclaveProviderBase(viaSemaphoreSlim) and implemented truly-async network paths for Azure Attestation and HGS/VSM signing certificate retrieval. - Added a new unit test suite validating default fallback behavior, cancellation, and concurrency characteristics across sync/async paths.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionEnclaveProvider.cs | Adds the new internal virtual async members with sync-fallback defaults, cancellation, and exception-to-faulted-task behavior. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/EnclaveProviderBase.cs | Implements async session gating + async helper APIs (tuple returns) to support non-blocking attestation flows. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AzureAttestationBasedEnclaveProvider.cs | Overrides async members to avoid sync-over-async blocking and propagate cancellation through network calls. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/VirtualSecureModeEnclaveProviderBase.cs | Adds async counterparts for VSM attestation logic, including async signing cert retrieval via an abstract MakeRequestAsync. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/VirtualSecureModeEnclaveProvider.cs | Implements truly async HTTP request + retry/backoff + async JSON deserialization for HGS signing cert retrieval. |
| src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AlwaysEncrypted/SqlColumnEncryptionEnclaveProviderAsyncShould.cs | Adds unit tests for fallback dispatch, cancellation semantics, gate behavior, and concurrency on sync/async paths. |
| doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionEnclaveProvider.xml | Documents the new async members and the “don’t mix sync/async within one attestation sequence” constraint. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4541 +/- ##
==========================================
- Coverage 64.78% 62.90% -1.88%
==========================================
Files 288 284 -4
Lines 44418 67766 +23348
==========================================
+ Hits 28774 42628 +13854
- Misses 15644 25138 +9494
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The enclave provider async tests derive from EnclaveProviderBase and call ThreadRetryCache.Remove, where ThreadRetryCache is a MemoryCache. That type lives in Microsoft.Extensions.Caching.Memory, so the test assembly needs a direct compile reference to it. In Project mode the reference flows transitively through the SqlClient project reference, which is why local builds passed. In Package mode the SqlClient package reference sets ExcludeAssets="compile" so that the compiler binds against the implementation assembly rather than the ref assembly, and that exclusion also suppresses the transitive compile asset. The result was: SqlColumnEncryptionEnclaveProviderAsyncShould.cs(570,21): error CS0012: The type 'MemoryCache' is defined in an assembly that is not referenced. Declaring the PackageReference explicitly fixes Package mode and is accurate in both modes, since the test code really does compile against that type. The version resolves through central package management, which already selects 8.0.1 or 9.0.18 based on the target framework. Verified by reproducing the failure locally in Package mode without this change and confirming it builds and passes with it, on net8.0, net9.0 and net10.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cf57fdc0-1c77-40f1-a1d5-7c0bd98c1a89
| string currentThreadId = Thread.CurrentThread.ManagedThreadId.ToString(); | ||
|
|
||
| // In case if on some thread we are running SQL workload which don't require attestation, then in those cases we don't want same thread to wait for the gate to be released. | ||
| // hence skipping it | ||
| string retryThreadID = ThreadRetryCache.Get<string>(currentThreadId); | ||
| if (!string.IsNullOrEmpty(retryThreadID)) | ||
| { | ||
| sameThreadRetry = true; | ||
| } | ||
| else if (!isRetry) | ||
| { | ||
| // We are explicitly not releasing the gate here, as we want to hold it until the driver calls CreateEnclaveSessionAsync. | ||
| // If we release it now, then multiple callers end up calling GetAttestationParameters which triggers the attestation workflow. | ||
| sessionCacheLockTaken = await s_asyncSessionLockEvent | ||
| .WaitAsync(Volatile.Read(ref s_asyncLockTimeoutInMilliseconds), cancellationToken) | ||
| .ConfigureAwait(false); | ||
|
|
||
| if (sessionCacheLockTaken) | ||
| { | ||
| lock (s_asyncLockUpdateSessionLock) | ||
| { | ||
| s_isAsyncSessionLockAcquired = true; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // In case of a multi-threaded application, the first caller takes the gate and all the subsequent callers wait here either until the enclave | ||
| // session is created or the timeout happens. | ||
| if (sessionCacheLockTaken || sameThreadRetry || isRetry) | ||
| { | ||
| // While the current caller was waiting for the gate we may already have completed the attestation elsewhere, | ||
| // in which case we need to release the gate here. | ||
| sqlEnclaveSession = SessionCache.GetEnclaveSession(enclaveSessionParameters, out counter); | ||
| if (sqlEnclaveSession != null && !sameThreadRetry) | ||
| { | ||
| ReleaseAsyncSessionLock(restoreLockTimeout: false); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| // In case we are unable to take the gate, then it represents either | ||
| // 1. Another caller has an ongoing attestation request which is taking more time, may be due to a slow network, or | ||
| // 2. The current workload doesn't require enclave computation, hence the driver is not invoking CreateEnclaveSessionAsync and sqlEnclaveSession is never set. | ||
| // In both cases we need to reduce the timeout to 0 so that subsequent requests should not wait. | ||
| Interlocked.Exchange(ref s_asyncLockTimeoutInMilliseconds, 0); | ||
| } | ||
|
|
||
| if (sqlEnclaveSession == null) | ||
| { | ||
| if (shouldGenerateNonce) | ||
| { | ||
| using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) | ||
| { | ||
| // Client decides to initiate the process of attesting the enclave and to establish a secure session with the enclave. | ||
| // To ensure that server send new attestation request instead of replaying / re-sending the old token, we will create a nonce for current attestation request. | ||
| byte[] nonce = new byte[NonceSize]; | ||
| rng.GetBytes(nonce); | ||
| customData = nonce; | ||
| customDataLength = nonce.Length; | ||
| } | ||
| } | ||
|
|
||
| if (!sameThreadRetry) | ||
| { | ||
| retryThreadID = currentThreadId; | ||
| } | ||
|
|
||
| ThreadRetryCache.Set<string>(currentThreadId, retryThreadID, |
There was a problem hiding this comment.
currentThreadId is captured before the await on line 233, but CreateEnclaveSessionAsync removes the entry using Thread.CurrentThread.ManagedThreadId after resuming — usually a different thread. So these entries are typically never removed and linger for the full 10-minute TTL.
ThreadRetryCache is static and shared with the sync path, so a stale entry left here makes a later sync caller that happens to land on the same thread ID set sameThreadRetry = true and skip the sync gate. That's a behavior change to the sync path, which FR-010 says should stay unchanged.
Just a thought - can we give the async path its own MemoryCache instance so it can't affect sync callers?
There was a problem hiding this comment.
Fixed in 575a2a2. The async path no longer touches ThreadRetryCache at all — it isn't read in GetEnclaveSessionHelperAsync and isn't removed from in CreateEnclaveSessionAsync. Thread-id state can't be sound in async code (continuations resume on arbitrary threads), and rather than give the async path its own MemoryCache I removed the need for one: the gate is now taken and released entirely inside CreateEnclaveSessionAsync, so nothing has to be remembered between calls. The sync path is untouched and can no longer be affected by async callers.
| finally | ||
| { | ||
| // See UpdateEnclaveSessionLockStatus for the rationale; this releases the async gate. | ||
| UpdateAsyncEnclaveSessionLockStatus(sqlEnclaveSession, cancellationToken); |
There was a problem hiding this comment.
sqlEnclaveSession is still null when attestation throws, so the gate isn't released on the failure path and other async callers wait out the full 15s timeout.
The rationale given for releasing on cancellation ("a cancelled caller will never go on to create the session") applies equally to a failed caller. Should this also release when we're leaving via an exception?
There was a problem hiding this comment.
Good catch — fixed in 575a2a2. The gate is now acquired via using (await AcquireAsyncAttestationGateAsync(ct)) around the whole attestation body, so it is released on success, failure and cancellation alike, with no dependence on sqlEnclaveSession being non-null.
|
|
||
| // Verify the token signature against the signing keys downloaded from meta data end point | ||
| bool isKeySigningExpired; | ||
| isSignatureValid = VerifyTokenSignature(attestationToken, attestationInstanceUrl, openIdConfig.SigningKeys, out isKeySigningExpired, out exceptionMessage); |
There was a problem hiding this comment.
VerifyTokenSignature does a Thread.Sleep(SigningKeyRetryInSec * 1000) on the SecurityTokenValidationException retry path (line 615). That's a multi-second, non-cancellable thread-pool block sitting inside the new async path.
Shall we move the delay into this do/while as an awaited Task.Delay(..., cancellationToken) so the async path is actually non-blocking.
There was a problem hiding this comment.
Fixed in 575a2a2. I split the signature check into VerifyTokenSignatureCore (no backoff) and kept the Thread.Sleep in the synchronous VerifyTokenSignature wrapper so sync behaviour is byte-for-byte unchanged. The async loop now calls the core method and awaits Task.Delay(SigningKeyRetryInSec * 1000, cancellationToken), so the backoff is non-blocking and cancellable.
| #if NET | ||
| using (Stream stream = await s_client.GetStreamAsync(url, cancellationToken).ConfigureAwait(false)) | ||
| #else | ||
| using (Stream stream = await s_client.GetStreamAsync(url).ConfigureAwait(false)) |
There was a problem hiding this comment.
On net462 there's no token overload, so an in-flight request isn't cancellable — cancellation is only observed between retries at line 97. That's fine given the framework, but worth a short comment here so callers don't assume uniform cancellation semantics across TFMs.
There was a problem hiding this comment.
Added in 575a2a2 — the method comment now spells out that on .NET the token is passed to HttpClient and cancels an in-flight request, while on .NET Framework there is no token-accepting GetStreamAsync overload so cancellation is only observed between attempts. The abstract declaration also notes that implementations should document framework-specific limits.
| int customDataLength = 0; | ||
| SqlEnclaveSession sqlEnclaveSession = SessionCache.GetEnclaveSession(enclaveSessionParameters, out long counter); | ||
|
|
||
| if (sqlEnclaveSession == null) |
There was a problem hiding this comment.
✏️ an early return if not null would help with readability
There was a problem hiding this comment.
Done in 575a2a2 — GetEnclaveSessionHelperAsync now early-returns the cached session, and the rest of the method is flat (no gating left in it at all).
| { | ||
| bool sessionCacheLockTaken = false; | ||
| bool sameThreadRetry = false; | ||
| string currentThreadId = Thread.CurrentThread.ManagedThreadId.ToString(); |
There was a problem hiding this comment.
I'm really wary of all this thread id caching in an async context. Because we typically use ConfigureAwait(false) a continuation can run a retry on a different thread and may break a lot of assumptions.
What's the benefit of tracking threads here? Why not rewrite the flow so that the semaphore can be returned in a finally block?
There was a problem hiding this comment.
Agreed, and fixed in 575a2a2 — the thread-id tracking is gone from the async path entirely.
The sync design needed it because the gate is taken in GetEnclaveSession and only released by a later CreateEnclaveSession call, so it had to recognise "this thread already holds it" (the driver may never call CreateEnclaveSession for a non-enclave query). That cross-call ownership is exactly what breaks under ConfigureAwait(false).
The async path no longer spans calls: GetEnclaveSessionHelperAsync just probes the cache (and generates the nonce) with no lock, and the gate is taken and released inside CreateEnclaveSessionAsync:
using (await AcquireAsyncAttestationGateAsync(cancellationToken).ConfigureAwait(false))
{
// another caller may have attested while we waited
var session = GetEnclaveSessionFromCache(parameters, out long counter);
if (session != null) { return (session, counter); }
... attest, cache ...
}The lease is a readonly struct that releases on Dispose, so acquisition and release live in one try/finally scope — no ambient state, no AsyncLocal, and nothing to leak when the driver never gets as far as creating a session.
Collapsing concurrent attestations is preserved (arguably improved): the post-gate cache re-check means N concurrent cold starts now produce exactly one attestation-service call instead of "1..N". The unit test asserts AttestationCount == 1 for 8 concurrent callers, and there's a new test proving the gate is released when attestation throws.
Addresses review feedback on the async enclave provider hierarchy: - The async attestation gate is now taken and released entirely inside CreateEnclaveSessionAsync via a disposable lease, so the semaphore is released in a finally on every exit path (success, failure, cancellation) and ownership never depends on which thread a continuation resumes on. - The async path no longer reads or writes the static, thread-id keyed ThreadRetryCache, so it can no longer leave stale entries that would make a later synchronous caller skip the sync gate. - Concurrent cold starts still collapse into a single attestation because CreateEnclaveSessionAsync re-checks the session cache after taking the gate. - GetEnclaveSessionHelperAsync now early-returns a cached session and performs no gating, so it never waits on another caller's in-flight attestation. - The signing key retry backoff is awaited (Task.Delay) on the async path instead of blocking a thread pool thread with Thread.Sleep; the synchronous path keeps its existing Thread.Sleep behaviour. - Documented the net462 cancellation granularity limit in MakeRequestAsync. - Tests: renamed the mismatched cancellation test, added coverage for gate release on attestation failure, and tightened the concurrent cold-start assertion to exactly one attestation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| SqlEnclaveSession sqlEnclaveSession = null; | ||
| long counter = 0; | ||
| try | ||
| using (await AcquireAsyncAttestationGateAsync(cancellationToken).ConfigureAwait(false)) |
There was a problem hiding this comment.
This gate acquisition would be a good candidate to be enforced within the base class. Having a public, concrete method that acquires the lock, then calls out to a protected, abstract that contains the logic. Implementations can implement the protected helper method.
| () => AttestAsync(failing, NewSessionParameters())); | ||
|
|
||
| // A subsequent, unrelated attestation must not wait on the abandoned gate. | ||
| FakeAttestationEnclaveProvider provider = new FakeAttestationEnclaveProvider(TimeSpan.Zero); |
There was a problem hiding this comment.
This test relies on the fact that the gate is static. We should reuse the same provider instance so that we're not bound to that fact. If we change to a non-static gate in the future this test will still provide the intended coverage.
|
|
||
| SqlEnclaveSession[] sessions = await Task.WhenAll(tasks); | ||
|
|
||
| Assert.All(sessions, session => Assert.NotNull(session)); |
There was a problem hiding this comment.
What's our expected behavior here? I don't see any assertion that the sessions are all the same. Sync and async won't contend on the lock, but they will contend on the cache. We could end up with duplicated work if a sync and async call hit an empty cache at the same exact time.
If we want to guarantee that only one writes, we could update the sync path to look at our new semaphore slim and wait on it synchronously.
mdaigle
left a comment
There was a problem hiding this comment.
🤖 This review was produced with AI assistance and checked by me before posting.
The rework looks good: taking and releasing the gate entirely inside CreateEnclaveSessionAsync removes the cross-call ownership and the thread-affinity, and dropping ThreadRetryCache from the async path follows from that. Tests pass locally on net8.0.
Three comments below, all non-blocking.
| // Collapsing concurrent attestations is preserved by re-checking the session cache after the | ||
| // gate is taken: the first caller performs the attestation and caches the session, and every | ||
| // caller queued behind it observes that session and returns without contacting the | ||
| // attestation service. |
There was a problem hiding this comment.
🤖 Async cold start no longer collapses the pre-attestation work
Worth settling explicitly before Phase 4 wires EnclaveDelegate up, since nothing calls these members yet.
Moving the gate into CreateEnclaveSessionAsync means GetEnclaveSessionHelperAsync takes no lock, so concurrent cold-start callers no longer queue in call 1. Each one proceeds through GetAttestationParametersAsync (a 384-bit ECDH keygen) and sends serialized attestation parameters on the sp_describe_parameter_encryption RPC, and the server produces enclave attestation info for each. They only serialize at call 3, where all but one return at the post-gate cache re-check.
So N concurrent cold-start queries cost N key generations and N server-side attestation-info productions where sync costs 1. The attestation service round trip, the billed one the file header cares about, is still collapsed. The comment reads as though collapsing is preserved outright, which is the part I'd tighten.
| // Collapsing concurrent attestations is preserved by re-checking the session cache after the | |
| // gate is taken: the first caller performs the attestation and caches the session, and every | |
| // caller queued behind it observes that session and returns without contacting the | |
| // attestation service. | |
| // Calls to the attestation service are still collapsed: the gate is re-checked against the | |
| // session cache after it is taken, so the first caller performs the attestation and caches the | |
| // session, and every caller queued behind it observes that session and returns without | |
| // contacting the attestation service. | |
| // | |
| // Unlike the synchronous path, the work *before* the attestation is no longer collapsed. | |
| // Because this gate is not held across GetEnclaveSessionAsync, concurrent cold-start callers | |
| // each generate a Diffie-Hellman key in GetAttestationParametersAsync and each receive enclave | |
| // attestation info from the server; only the attestation service call itself is shared. This | |
| // is a deliberate trade: it buys an ownership model that is sound under ConfigureAwait(false) | |
| // at the cost of some redundant per-caller work during a cold start. |
| // Represents ownership of the async attestation gate. Disposal releases the gate only when it | ||
| // was actually taken, so a 'using' statement is safe even when the wait timed out. The lease | ||
| // must be disposed exactly once, which 'using' guarantees. | ||
| protected readonly struct AsyncAttestationGateLease : IDisposable | ||
| { | ||
| private readonly bool _acquired; | ||
|
|
||
| internal AsyncAttestationGateLease(bool acquired) | ||
| { | ||
| _acquired = acquired; | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| if (_acquired) | ||
| { | ||
| s_asyncAttestationGate.Release(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🤖 Lease disposal is not idempotent
Both call sites use using (await ...) and are correct, so this is about the extension point rather than current behavior.
Dispose has no guard, so a lease that gets copied and disposed twice calls Release() twice. That throws SemaphoreFullException, and before it does it admits a second caller into the gate. A struct is easy to copy accidentally, and the comment's "must be disposed exactly once" is the only thing enforcing it on a protected member that Phase 4 providers will use.
Making it a class costs one allocation per cold-start attestation. Moot if the gate stops being exposed to subclasses.
| // Represents ownership of the async attestation gate. Disposal releases the gate only when it | |
| // was actually taken, so a 'using' statement is safe even when the wait timed out. The lease | |
| // must be disposed exactly once, which 'using' guarantees. | |
| protected readonly struct AsyncAttestationGateLease : IDisposable | |
| { | |
| private readonly bool _acquired; | |
| internal AsyncAttestationGateLease(bool acquired) | |
| { | |
| _acquired = acquired; | |
| } | |
| public void Dispose() | |
| { | |
| if (_acquired) | |
| { | |
| s_asyncAttestationGate.Release(); | |
| } | |
| } | |
| } | |
| // Represents ownership of the async attestation gate. Disposal releases the gate only when it | |
| // was actually taken, so a 'using' statement is safe even when the wait timed out. Disposal is | |
| // idempotent: releasing a SemaphoreSlim more often than it was taken throws | |
| // SemaphoreFullException, and admits a second caller into the gate before it does. | |
| protected sealed class AsyncAttestationGateLease : IDisposable | |
| { | |
| private int _acquired; | |
| internal AsyncAttestationGateLease(bool acquired) | |
| { | |
| _acquired = acquired ? 1 : 0; | |
| } | |
| public void Dispose() | |
| { | |
| if (Interlocked.Exchange(ref _acquired, 0) == 1) | |
| { | |
| s_asyncAttestationGate.Release(); | |
| } | |
| } | |
| } |
| /// <summary> | ||
| /// Drives the async GetEnclaveSession -> CreateEnclaveSession attestation sequence. | ||
| /// </summary> | ||
| private static async Task<SqlEnclaveSession> AttestAsync( |
There was a problem hiding this comment.
🤖 Test helper skips call 2, so the real sequence is never driven
Add GetAttestationParametersAsync to this helper before CreateEnclaveSessionAsync, and pass the returned client ECDH key into create. The driver sequence is GetEnclaveSession, GetAttestationParameters, then CreateEnclaveSession, and the attestation-parameter step creates the ECDH key.
Consider adding a parameter-generation counter to FakeAttestationEnclaveProvider. Then CreateEnclaveSessionAsync_ConcurrentColdStart_CompletesWithoutDeadlock can assert how many times that step ran, instead of relying only on AttestationCount.
priyankatiwari08
left a comment
There was a problem hiding this comment.
LGTM. Though I agree with Malcolm's cold-start collapsing point that needs to be addressed.
Description
Implements the enclave-provider portion of Phase 3 of
specs/002-async-always-encrypted/spec.md, building on theSqlColumnEncryptionKeyStoreProviderasync members that landed in Phase 1 (#3673).The motivation is that the Always Encrypted enclave path currently blocks a thread on real network I/O even when the caller invoked an async API. Two call sites perform sync-over-async, and the attestation gate that serialises concurrent cold starts is an
AutoResetEventwith a 15 second timeout, so contending callers park a thread pool thread for up to 15 seconds each. This PR lays the async groundwork to remove all three.API changes, backwards compatibility
None.
SqlColumnEncryptionEnclaveProviderisinternal abstractand appears in neithernetcore/ref/nornetfx/ref/, so there is no public API surface and no binary compatibility constraint. The hierarchy is closed and entirely internal.Functionality
1. Four async counterparts on the base type.
GetEnclaveSessionAsync,GetAttestationParametersAsync,CreateEnclaveSessionAsyncandInvalidateEnclaveSessionAsync. They arevirtual, with defaults that defer to the existing sync overloads, so providers with no I/O need no boilerplate. This mirrors the Phase 1Task.FromCanceled/Task.FromResult/Task.FromException+ADP.IsCatchableExceptionTypepattern exactly.C# forbids
outparameters on async methods, so the two members that report multiple values return tuples instead (spec Design Decision 4):2. Both HTTP providers are genuinely async. A
virtualdefault would leave a real network call blocking a thread, so the two providers that perform I/O explicitly override rather than inheriting the fallback. This removes both sync-over-async blocking calls in the hierarchy:AzureAttestationBasedEnclaveProviderGetConfigurationAsync(CancellationToken.None).Resultawait GetConfigurationAsync(cancellationToken)HostGuardianServiceEnclaveProviderGetStreamAsync(url).ConfigureAwait(false).GetAwaiter().GetResult()await GetStreamAsync(url, cancellationToken)The HGS path also awaits
JsonSerializer.DeserializeAsyncand replacesThread.Sleepin the retry backoff withTask.Delay.MakeRequestAsyncis declaredprotected abstractrather thanvirtualso no derived provider can silently inherit a blocking implementation.Left on the base defaults deliberately:
NoneAttestationEnclaveProvider(no I/O), and the CPU boundGetAttestationParameters/InvalidateEnclaveSessionoperations (ECDH key generation, nonce generation,MemoryCacheaccess).3. FR-015, attestation gating. The spec calls for converting
locktoSemaphoreSlimwhere an async operation precedes session storage. On inspection, the fivelockstatements named in the spec were not the ones that needed converting: their bodies only touchMemoryCacheand flags, noawaitoccurs inside them, and none can, because the awaited attestation happens beforeAddEnclaveSessionToCacheis called.lockremains correct and cheaper there.The construct that genuinely blocks is
EnclaveProviderBase.sessionLockEvent, anAutoResetEventwith no awaitable wait.GetEnclaveSessionHelperAsyncuses aSemaphoreSlimgate instead.The sync and async gates are deliberately independent, per the requirement never to hold a gate across an awaited network call that a synchronous caller can also block on. The trade off is that a concurrent sync plus async cold start may perform two attestations. The existing design already tolerates duplicates (see the documented lock timeout cases) and the session cache is idempotent.
Behavioral differences worth reviewer attention
UpdateEnclaveSessionLockStatusreleases only when a session was created. The async version also releases when the token is cancelled, otherwise a caller cancelled mid attestation would stall every other async caller for the full 15 second lock timeout. The sync path has no equivalent case because it has no cancellation.SemaphoreSlim.Release()throws when the semaphore is full, unlikeAutoResetEvent.Set()which is a harmless no-op. Releases are therefore guarded by ans_isAsyncSessionLockAcquiredflag, preserving the sync design's "any caller may signal" semantics while staying idempotent.GetEnclaveSessionAsync->GetAttestationParametersAsync->CreateEnclaveSessionAsyncis one unit. Pairing an async Get with a sync Create leaks a gate because the two use different gates. This is documented in the snippet XML<remarks>and is the main thing to watch during Phase 5 integration.ThreadRetryCacheis keyed byManagedThreadId, which is not stable acrossawaitboundaries. A caller resuming on a different thread may miss the same thread retry optimisation, costing an extra attestation. Never a correctness or deadlock issue. Worth replacing with a correlation token in Phase 5.FR-010, sync paths unchanged
Every source change is an insertion.
git diff --statreports+690 / -1across the five source files, and the single deletion is in the docs XML (a<remarks>line that was expanded). No existing sync statement was moved, reordered or edited. Each new async method was verified statement by statement against its sync twin.Documentation
XML docs for the new members use
<include>intodoc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionEnclaveProvider.xml, consistent with the existing sync members. The class level<remarks>gained the three call attestation sequence and the do-not-mix-sync-and-async contract. No localization impact.Not in scope
EnclaveDelegateasync dispatchers,SqlSecurityUtility.DecryptSymmetricKeyAsyncand theSqlCommand.Encryption.cscall sites are Phase 3 remainder through Phase 5. Nothing calls these new members yet, so there is no end to end behaviour change from this PR. It is purely additive groundwork.Issues
Implements part of Phase 3 of
specs/002-async-always-encrypted/spec.md. Follows on from #3673 (Phase 1).No GitHub issue is closed by this PR.
Testing
12 new unit tests in
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AlwaysEncrypted/SqlColumnEncryptionEnclaveProviderAsyncShould.cs, using four in-file test doubles so no SQL Server or attestation service is required:Task.FromExceptionAttestation took 00:00:15.05, so it is a proven regression guard rather than an assumed oneMakeRequestAsynccancellation, and mapping of request failure toSqlExceptionResults:
No integration or manual tests were added. The new members are not yet reachable from any public API (see "Not in scope"), so there is no end to end path to exercise. Integration coverage against a live enclave belongs with the Phase 5 call site work, where
SqlCommandactually invokes these methods.Additional verification against the spec's hard requirements:
awaitin library code uses.ConfigureAwait(false)(Design Decision 5)CancellationTokenis propagated through all new async methodssrc/Microsoft.Data.SqlClient/src/, none innetfx/src/ornetcore/src/