The system SHALL provide a PrefetchUnwrapKeyAsync method that calls ResolveAsync() + UnwrapKeyAsync() asynchronously and stores the result in a ConcurrentDictionary<string, byte[]> prefetch cache. This method SHALL be called before semaphore acquisition in BuildProtectedDataEncryptionKeyAsync.
- WHEN
BuildEncryptionAlgorithmForSettingAsyncenters the cold path and callsPrefetchUnwrapKeyAsyncbefore acquiring the semaphore - THEN
ResolveAsyncandUnwrapKeyAsyncSHALL execute asynchronously (yielding the thread), and the result SHALL be stored in the prefetch cache
- WHEN the Microsoft Data Encryption library's sync
UnwrapKeyis called inside the semaphore and the prefetch cache has a valid entry for the wrapped key - THEN
UnwrapKeySHALL return the cached bytes immediately without callingResolve()orUnwrapKey()on Key Vault
- WHEN the Microsoft Data Encryption library's sync
UnwrapKeyis called inside the semaphore and the prefetch cache does NOT have an entry (race condition, prefetch failed, or prefetch not called) - THEN
UnwrapKeySHALL fall through to the existing syncResolve()+UnwrapKey()path (identical to current behavior)
The system SHALL deduplicate concurrent prefetch calls for the same wrapped key so that only one async Key Vault call flies per key at a time.
- WHEN N threads simultaneously call
PrefetchUnwrapKeyAsyncfor the same wrapped key (N can be any number of concurrent callers) - THEN only one
ResolveAsync+UnwrapKeyAsynccall SHALL be made to Key Vault; all N threads SHALL await the sameTask - NOTE: The deduplication guarantee is independent of the number of concurrent callers. Test scenarios SHOULD use a representative concurrency level (e.g. 50) but the invariant holds for any N ≥ 2.
The system SHALL schedule a background refresh of the prefetch cache entry when the entry is within the refresh window of its time-to-live expiry (20% of cache time-to-live, capped at 5 minutes maximum), so that the next consumer finds a warm cache.
- WHEN a prefetch cache entry is within the refresh window (20% of time-to-live, max 5 minutes) of expiry and is accessed
- THEN the system SHALL initiate a background
Task.Runthat callsResolveAsync+UnwrapKeyAsyncand updates the cache entry
- WHEN the background refresh call fails (Key Vault down, 429 throttle, network error)
- THEN the failure SHALL be caught and logged; the existing cache entry SHALL remain until its time-to-live expires; the sync fallback path SHALL handle the next call
- NOTE: The background refresh SHALL NOT retry on failure. Retrying with backoff risks spanning past the cache entry's time-to-live expiry — at which point the entry is gone, concurrent threads find no cache hit, and all fall through to the sync
Resolve()+UnwrapKey()path under the semaphore, recreating the thundering herd problem this design prevents. Instead, fail fast: log the failure, keep serving the existing entry until time-to-live expiry, and rely on the next natural prefetch call (on the next cache access) to retry organically. The prefetch path already deduplicates concurrent calls, so retry coordination is built in. No explicit cache invalidation is needed — time-to-live expiry naturally clears stale entries.
The prefetch cache entry time-to-live SHALL match the ProtectedDataEncryptionKey.TimeToLive value.
- WHEN the
ProtectedDataEncryptionKeycache time-to-live (1–2 hours) elapses - THEN the prefetch cache entry for the same key SHALL also be expired, ensuring a fresh Key Vault call on the next cold path
The async prefetch layer SHALL use a CancellationTokenSource to cancel in-flight background refresh tasks on disposal.
- WHEN
EncryptionCosmosClient.Dispose()is called - THEN the
CancellationTokenSourceSHALL be cancelled, all in-flight background refresh tasks SHALL observe cancellation, and the prefetch cache SHALL be cleared
- WHEN
Dispose()is called multiple times - THEN the second and subsequent calls SHALL be no-ops (idempotent via
Interlocked.Exchange)
The prefetch call in BuildEncryptionAlgorithmForSettingAsync SHALL be best-effort.
- WHEN
PrefetchUnwrapKeyAsyncthrows an exception that is notOperationCanceledException - THEN the exception SHALL be caught and swallowed; execution SHALL continue to semaphore acquisition and the normal sync path
- WHEN
PrefetchUnwrapKeyAsyncthrowsOperationCanceledException(caller's token fired) - THEN the exception SHALL propagate to the caller (same as current behavior when
WaitAsyncis cancelled)