From 5d62af50420aec7956927304aa76cae88a99b38a Mon Sep 17 00:00:00 2001 From: Yosef Bensimchon Date: Wed, 17 Jun 2026 19:11:14 +0000 Subject: [PATCH 01/38] fix(kms-keyring-node): coalesce concurrent branch key cache misses When many encrypt/decrypt operations run concurrently against a cold cache for the same branch key, each cache miss independently queried the keystore, firing N DynamoDB GetItem and N KMS Decrypt calls instead of one. getBranchKeyMaterials now shares a single in-flight request per cache entry id, evicting it on settle so the cryptographic materials cache keeps ownership of caching and TTL. A rejected request is evicted too, so the next call retries rather than sharing the failure. Co-Authored-By: Claude Opus 4.8 --- .../kms-keyring-node/src/kms_hkeyring_node.ts | 8 ++ .../src/kms_hkeyring_node_helpers.ts | 112 ++++++++++++------ .../kms_hkeyring_node.concurrency.test.ts | 48 ++++++++ 3 files changed, 130 insertions(+), 38 deletions(-) diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index f0a5ad8c2..8352e2957 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -15,6 +15,7 @@ import { KeyringNode, needs, NodeAlgorithmSuite, + NodeBranchKeyMaterial, NodeDecryptionMaterial, NodeEncryptionMaterial, readOnlyProperty, @@ -88,6 +89,7 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { encryptedDataKeys: EncryptedDataKey[] ): Promise cacheEntryHasExceededLimits(entry: BranchKeyMaterialEntry): boolean + _branchKeyMaterialsInFlight: Map> } export class KmsHierarchicalKeyRingNode @@ -104,6 +106,10 @@ export class KmsHierarchicalKeyRingNode public declare cacheLimitTtl: number public declare maxCacheSize?: number public declare _cmc: CryptographicMaterialsCache + public declare _branchKeyMaterialsInFlight: Map< + string, + Promise + > declare readonly _partition: Buffer public declare _utf8Sorting: boolean @@ -259,6 +265,8 @@ export class KmsHierarchicalKeyRingNode readOnlyProperty(this, 'maxCacheSize', maxCacheSize) readOnlyProperty(this, '_cmc', cache) + readOnlyProperty(this, '_branchKeyMaterialsInFlight', new Map()) + if (utf8Sorting === undefined) { readOnlyProperty(this, '_utf8Sorting', false) } else { diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 0c182f4a7..3672dc17e 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -201,48 +201,59 @@ export async function getBranchKeyMaterials( let branchKeyMaterials: NodeBranchKeyMaterial // if the cache entry is false, branch key materials were not found if (!cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry)) { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. + /* Concurrent misses for the same cache entry share one keystore request. + * Without this, N decrypts that start before the cache is populated each + * fire their own DynamoDB GetItem and KMS Decrypt. */ + branchKeyMaterials = await ensureBranchKeyMaterialsInFlight( + hKeyring._branchKeyMaterialsInFlight, + cacheEntryId, + async () => { + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If this is NOT true, then we MUST treat the cache entry as expired. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If this is NOT true, then we MUST treat the cache entry as expired. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is not found or the cache entry is expired, the hierarchical keyring MUST attempt to obtain the branch key materials + //# by querying the backing branch keystore specified in the [retrieve OnEncrypt branch key materials](#query-branch-keystore-onencrypt) section. + //# If the keyring is not able to retrieve [branch key materials](../structures.md#branch-key-materials) + //# through the underlying cryptographic materials cache or + //# it no longer has access to them through the backing keystore, OnEncrypt MUST fail. - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# Otherwise, OnEncrypt MUST fail. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# Otherwise, OnDecrypt MUST fail. - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is not found or the cache entry is expired, the hierarchical keyring MUST attempt to obtain the branch key materials - //# by querying the backing branch keystore specified in the [retrieve OnEncrypt branch key materials](#query-branch-keystore-onencrypt) section. - //# If the keyring is not able to retrieve [branch key materials](../structures.md#branch-key-materials) - //# through the underlying cryptographic materials cache or - //# it no longer has access to them through the backing keystore, OnEncrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# Otherwise, OnEncrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# Otherwise, OnDecrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: - branchKeyMaterials = branchKeyVersion - ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) - : // The complice needs a line //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: - //# - the `branchKeyId` used in this operation - await keyStore.getActiveBranchKey(branchKeyId) - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# If the Keystore's GetActiveBranchKey operation succeeds - //# the keyring MUST put the returned branch key materials in the cache using the - //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# If the Keystore's GetBranchKeyVersion operation succeeds - //# the keyring MUST put the returned branch key materials in the cache using the - //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). - cmc.putBranchKeyMaterial(cacheEntryId, branchKeyMaterials, cacheLimitTtl) + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: + const materials = branchKeyVersion + ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) + : // The complice needs a line + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: + //# - the `branchKeyId` used in this operation + await keyStore.getActiveBranchKey(branchKeyId) + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# If the Keystore's GetActiveBranchKey operation succeeds + //# the keyring MUST put the returned branch key materials in the cache using the + //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# If the Keystore's GetBranchKeyVersion operation succeeds + //# the keyring MUST put the returned branch key materials in the cache using the + //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). + cmc.putBranchKeyMaterial(cacheEntryId, materials, cacheLimitTtl) + + return materials + } + ) } else { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. @@ -270,6 +281,31 @@ function deepCopyBranchKeyMaterial( ) } +// Coalesces concurrent misses for one cache entry onto a single in-flight +// request, evicted on settle so the cryptographic materials cache (not this +// map) keeps ownership of caching and TTL. A rejected request is evicted too, +// so the next call retries rather than sharing the failure. +async function ensureBranchKeyMaterialsInFlight( + inFlight: Map>, + cacheEntryId: string, + fetch: () => Promise +): Promise { + let pending = inFlight.get(cacheEntryId) + if (!pending) { + pending = fetch() + inFlight.set(cacheEntryId, pending) + } + + try { + const branchKeyMaterials = await pending + inFlight.delete(cacheEntryId) + return branchKeyMaterials + } catch (error) { + inFlight.delete(cacheEntryId) + throw error + } +} + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# If the input [encryption materials](../structures.md#encryption-materials) do not contain a plaintext data key, //# OnEncrypt MUST generate a random plaintext data key, according to the key length defined in the [algorithm suite](../algorithm-suites.md#encryption-key-length). diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 2369b8dd7..5e69af372 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -29,6 +29,11 @@ import { KeyStoreInfoOutput, } from '@aws-crypto/branch-keystore-node' +import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' +import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' +import { NodeAlgorithmSuite } from '@aws-crypto/material-management' +import { v4 } from 'uuid' + const CONCURRENCY = 25 let activeMaterialA: NodeBranchKeyMaterial @@ -143,3 +148,46 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', } }) }) + +const CONCURRENT_DECRYPTS = 3000 +const CACHE_LIMIT_TTL = 60_000 +const CACHE_ENTRY_ID = 'shared-cache-entry-id' + +function fixtureMaterial(): NodeBranchKeyMaterial { + return new NodeBranchKeyMaterial(Buffer.alloc(32), 'branchKeyId', v4(), {}) +} + +describe('KmsHierarchicalKeyRingNode: concurrent branch key retrieval', () => { + it(`coalesces ${CONCURRENT_DECRYPTS} concurrent cache misses into one keystore call`, async () => { + let getBranchKeyVersionCalls = 0 + const keyStore = { + async getBranchKeyVersion() { + getBranchKeyVersionCalls += 1 + await new Promise((resolve) => setTimeout(resolve, 5)) + return fixtureMaterial() + }, + } + + const cmc = getLocalCryptographicMaterialsCache(100) + const hKeyring = { + keyStore, + cacheLimitTtl: CACHE_LIMIT_TTL, + cacheEntryHasExceededLimits: () => false, + _branchKeyMaterialsInFlight: new Map(), + } as any + + await Promise.all( + Array.from({ length: CONCURRENT_DECRYPTS }, () => + getBranchKeyMaterials( + hKeyring, + cmc, + 'branchKeyId', + CACHE_ENTRY_ID, + 'branchKeyVersion' + ) + ) + ) + + expect(getBranchKeyVersionCalls).to.equal(1) + }) +}) From 32a4efdba1fd993227639e0241d0934a2d836e7a Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Tue, 6 Oct 2026 20:07:07 +0000 Subject: [PATCH 02/38] fix: coalesce concurrent cache misses in the caching CMM Track in-flight branch key requests per cache, so keyrings sharing a cache share requests. Coalesce concurrent caching CMM misses the same way; waiting encrypts read through the cache so each use counts against the entry's limits. Co-authored-by: Yosef Bensimchon --- ...hing_cryptographic_materials_decorators.ts | 186 +++++++++++++----- ...c_materials_decorators.concurrency.test.ts | 150 ++++++++++++++ .../kms-keyring-node/src/kms_hkeyring_node.ts | 8 - .../src/kms_hkeyring_node_helpers.ts | 44 ++--- .../kms_hkeyring_node.concurrency.test.ts | 127 ++++++++---- 5 files changed, 402 insertions(+), 113 deletions(-) create mode 100644 modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 5be6e144b..79d24b226 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -102,50 +102,79 @@ export function getEncryptionMaterials({ suite, encryptionContext, }) - const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) - /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) + const inFlight = inFlightRequests(this._cache) + /* Concurrent misses wait for one backing request, + * then read the cache again so that each use counts against the entry's limits. + */ + for (;;) { + const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) + /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } + + const pending = inFlight.get(cacheKey) + if (!pending) break + await pending } - const material = await this._backingMaterialsManager - /* Strip any information about the plaintext from the backing request, - * because the resulting response may be used to encrypt multiple plaintexts. - */ - .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) + const settle = startRequest(inFlight, cacheKey) + let material: EncryptionMaterial + try { + material = await this._backingMaterialsManager + /* Strip any information about the plaintext from the backing request, + * because the resulting response may be used to encrypt multiple plaintexts. + */ + .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) + } catch (error) { + settle({ error }) + throw error + } + try { + return cacheEncryptionMaterial(this, cacheKey, material, plaintextLength) + } finally { + settle() + } + } +} - /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ - if (!material.suite.cacheSafe) return material +function cacheEncryptionMaterial( + cmm: CachingMaterialsManager, + cacheKey: string, + material: EncryptionMaterial, + plaintextLength: number +): EncryptionMaterial { + /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ + if (!material.suite.cacheSafe) return material - /* It is possible for an entry to exceed limits immediately. - * The simplest case is to need to encrypt more than then maxBytesEncrypted. - * In this case, I return the response to encrypt the data, - * but do not put a know invalid item into the cache. + /* It is possible for an entry to exceed limits immediately. + * The simplest case is to need to encrypt more than then maxBytesEncrypted. + * In this case, I return the response to encrypt the data, + * but do not put a know invalid item into the cache. + */ + const testEntry = { + response: material, + now: Date.now(), + messagesEncrypted: 1, + bytesEncrypted: plaintextLength, + } + if (!cmm._cacheEntryHasExceededLimits(testEntry)) { + cmm._cache.putEncryptionMaterial( + cacheKey, + material, + plaintextLength, + cmm._maxAge + ) + return cloneResponse(material) + } else { + /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. + * If it is cloned, and the clone is returned, + * then there exist a copy of the unencrypted data key. + * It is true that this data would be caught by GC, it is better to just not rely on that. */ - const testEntry = { - response: material, - now: Date.now(), - messagesEncrypted: 1, - bytesEncrypted: plaintextLength, - } - if (!this._cacheEntryHasExceededLimits(testEntry)) { - this._cache.putEncryptionMaterial( - cacheKey, - material, - plaintextLength, - this._maxAge - ) - return cloneResponse(material) - } else { - /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. - * If it is cloned, and the clone is returned, - * then there exist a copy of the unencrypted data key. - * It is true that this data would be caught by GC, it is better to just not rely on that. - */ - return material - } + return material } } @@ -169,20 +198,77 @@ export function decryptMaterials({ this._partition, request ) - const entry = this._cache.getDecryptionMaterial(cacheKey) - /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) + const inFlight = inFlightRequests(this._cache) + /* Concurrent misses wait for one backing request, then read the cache again. */ + for (;;) { + const entry = this._cache.getDecryptionMaterial(cacheKey) + /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } + + const pending = inFlight.get(cacheKey) + if (!pending) break + await pending } - const material = await this._backingMaterialsManager.decryptMaterials( - request - ) + const settle = startRequest(inFlight, cacheKey) + let material: DecryptionMaterial + try { + material = await this._backingMaterialsManager.decryptMaterials(request) + } catch (error) { + settle({ error }) + throw error + } + try { + this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) + return cloneResponse(material) + } finally { + settle() + } + } +} - this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - return cloneResponse(material) +/* In-flight backing requests are tracked per cache, + * so caching materials managers that share a cache also share requests. + */ +const inFlightByCache = new WeakMap< + CryptographicMaterialsCache, + Map> +>() + +function inFlightRequests( + cache: CryptographicMaterialsCache +): Map> { + const inFlight = + inFlightByCache.get(cache) || new Map>() + inFlightByCache.set(cache, inFlight) + return inFlight +} + +/* Marks a backing request for `cacheKey` as in flight. + * The returned function settles it once the response is in the cache, + * or fails every waiting caller with the request's error. + */ +function startRequest( + inFlight: Map>, + cacheKey: string +): (failure?: { error: unknown }) => void { + let resolve!: () => void + let reject!: (error: unknown) => void + const pending = new Promise((res, rej) => { + resolve = res + reject = rej + }) + /* The requesting caller throws the error itself; only waiting callers read it from here. */ + pending.catch(() => undefined) + inFlight.set(cacheKey, pending) + return (failure) => { + inFlight.delete(cacheKey) + if (failure) reject(failure.error) + else resolve() } } diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts new file mode 100644 index 000000000..6ac675a0c --- /dev/null +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -0,0 +1,150 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-env mocha */ + +import { expect } from 'chai' +import { + cacheEntryHasExceededLimits, + getEncryptionMaterials, + decryptMaterials, +} from '../src/caching_cryptographic_materials_decorators' +import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' +import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' +import { createHash, randomBytes } from 'crypto' +import { + NodeAlgorithmSuite, + AlgorithmSuiteIdentifier, + KeyringTraceFlag, + EncryptedDataKey, + NodeEncryptionMaterial, + NodeDecryptionMaterial, + unwrapDataKey, +} from '@aws-crypto/material-management' + +const suite = new NodeAlgorithmSuite( + AlgorithmSuiteIdentifier.ALG_AES128_GCM_IV12_TAG16_HKDF_SHA256 +) +const edk = new EncryptedDataKey({ + providerId: 'keyNamespace', + providerInfo: 'keyName', + encryptedDataKey: new Uint8Array([1]), +}) +const trace = { keyNamespace: 'keyNamespace', keyName: 'keyName' } + +const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( + (input: string) => Buffer.from(input, 'utf8'), + (input: Uint8Array) => Buffer.from(input).toString('utf8'), + async (...data: (Uint8Array | string)[]) => + data + .map((item) => + typeof item === 'string' ? Buffer.from(item, 'hex') : item + ) + .reduce((hash, item) => hash.update(item), createHash('sha512')) + .digest() +) + +// A backing materials manager that takes a few milliseconds per request +// and returns a new data key every time. +function slowBackingMaterialsManager(fail = false) { + const calls = { encrypt: 0, decrypt: 0 } + const respond = async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (fail) throw new Error('backing materials manager unavailable') + } + return { + calls, + async getEncryptionMaterials() { + calls.encrypt += 1 + await respond() + return new NodeEncryptionMaterial(suite, {}) + .setUnencryptedDataKey(randomBytes(16), { + ...trace, + flags: KeyringTraceFlag.WRAPPING_KEY_GENERATED_DATA_KEY, + }) + .addEncryptedDataKey( + edk, + KeyringTraceFlag.WRAPPING_KEY_ENCRYPTED_DATA_KEY + ) + }, + async decryptMaterials() { + calls.decrypt += 1 + await respond() + return new NodeDecryptionMaterial(suite, {}).setUnencryptedDataKey( + randomBytes(16), + { ...trace, flags: KeyringTraceFlag.WRAPPING_KEY_DECRYPTED_DATA_KEY } + ) + }, + } +} + +function cachingCMM(backing: any, maxMessagesEncrypted = 1000) { + return { + _partition: 'partition', + _maxAge: 1000 * 60, + _maxBytesEncrypted: 1000, + _maxMessagesEncrypted: maxMessagesEncrypted, + _cache: getLocalCryptographicMaterialsCache(100), + _backingMaterialsManager: backing, + _cacheEntryHasExceededLimits: cacheEntryHasExceededLimits(), + getEncryptionMaterials: getEncryptionMaterials(cacheKeyHelpers), + decryptMaterials: decryptMaterials(cacheKeyHelpers), + } as any +} + +const encryptRequest = { suite, encryptionContext: {}, plaintextLength: 1 } +const decryptRequest = { + suite, + encryptionContext: {}, + encryptedDataKeys: [edk], +} + +describe('caching materials manager: concurrent cache misses (#1665)', () => { + it('concurrent encrypts share data keys without exceeding maxMessagesEncrypted', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing, 5) + + const materials = await Promise.all( + Array.from({ length: 10 }, () => + cmm.getEncryptionMaterials(encryptRequest) + ) + ) + + const usesByDataKey = new Map() + for (const material of materials) { + const dataKey = Buffer.from( + unwrapDataKey(material.getUnencryptedDataKey()) + ).toString('hex') + usesByDataKey.set(dataKey, (usesByDataKey.get(dataKey) || 0) + 1) + } + expect(backing.calls.encrypt).to.equal(2) + expect([...usesByDataKey.values()]).to.deep.equal([5, 5]) + }) + + it('concurrent decrypts share one backing request', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing) + + await Promise.all( + Array.from({ length: 10 }, () => cmm.decryptMaterials(decryptRequest)) + ) + + expect(backing.calls.decrypt).to.equal(1) + }) + + it('fails every waiting caller on a failed request, and retries on the next call', async () => { + const backing = slowBackingMaterialsManager(true) + const cmm = cachingCMM(backing) + + const results = await Promise.allSettled( + Array.from({ length: 10 }, () => + cmm.getEncryptionMaterials(encryptRequest) + ) + ) + expect(results.every((r) => r.status === 'rejected')).to.equal(true) + expect(backing.calls.encrypt).to.equal(1) + + await cmm.getEncryptionMaterials(encryptRequest).catch(() => undefined) + expect(backing.calls.encrypt).to.equal(2) + }) +}) diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index 8352e2957..f0a5ad8c2 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -15,7 +15,6 @@ import { KeyringNode, needs, NodeAlgorithmSuite, - NodeBranchKeyMaterial, NodeDecryptionMaterial, NodeEncryptionMaterial, readOnlyProperty, @@ -89,7 +88,6 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { encryptedDataKeys: EncryptedDataKey[] ): Promise cacheEntryHasExceededLimits(entry: BranchKeyMaterialEntry): boolean - _branchKeyMaterialsInFlight: Map> } export class KmsHierarchicalKeyRingNode @@ -106,10 +104,6 @@ export class KmsHierarchicalKeyRingNode public declare cacheLimitTtl: number public declare maxCacheSize?: number public declare _cmc: CryptographicMaterialsCache - public declare _branchKeyMaterialsInFlight: Map< - string, - Promise - > declare readonly _partition: Buffer public declare _utf8Sorting: boolean @@ -265,8 +259,6 @@ export class KmsHierarchicalKeyRingNode readOnlyProperty(this, 'maxCacheSize', maxCacheSize) readOnlyProperty(this, '_cmc', cache) - readOnlyProperty(this, '_branchKeyMaterialsInFlight', new Map()) - if (utf8Sorting === undefined) { readOnlyProperty(this, '_utf8Sorting', false) } else { diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 3672dc17e..cd8665936 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -201,11 +201,9 @@ export async function getBranchKeyMaterials( let branchKeyMaterials: NodeBranchKeyMaterial // if the cache entry is false, branch key materials were not found if (!cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry)) { - /* Concurrent misses for the same cache entry share one keystore request. - * Without this, N decrypts that start before the cache is populated each - * fire their own DynamoDB GetItem and KMS Decrypt. */ + /* Concurrent misses for the same cache entry share one keystore request. */ branchKeyMaterials = await ensureBranchKeyMaterialsInFlight( - hKeyring._branchKeyMaterialsInFlight, + cmc, cacheEntryId, async () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt @@ -281,29 +279,31 @@ function deepCopyBranchKeyMaterial( ) } -// Coalesces concurrent misses for one cache entry onto a single in-flight -// request, evicted on settle so the cryptographic materials cache (not this -// map) keeps ownership of caching and TTL. A rejected request is evicted too, -// so the next call retries rather than sharing the failure. +/* In-flight keystore requests are tracked per cache, + * so keyrings that share a cache also share requests. + * A request leaves the map when it settles; + * the cache alone holds results, and a failure is not reused by later calls. */ +const branchKeyMaterialsInFlight = new WeakMap< + CryptographicMaterialsCache, + Map> +>() + async function ensureBranchKeyMaterialsInFlight( - inFlight: Map>, + cmc: CryptographicMaterialsCache, cacheEntryId: string, fetch: () => Promise ): Promise { - let pending = inFlight.get(cacheEntryId) - if (!pending) { - pending = fetch() - inFlight.set(cacheEntryId, pending) - } + const inFlight = + branchKeyMaterialsInFlight.get(cmc) || + new Map>() + branchKeyMaterialsInFlight.set(cmc, inFlight) - try { - const branchKeyMaterials = await pending - inFlight.delete(cacheEntryId) - return branchKeyMaterials - } catch (error) { - inFlight.delete(cacheEntryId) - throw error - } + const existing = inFlight.get(cacheEntryId) + if (existing) return existing + + const pending = fetch().finally(() => inFlight.delete(cacheEntryId)) + inFlight.set(cacheEntryId, pending) + return pending } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 5e69af372..1e978fddc 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -29,7 +29,6 @@ import { KeyStoreInfoOutput, } from '@aws-crypto/branch-keystore-node' -import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -149,45 +148,107 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', }) }) -const CONCURRENT_DECRYPTS = 3000 -const CACHE_LIMIT_TTL = 60_000 -const CACHE_ENTRY_ID = 'shared-cache-entry-id' +const CONCURRENT_OPERATIONS = 3000 + +// A keystore stub that takes a few milliseconds per request, +// so concurrent operations all miss the cache before the first request returns. +function slowKeyStore( + fail = false +): Sinon.SinonStubbedInstance { + const material = new NodeBranchKeyMaterial( + Buffer.alloc(32, 1), + BRANCH_KEY_ID_A, + v4(), + {} + ) + const keyStore = + stubKeyStore() as Sinon.SinonStubbedInstance + const fetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (fail) throw new Error('keystore unavailable') + return deepCopyBranchKeyMaterial(material) + } + keyStore.getActiveBranchKey.callsFake(fetch) + keyStore.getBranchKeyVersion.callsFake(fetch) + return keyStore +} -function fixtureMaterial(): NodeBranchKeyMaterial { - return new NodeBranchKeyMaterial(Buffer.alloc(32), 'branchKeyId', v4(), {}) +function keyringFor( + keyStore: BranchKeyStoreNode, + cache?: KmsHierarchicalKeyRingNode['_cmc'], + partitionId?: string +): KmsHierarchicalKeyRingNode { + return new KmsHierarchicalKeyRingNode({ + branchKeyIdSupplier: BRANCH_KEY_ID_SUPPLIER, + keyStore, + cacheLimitTtl: TTL, + cache, + partitionId, + }) } -describe('KmsHierarchicalKeyRingNode: concurrent branch key retrieval', () => { - it(`coalesces ${CONCURRENT_DECRYPTS} concurrent cache misses into one keystore call`, async () => { - let getBranchKeyVersionCalls = 0 - const keyStore = { - async getBranchKeyVersion() { - getBranchKeyVersionCalls += 1 - await new Promise((resolve) => setTimeout(resolve, 5)) - return fixtureMaterial() - }, - } +async function encryptConcurrently( + hkr: KmsHierarchicalKeyRingNode, + count: number +) { + return Promise.all( + Array.from({ length: count }, async () => + hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) + ) + ) +} - const cmc = getLocalCryptographicMaterialsCache(100) - const hKeyring = { - keyStore, - cacheLimitTtl: CACHE_LIMIT_TTL, - cacheEntryHasExceededLimits: () => false, - _branchKeyMaterialsInFlight: new Map(), - } as any +describe('KmsHierarchicalKeyRingNode: concurrent branch key cache misses (#1663)', () => { + it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onEncrypt misses into one keystore call`, async () => { + const keyStore = slowKeyStore() + await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + }) - await Promise.all( - Array.from({ length: CONCURRENT_DECRYPTS }, () => - getBranchKeyMaterials( - hKeyring, - cmc, - 'branchKeyId', - CACHE_ENTRY_ID, - 'branchKeyVersion' - ) + it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onDecrypt misses into one keystore call`, async () => { + const keyStore = slowKeyStore() + const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) + const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) + + const hkr = keyringFor(keyStore) + const recovered = await Promise.all( + Array.from({ length: CONCURRENT_OPERATIONS }, async () => { + const material = new NodeDecryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A) + await hkr.onDecrypt(material, encrypted.encryptedDataKeys) + return unwrapDataKey(material.getUnencryptedDataKey()) + }) + ) + + expect(keyStore.getBranchKeyVersion.callCount).to.equal(1) + for (const pdk of recovered) expect(pdk).to.deep.equal(expectedPdk) + }) + + it('coalesces misses across keyrings that share a cache and partition', async () => { + const keyStore = slowKeyStore() + const cache = getLocalCryptographicMaterialsCache(100) + const partitionId = v4() + await Promise.all([ + encryptConcurrently(keyringFor(keyStore, cache, partitionId), 10), + encryptConcurrently(keyringFor(keyStore, cache, partitionId), 10), + ]) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + }) + + it('fails every waiting operation on a failed request, and retries on the next call', async () => { + const keyStore = slowKeyStore(true) + const hkr = keyringFor(keyStore) + + const results = await Promise.allSettled( + Array.from({ length: 10 }, async () => + hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) ) ) + expect(results.every((r) => r.status === 'rejected')).to.equal(true) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - expect(getBranchKeyVersionCalls).to.equal(1) + await hkr + .onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) + .catch(() => undefined) + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) }) From 9fa9480289a5481a0558f486cd9486a758d83362 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Tue, 6 Oct 2026 20:59:48 +0000 Subject: [PATCH 03/38] fix: share branch key copies and skip coalescing uncacheable responses Copy branch key materials before sharing them with waiting callers, so a concurrent eviction cannot zero them first. In the caching CMM, let waiters request in parallel when the response cannot serve them. --- ...hing_cryptographic_materials_decorators.ts | 93 +++++---- ...c_materials_decorators.concurrency.test.ts | 183 +++++++++++++++--- .../src/kms_hkeyring_node_helpers.ts | 24 ++- .../kms_hkeyring_node.concurrency.test.ts | 71 +++++++ 4 files changed, 300 insertions(+), 71 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 79d24b226..8499997ef 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -105,7 +105,16 @@ export function getEncryptionMaterials({ const inFlight = inFlightRequests(this._cache) /* Concurrent misses wait for one backing request, * then read the cache again so that each use counts against the entry's limits. + * Once an entry's limits are spent, the next waiter requests a new data key + * while the rest wait, so N callers with a limit of L make about N/L + * backing requests in sequence. + * A response that cannot serve another caller is not shared: + * waiters then request their own in parallel. */ + const shareable = + plaintextLength <= this._maxBytesEncrypted && + this._maxMessagesEncrypted > 1 + let settle: Settle | undefined for (;;) { const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ @@ -115,28 +124,33 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } + if (!shareable) break const pending = inFlight.get(cacheKey) - if (!pending) break - await pending + if (!pending) { + settle = startRequest(inFlight, cacheKey) + break + } + if (!(await pending)) break } - const settle = startRequest(inFlight, cacheKey) - let material: EncryptionMaterial try { - material = await this._backingMaterialsManager + const material = await this._backingMaterialsManager /* Strip any information about the plaintext from the backing request, * because the resulting response may be used to encrypt multiple plaintexts. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) + const response = cacheEncryptionMaterial( + this, + cacheKey, + material, + plaintextLength + ) + settle && settle({ shared: response.shared }) + return response.material } catch (error) { - settle({ error }) + settle && settle({ error }) throw error } - try { - return cacheEncryptionMaterial(this, cacheKey, material, plaintextLength) - } finally { - settle() - } } } @@ -145,9 +159,9 @@ function cacheEncryptionMaterial( cacheKey: string, material: EncryptionMaterial, plaintextLength: number -): EncryptionMaterial { +): { material: EncryptionMaterial; shared: boolean } { /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ - if (!material.suite.cacheSafe) return material + if (!material.suite.cacheSafe) return { material, shared: false } /* It is possible for an entry to exceed limits immediately. * The simplest case is to need to encrypt more than then maxBytesEncrypted. @@ -167,14 +181,14 @@ function cacheEncryptionMaterial( plaintextLength, cmm._maxAge ) - return cloneResponse(material) + return { material: cloneResponse(material), shared: true } } else { /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. * If it is cloned, and the clone is returned, * then there exist a copy of the unencrypted data key. * It is true that this data would be caught by GC, it is better to just not rely on that. */ - return material + return { material, shared: false } } } @@ -215,60 +229,63 @@ export function decryptMaterials({ } const settle = startRequest(inFlight, cacheKey) - let material: DecryptionMaterial try { - material = await this._backingMaterialsManager.decryptMaterials(request) + const material = await this._backingMaterialsManager.decryptMaterials( + request + ) + this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) + settle({ shared: true }) + return cloneResponse(material) } catch (error) { settle({ error }) throw error } - try { - this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - return cloneResponse(material) - } finally { - settle() - } } } /* In-flight backing requests are tracked per cache, * so caching materials managers that share a cache also share requests. + * Each resolves to whether its response can serve a waiting caller. + * Callers already waiting share a failed request's error; later calls retry. */ const inFlightByCache = new WeakMap< CryptographicMaterialsCache, - Map> + Map> >() function inFlightRequests( cache: CryptographicMaterialsCache -): Map> { - const inFlight = - inFlightByCache.get(cache) || new Map>() - inFlightByCache.set(cache, inFlight) +): Map> { + let inFlight = inFlightByCache.get(cache) + if (!inFlight) { + inFlight = new Map() + inFlightByCache.set(cache, inFlight) + } return inFlight } +type Settle = (result: { shared: boolean } | { error: unknown }) => void + /* Marks a backing request for `cacheKey` as in flight. - * The returned function settles it once the response is in the cache, - * or fails every waiting caller with the request's error. + * The returned function removes it and resolves or rejects its waiting callers. */ function startRequest( - inFlight: Map>, + inFlight: Map>, cacheKey: string -): (failure?: { error: unknown }) => void { - let resolve!: () => void +): Settle { + let resolve!: (shared: boolean) => void let reject!: (error: unknown) => void - const pending = new Promise((res, rej) => { + const pending = new Promise((res, rej) => { resolve = res reject = rej }) /* The requesting caller throws the error itself; only waiting callers read it from here. */ pending.catch(() => undefined) inFlight.set(cacheKey, pending) - return (failure) => { - inFlight.delete(cacheKey) - if (failure) reject(failure.error) - else resolve() + return (result) => { + if (inFlight.get(cacheKey) === pending) inFlight.delete(cacheKey) + if ('error' in result) reject(result.error) + else resolve(result.shared) } } diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 6ac675a0c..dbd094f86 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -25,6 +25,9 @@ import { const suite = new NodeAlgorithmSuite( AlgorithmSuiteIdentifier.ALG_AES128_GCM_IV12_TAG16_HKDF_SHA256 ) +const uncacheableSuite = new NodeAlgorithmSuite( + AlgorithmSuiteIdentifier.ALG_AES128_GCM_IV12_TAG16 +) const edk = new EncryptedDataKey({ providerId: 'keyNamespace', providerInfo: 'keyName', @@ -46,10 +49,16 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( // A backing materials manager that takes a few milliseconds per request // and returns a new data key every time. -function slowBackingMaterialsManager(fail = false) { - const calls = { encrypt: 0, decrypt: 0 } +function slowBackingMaterialsManager({ + fail = false, + materialSuite = suite, +} = {}) { + const calls = { encrypt: 0, decrypt: 0, active: 0, peak: 0 } const respond = async () => { + calls.active += 1 + calls.peak = Math.max(calls.peak, calls.active) await new Promise((resolve) => setTimeout(resolve, 5)) + calls.active -= 1 if (fail) throw new Error('backing materials manager unavailable') } return { @@ -57,7 +66,7 @@ function slowBackingMaterialsManager(fail = false) { async getEncryptionMaterials() { calls.encrypt += 1 await respond() - return new NodeEncryptionMaterial(suite, {}) + return new NodeEncryptionMaterial(materialSuite, {}) .setUnencryptedDataKey(randomBytes(16), { ...trace, flags: KeyringTraceFlag.WRAPPING_KEY_GENERATED_DATA_KEY, @@ -78,13 +87,20 @@ function slowBackingMaterialsManager(fail = false) { } } -function cachingCMM(backing: any, maxMessagesEncrypted = 1000) { +function cachingCMM( + backing: any, + { + maxMessagesEncrypted = 1000, + maxBytesEncrypted = 1000, + cache = getLocalCryptographicMaterialsCache(100), + } = {} +) { return { _partition: 'partition', _maxAge: 1000 * 60, - _maxBytesEncrypted: 1000, + _maxBytesEncrypted: maxBytesEncrypted, _maxMessagesEncrypted: maxMessagesEncrypted, - _cache: getLocalCryptographicMaterialsCache(100), + _cache: cache, _backingMaterialsManager: backing, _cacheEntryHasExceededLimits: cacheEntryHasExceededLimits(), getEncryptionMaterials: getEncryptionMaterials(cacheKeyHelpers), @@ -99,26 +115,55 @@ const decryptRequest = { encryptedDataKeys: [edk], } +async function encryptConcurrently(cmm: any, count: number, request: any) { + return Promise.all( + Array.from({ length: count }, async () => + cmm.getEncryptionMaterials(request) + ) + ) +} + +// Messages and bytes encrypted under each distinct data key. +function usageByDataKey(materials: any[], plaintextLength: number) { + const usage = new Map() + for (const material of materials) { + const dataKey = Buffer.from( + unwrapDataKey(material.getUnencryptedDataKey()) + ).toString('hex') + const entry = usage.get(dataKey) || { messages: 0, bytes: 0 } + entry.messages += 1 + entry.bytes += plaintextLength + usage.set(dataKey, entry) + } + return [...usage.values()] +} + describe('caching materials manager: concurrent cache misses (#1665)', () => { it('concurrent encrypts share data keys without exceeding maxMessagesEncrypted', async () => { const backing = slowBackingMaterialsManager() - const cmm = cachingCMM(backing, 5) + const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) - const materials = await Promise.all( - Array.from({ length: 10 }, () => - cmm.getEncryptionMaterials(encryptRequest) - ) - ) + const materials = await encryptConcurrently(cmm, 10, encryptRequest) - const usesByDataKey = new Map() - for (const material of materials) { - const dataKey = Buffer.from( - unwrapDataKey(material.getUnencryptedDataKey()) - ).toString('hex') - usesByDataKey.set(dataKey, (usesByDataKey.get(dataKey) || 0) + 1) - } expect(backing.calls.encrypt).to.equal(2) - expect([...usesByDataKey.values()]).to.deep.equal([5, 5]) + expect(usageByDataKey(materials, 1).map((u) => u.messages)).to.deep.equal([ + 5, 5, + ]) + }) + + it('concurrent encrypts share data keys without exceeding maxBytesEncrypted', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing, { maxBytesEncrypted: 10 }) + + const materials = await encryptConcurrently(cmm, 10, { + ...encryptRequest, + plaintextLength: 3, + }) + + expect(backing.calls.encrypt).to.equal(4) + expect(usageByDataKey(materials, 3).map((u) => u.bytes)).to.deep.equal([ + 9, 9, 9, 3, + ]) }) it('concurrent decrypts share one backing request', async () => { @@ -126,18 +171,20 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { const cmm = cachingCMM(backing) await Promise.all( - Array.from({ length: 10 }, () => cmm.decryptMaterials(decryptRequest)) + Array.from({ length: 10 }, async () => + cmm.decryptMaterials(decryptRequest) + ) ) expect(backing.calls.decrypt).to.equal(1) }) it('fails every waiting caller on a failed request, and retries on the next call', async () => { - const backing = slowBackingMaterialsManager(true) + const backing = slowBackingMaterialsManager({ fail: true }) const cmm = cachingCMM(backing) const results = await Promise.allSettled( - Array.from({ length: 10 }, () => + Array.from({ length: 10 }, async () => cmm.getEncryptionMaterials(encryptRequest) ) ) @@ -147,4 +194,94 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { await cmm.getEncryptionMaterials(encryptRequest).catch(() => undefined) expect(backing.calls.encrypt).to.equal(2) }) + + describe('requests run in parallel when the response is not cached', () => { + const uncached = { + 'plaintextLength exceeds maxBytesEncrypted': { + backing: {}, + cmm: { maxBytesEncrypted: 10 }, + request: { ...encryptRequest, plaintextLength: 11 }, + peak: 10, + }, + 'maxMessagesEncrypted is 1': { + backing: {}, + cmm: { maxMessagesEncrypted: 1 }, + request: encryptRequest, + peak: 10, + }, + 'the backing suite is not cache safe': { + backing: { materialSuite: uncacheableSuite }, + cmm: {}, + request: { encryptionContext: {}, plaintextLength: 1 }, + // Known only once the first response returns. + peak: 9, + }, + } + + for (const [ + name, + { backing: options, cmm: limits, request, peak }, + ] of Object.entries(uncached)) { + it(name, async () => { + const backing = slowBackingMaterialsManager(options) + const cmm = cachingCMM(backing, limits) + + const materials = await encryptConcurrently(cmm, 10, request) + + expect(backing.calls.encrypt).to.equal(10) + expect(backing.calls.peak).to.equal(peak) + expect(usageByDataKey(materials, 1)).to.have.lengthOf(10) + }) + } + }) + + it('serves every caller when the entry is evicted while waiters resume', async () => { + const backing = slowBackingMaterialsManager() + // A one-entry cache that another key overwrites right after each put. + const cache = getLocalCryptographicMaterialsCache(1) + const evictingCache = { + ...cache, + putEncryptionMaterial(...args: [string, any, number, number?]) { + cache.putEncryptionMaterial(...args) + queueMicrotask(() => cache.del(args[0])) + }, + } + const cmm = cachingCMM(backing, { cache: evictingCache }) + + const materials = await encryptConcurrently(cmm, 10, encryptRequest) + + expect(materials).to.have.lengthOf(10) + for (const material of materials) { + expect(material.hasUnencryptedDataKey).to.equal(true) + } + }) + + it('fails waiting callers when the response cannot be cached', async () => { + const malformed = { + async getEncryptionMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { suite } + }, + async decryptMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { suite } + }, + } + const cmm = cachingCMM(malformed) + + const encrypts = await Promise.allSettled( + Array.from({ length: 3 }, async () => + cmm.getEncryptionMaterials(encryptRequest) + ) + ) + const decrypts = await Promise.allSettled( + Array.from({ length: 3 }, async () => + cmm.decryptMaterials(decryptRequest) + ) + ) + + for (const result of [...encrypts, ...decrypts]) { + expect(result.status).to.equal('rejected') + } + }) }) diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index cd8665936..7ad935c1f 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -249,7 +249,9 @@ export async function getBranchKeyMaterials( //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). cmc.putBranchKeyMaterial(cacheEntryId, materials, cacheLimitTtl) - return materials + /* Share a copy: the cache can evict and zero `materials` + * before waiting callers resume. */ + return deepCopyBranchKeyMaterial(materials) } ) } else { @@ -281,8 +283,8 @@ function deepCopyBranchKeyMaterial( /* In-flight keystore requests are tracked per cache, * so keyrings that share a cache also share requests. - * A request leaves the map when it settles; - * the cache alone holds results, and a failure is not reused by later calls. */ + * A request leaves the map when it settles, so the cache alone holds results. + * Callers already waiting share a failed request's error; later calls retry. */ const branchKeyMaterialsInFlight = new WeakMap< CryptographicMaterialsCache, Map> @@ -293,16 +295,18 @@ async function ensureBranchKeyMaterialsInFlight( cacheEntryId: string, fetch: () => Promise ): Promise { - const inFlight = - branchKeyMaterialsInFlight.get(cmc) || - new Map>() - branchKeyMaterialsInFlight.set(cmc, inFlight) + let inFlight = branchKeyMaterialsInFlight.get(cmc) + if (!inFlight) { + inFlight = new Map() + branchKeyMaterialsInFlight.set(cmc, inFlight) + } + const requests = inFlight - const existing = inFlight.get(cacheEntryId) + const existing = requests.get(cacheEntryId) if (existing) return existing - const pending = fetch().finally(() => inFlight.delete(cacheEntryId)) - inFlight.set(cacheEntryId, pending) + const pending = fetch().finally(() => requests.delete(cacheEntryId)) + requests.set(cacheEntryId, pending) return pending } diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 1e978fddc..80e7a75c0 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -29,6 +29,7 @@ import { KeyStoreInfoOutput, } from '@aws-crypto/branch-keystore-node' +import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -252,3 +253,73 @@ describe('KmsHierarchicalKeyRingNode: concurrent branch key cache misses (#1663) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) }) + +describe('getBranchKeyMaterials: eviction while a request settles', () => { + const material = (fill: number) => + new NodeBranchKeyMaterial(Buffer.alloc(32, fill), BRANCH_KEY_ID_A, v4(), {}) + + function hKeyringWith(keyStore: any) { + return { + keyStore, + cacheLimitTtl: TTL, + cacheEntryHasExceededLimits: () => false, + } as any + } + + it('returns an intact branch key when another put evicts the entry before callers resume', async () => { + const cache = getLocalCryptographicMaterialsCache(1) + // Another operation's put lands one microtask after this request's put. + const racingCache = { + ...cache, + putBranchKeyMaterial( + key: string, + m: NodeBranchKeyMaterial, + ttl?: number + ) { + cache.putBranchKeyMaterial(key, m, ttl) + queueMicrotask(() => cache.putBranchKeyMaterial('other', material(2))) + }, + } + const keyStore = { getBranchKeyVersion: async () => material(1) } + + const results = await Promise.all( + Array.from({ length: 3 }, async () => + getBranchKeyMaterials( + hKeyringWith(keyStore), + racingCache, + BRANCH_KEY_ID_A, + 'entry', + 'version' + ) + ) + ) + + for (const result of results) { + expect(result.branchKey()).to.deep.equal(Buffer.alloc(32, 1)) + } + }) + + it('returns intact branch keys for two keys racing through a one-entry cache', async () => { + const cache = getLocalCryptographicMaterialsCache(1) + const keyStore = { + getBranchKeyVersion: async (id: string) => + material(id === BRANCH_KEY_ID_A ? 1 : 2), + } + + const results = await Promise.all( + Array.from({ length: 10 }, async (_, i) => + getBranchKeyMaterials( + hKeyringWith(keyStore), + cache, + i % 2 ? BRANCH_KEY_ID_B : BRANCH_KEY_ID_A, + i % 2 ? 'entry-b' : 'entry-a', + 'version' + ) + ) + ) + + results.forEach((result, i) => + expect(result.branchKey()).to.deep.equal(Buffer.alloc(32, i % 2 ? 2 : 1)) + ) + }) +}) From f77502a19db1503dd0d103655a1f464a72055962 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Wed, 7 Oct 2026 16:05:11 +0000 Subject: [PATCH 04/38] fix(cache-material): coalesce only when an entry can serve two callers --- .../caching_cryptographic_materials_decorators.ts | 4 ++-- ...raphic_materials_decorators.concurrency.test.ts | 6 ++++++ .../src/kms_hkeyring_node_helpers.ts | 14 +++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 8499997ef..3de4dafcc 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -108,11 +108,11 @@ export function getEncryptionMaterials({ * Once an entry's limits are spent, the next waiter requests a new data key * while the rest wait, so N callers with a limit of L make about N/L * backing requests in sequence. - * A response that cannot serve another caller is not shared: + * A response that cannot serve two callers of this size is not shared: * waiters then request their own in parallel. */ const shareable = - plaintextLength <= this._maxBytesEncrypted && + 2 * plaintextLength <= this._maxBytesEncrypted && this._maxMessagesEncrypted > 1 let settle: Settle | undefined for (;;) { diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index dbd094f86..bdc1c1e26 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -203,6 +203,12 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { request: { ...encryptRequest, plaintextLength: 11 }, peak: 10, }, + 'two plaintexts exceed maxBytesEncrypted': { + backing: {}, + cmm: { maxBytesEncrypted: 10 }, + request: { ...encryptRequest, plaintextLength: 6 }, + peak: 10, + }, 'maxMessagesEncrypted is 1': { backing: {}, cmm: { maxMessagesEncrypted: 1 }, diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 7ad935c1f..0fb792f45 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -295,18 +295,18 @@ async function ensureBranchKeyMaterialsInFlight( cacheEntryId: string, fetch: () => Promise ): Promise { - let inFlight = branchKeyMaterialsInFlight.get(cmc) - if (!inFlight) { - inFlight = new Map() + const inFlight = + branchKeyMaterialsInFlight.get(cmc) || + new Map>() + if (!branchKeyMaterialsInFlight.has(cmc)) { branchKeyMaterialsInFlight.set(cmc, inFlight) } - const requests = inFlight - const existing = requests.get(cacheEntryId) + const existing = inFlight.get(cacheEntryId) if (existing) return existing - const pending = fetch().finally(() => requests.delete(cacheEntryId)) - requests.set(cacheEntryId, pending) + const pending = fetch().finally(() => inFlight.delete(cacheEntryId)) + inFlight.set(cacheEntryId, pending) return pending } From 84158b4906e6babeea356c1ac961711f95208163 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Wed, 7 Oct 2026 16:11:31 +0000 Subject: [PATCH 05/38] docs: clarify cache coalescing comments --- ...hing_cryptographic_materials_decorators.ts | 26 ++++++++++--------- ...c_materials_decorators.concurrency.test.ts | 9 ++++--- .../src/kms_hkeyring_node_helpers.ts | 13 ++++++---- .../kms_hkeyring_node.concurrency.test.ts | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 3de4dafcc..91efb48c1 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -103,13 +103,12 @@ export function getEncryptionMaterials({ encryptionContext, }) const inFlight = inFlightRequests(this._cache) - /* Concurrent misses wait for one backing request, - * then read the cache again so that each use counts against the entry's limits. - * Once an entry's limits are spent, the next waiter requests a new data key - * while the rest wait, so N callers with a limit of L make about N/L - * backing requests in sequence. - * A response that cannot serve two callers of this size is not shared: - * waiters then request their own in parallel. + /* Concurrent misses wait for one backing request, then read the cache. + * Reading through the cache counts each caller's use against the entry's limits. + * When an entry's limits run out, one waiter requests a new data key and the rest wait again, + * so N callers with a limit of L make about N/L backing requests one after another. + * Callers make their own requests in parallel when a response cannot serve another caller: + * when two uses of this size exceed the limits, or when the response is not cached. */ const shareable = 2 * plaintextLength <= this._maxBytesEncrypted && @@ -243,10 +242,10 @@ export function decryptMaterials({ } } -/* In-flight backing requests are tracked per cache, +/* Tracks in-flight backing requests per cache, * so caching materials managers that share a cache also share requests. - * Each resolves to whether its response can serve a waiting caller. - * Callers already waiting share a failed request's error; later calls retry. + * A request resolves to whether it put its response in the cache for waiting callers. + * Callers already waiting get a failed request's error; later calls make a new request. */ const inFlightByCache = new WeakMap< CryptographicMaterialsCache, @@ -267,7 +266,8 @@ function inFlightRequests( type Settle = (result: { shared: boolean } | { error: unknown }) => void /* Marks a backing request for `cacheKey` as in flight. - * The returned function removes it and resolves or rejects its waiting callers. + * Call the returned function when the request finishes: + * it removes the request from `inFlight` and wakes its waiting callers. */ function startRequest( inFlight: Map>, @@ -279,7 +279,9 @@ function startRequest( resolve = res reject = rej }) - /* The requesting caller throws the error itself; only waiting callers read it from here. */ + /* The requesting caller rethrows the error itself. + * Without this handler, a failed request with no waiters is an unhandled rejection. + */ pending.catch(() => undefined) inFlight.set(cacheKey, pending) return (result) => { diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index bdc1c1e26..1c3eafad1 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -47,8 +47,8 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( .digest() ) -// A backing materials manager that takes a few milliseconds per request -// and returns a new data key every time. +// A backing materials manager that takes 5 ms per request +// and returns a new data key each time. function slowBackingMaterialsManager({ fail = false, materialSuite = suite, @@ -219,7 +219,8 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { backing: { materialSuite: uncacheableSuite }, cmm: {}, request: { encryptionContext: {}, plaintextLength: 1 }, - // Known only once the first response returns. + // The first response reveals the suite is not cache safe, + // so the other 9 requests start after it, in parallel. peak: 9, }, } @@ -243,7 +244,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { it('serves every caller when the entry is evicted while waiters resume', async () => { const backing = slowBackingMaterialsManager() - // A one-entry cache that another key overwrites right after each put. + // A cache that deletes each entry one microtask after it is put. const cache = getLocalCryptographicMaterialsCache(1) const evictingCache = { ...cache, diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 0fb792f45..d5e6093c5 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -249,8 +249,10 @@ export async function getBranchKeyMaterials( //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). cmc.putBranchKeyMaterial(cacheEntryId, materials, cacheLimitTtl) - /* Share a copy: the cache can evict and zero `materials` - * before waiting callers resume. */ + /* The cache zeroes `materials` when it evicts them, + * which can happen before waiting callers resume. + * Share a copy instead. + */ return deepCopyBranchKeyMaterial(materials) } ) @@ -281,10 +283,11 @@ function deepCopyBranchKeyMaterial( ) } -/* In-flight keystore requests are tracked per cache, +/* Tracks in-flight keystore requests per cache, * so keyrings that share a cache also share requests. - * A request leaves the map when it settles, so the cache alone holds results. - * Callers already waiting share a failed request's error; later calls retry. */ + * A request leaves the map when it finishes; only the cache keeps results. + * Callers already waiting get a failed request's error; later calls make a new request. + */ const branchKeyMaterialsInFlight = new WeakMap< CryptographicMaterialsCache, Map> diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 80e7a75c0..695fba807 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -151,7 +151,7 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// A keystore stub that takes a few milliseconds per request, +// A keystore stub that takes 5 ms per request, // so concurrent operations all miss the cache before the first request returns. function slowKeyStore( fail = false From 38bc0d4caa4b03205ae1f23d9210460a132fab12 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Thu, 8 Oct 2026 21:54:53 +0000 Subject: [PATCH 06/38] fix: follow the MPL storm-tracking cache for concurrent cache misses Port the MPL's StormTracker to the hierarchical keyring: refresh entries in their grace period, let another caller fetch after graceInterval, fail waiters after inFlightTTL, and limit concurrent fetches to fanOut. In the caching CMM, callers join an in-flight request only while it has room in its limits, so a burst runs its requests in parallel. Waiters time out after inFlightTTL and retry a failed request once. --- ...hing_cryptographic_materials_decorators.ts | 329 ++++++++++++------ ...c_materials_decorators.concurrency.test.ts | 284 ++++++++++----- .../src/branch_key_storm_tracker.ts | 104 ++++++ .../src/kms_hkeyring_node_helpers.ts | 193 +++++----- .../kms_hkeyring_node.concurrency.test.ts | 157 +++++++-- 5 files changed, 753 insertions(+), 314 deletions(-) create mode 100644 modules/kms-keyring-node/src/branch_key_storm_tracker.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 91efb48c1..4cf7b7d90 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -22,6 +22,17 @@ import { } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' +/* Timing for coalesced backing requests, from the MPL's storm-tracking cache defaults. + * Times are in milliseconds. + * Tests shorten them, so read them at call time. + */ +export const COALESCING = { + // A request this old takes no new callers; the next caller starts another. + graceInterval: 1000, + // A caller that has waited this long fails. + inFlightTTL: 10 * 1000, +} + export function decorateProperties( obj: CachingMaterialsManager, input: CachingMaterialsManagerDecorateInput @@ -102,18 +113,21 @@ export function getEncryptionMaterials({ suite, encryptionContext, }) - const inFlight = inFlightRequests(this._cache) - /* Concurrent misses wait for one backing request, then read the cache. - * Reading through the cache counts each caller's use against the entry's limits. - * When an entry's limits run out, one waiter requests a new data key and the rest wait again, - * so N callers with a limit of L make about N/L backing requests one after another. - * Callers make their own requests in parallel when a response cannot serve another caller: - * when two uses of this size exceed the limits, or when the response is not cached. + const fetch = async () => + this._backingMaterialsManager + /* Strip any information about the plaintext from the backing request, + * because the resulting response may be used to encrypt multiple plaintexts. + */ + .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) + const batches = openBatches>(this._cache, cacheKey) + const waitUntil = Date.now() + COALESCING.inFlightTTL + let retried = false + + /* Concurrent misses join an in-flight backing request that still has room in its limits. + * Each caller that joins reserves its use of the data key before the response arrives, + * so the data key is never used past maxMessagesEncrypted or maxBytesEncrypted. + * A caller that does not fit starts its own request, so requests run in parallel. */ - const shareable = - 2 * plaintextLength <= this._maxBytesEncrypted && - this._maxMessagesEncrypted > 1 - let settle: Settle | undefined for (;;) { const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ @@ -123,31 +137,62 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - if (!shareable) break - const pending = inFlight.get(cacheKey) - if (!pending) { - settle = startRequest(inFlight, cacheKey) - break + const batch = batches.find((b) => canJoin(this, b, plaintextLength)) + if (!batch) break + const outcome = await joinBatch(batch, plaintextLength, waitUntil) + if ('material' in outcome) return outcome.material + if ('uncached' in outcome) { + return cacheEncryptionMaterial( + this, + cacheKey, + await fetch(), + plaintextLength + ) } - if (!(await pending)) break + /* A failed request is retried once, then its error reaches the caller. */ + if (retried) throw outcome.error + retried = true + await sleep(batch.startedAt + COALESCING.graceInterval - Date.now()) } + const batch = startBatch(batches, this, plaintextLength) try { - const material = await this._backingMaterialsManager - /* Strip any information about the plaintext from the backing request, - * because the resulting response may be used to encrypt multiple plaintexts. - */ - .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - const response = cacheEncryptionMaterial( - this, + const material = await fetch() + closeBatch(batches, batch) + if (!material.suite.cacheSafe) { + settleMembers(batch, { uncached: true }) + return material + } + const used = { + response: material, + now: Date.now(), + messagesEncrypted: batch.messages, + bytesEncrypted: batch.bytes, + } + /* A batch over its limits has no members: none could join it. + * The requester's own use exceeds the limits, as in cacheEncryptionMaterial. + */ + if (this._cacheEntryHasExceededLimits(used)) { + settleMembers(batch, { uncached: true }) + return material + } + this._cache.putEncryptionMaterial( cacheKey, material, - plaintextLength + plaintextLength, + this._maxAge ) - settle && settle({ shared: response.shared }) - return response.material + /* Count every member's use against the entry this request just put. */ + for (const member of batch.members) { + this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + } + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) } catch (error) { - settle && settle({ error }) + closeBatch(batches, batch) + settleMembers(batch, { error }) throw error } } @@ -158,36 +203,38 @@ function cacheEncryptionMaterial( cacheKey: string, material: EncryptionMaterial, plaintextLength: number -): { material: EncryptionMaterial; shared: boolean } { - /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ - if (!material.suite.cacheSafe) return { material, shared: false } - - /* It is possible for an entry to exceed limits immediately. - * The simplest case is to need to encrypt more than then maxBytesEncrypted. - * In this case, I return the response to encrypt the data, - * but do not put a know invalid item into the cache. - */ - const testEntry = { - response: material, - now: Date.now(), - messagesEncrypted: 1, - bytesEncrypted: plaintextLength, - } - if (!cmm._cacheEntryHasExceededLimits(testEntry)) { - cmm._cache.putEncryptionMaterial( - cacheKey, - material, - plaintextLength, - cmm._maxAge - ) - return { material: cloneResponse(material), shared: true } - } else { - /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. - * If it is cloned, and the clone is returned, - * then there exist a copy of the unencrypted data key. - * It is true that this data would be caught by GC, it is better to just not rely on that. +): EncryptionMaterial { + { + /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ + if (!material.suite.cacheSafe) return material + + /* It is possible for an entry to exceed limits immediately. + * The simplest case is to need to encrypt more than then maxBytesEncrypted. + * In this case, I return the response to encrypt the data, + * but do not put a know invalid item into the cache. */ - return { material, shared: false } + const testEntry = { + response: material, + now: Date.now(), + messagesEncrypted: 1, + bytesEncrypted: plaintextLength, + } + if (!cmm._cacheEntryHasExceededLimits(testEntry)) { + cmm._cache.putEncryptionMaterial( + cacheKey, + material, + plaintextLength, + cmm._maxAge + ) + return cloneResponse(material) + } else { + /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. + * If it is cloned, and the clone is returned, + * then there exist a copy of the unencrypted data key. + * It is true that this data would be caught by GC, it is better to just not rely on that. + */ + return material + } } } @@ -211,8 +258,11 @@ export function decryptMaterials({ this._partition, request ) - const inFlight = inFlightRequests(this._cache) - /* Concurrent misses wait for one backing request, then read the cache again. */ + const batches = openBatches>(this._cache, cacheKey) + const waitUntil = Date.now() + COALESCING.inFlightTTL + let retried = false + + /* Concurrent misses join the in-flight backing request. */ for (;;) { const entry = this._cache.getDecryptionMaterial(cacheKey) /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ @@ -222,73 +272,148 @@ export function decryptMaterials({ this._cache.del(cacheKey) } - const pending = inFlight.get(cacheKey) - if (!pending) break - await pending + const batch = batches.find(isRecent) + if (!batch) break + const outcome = await joinBatch(batch, 0, waitUntil) + if ('material' in outcome) return outcome.material + /* A failed request is retried once, then its error reaches the caller. + * Decrypt requests always cache their response, so the outcome is a failure. + */ + if (retried) throw (outcome as Failed).error + retried = true + await sleep(batch.startedAt + COALESCING.graceInterval - Date.now()) } - const settle = startRequest(inFlight, cacheKey) + const batch = startBatch(batches, this, 0) try { const material = await this._backingMaterialsManager.decryptMaterials( request ) + closeBatch(batches, batch) this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - settle({ shared: true }) + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } return cloneResponse(material) } catch (error) { - settle({ error }) + closeBatch(batches, batch) + settleMembers(batch, { error }) throw error } } } -/* Tracks in-flight backing requests per cache, +/* An in-flight backing request and the callers waiting on it. + * `messages` and `bytes` count the uses reserved by the requester and its members. + */ +interface Batch { + startedAt: number + messages: number + bytes: number + requester: CachingMaterialsManager + members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] +} + +interface Failed { + error: unknown +} +type Outcome = { material: M } | { uncached: true } | Failed + +/* Open requests per cache and cache key, * so caching materials managers that share a cache also share requests. - * A request resolves to whether it put its response in the cache for waiting callers. - * Callers already waiting get a failed request's error; later calls make a new request. + * The map lives in this module: two copies of this package loaded in one process + * keep separate requests for the same cache. */ -const inFlightByCache = new WeakMap< +const batchesByCache = new WeakMap< CryptographicMaterialsCache, - Map> + Map[]> >() -function inFlightRequests( - cache: CryptographicMaterialsCache -): Map> { - let inFlight = inFlightByCache.get(cache) - if (!inFlight) { - inFlight = new Map() - inFlightByCache.set(cache, inFlight) +function openBatches( + cache: CryptographicMaterialsCache, + cacheKey: string +): Batch[] { + let byKey = batchesByCache.get(cache) + if (!byKey) { + byKey = new Map() + batchesByCache.set(cache, byKey) + } + let batches = byKey.get(cacheKey) + if (!batches) { + batches = [] + byKey.set(cacheKey, batches) } - return inFlight + return batches } -type Settle = (result: { shared: boolean } | { error: unknown }) => void +function isRecent(batch: Batch) { + return Date.now() < batch.startedAt + COALESCING.graceInterval +} -/* Marks a backing request for `cacheKey` as in flight. - * Call the returned function when the request finishes: - * it removes the request from `inFlight` and wakes its waiting callers. - */ -function startRequest( - inFlight: Map>, - cacheKey: string -): Settle { - let resolve!: (shared: boolean) => void - let reject!: (error: unknown) => void - const pending = new Promise((res, rej) => { - resolve = res - reject = rej - }) - /* The requesting caller rethrows the error itself. - * Without this handler, a failed request with no waiters is an unhandled rejection. - */ - pending.catch(() => undefined) - inFlight.set(cacheKey, pending) - return (result) => { - if (inFlight.get(cacheKey) === pending) inFlight.delete(cacheKey) - if ('error' in result) reject(result.error) - else resolve(result.shared) +/* Whether one more use of `plaintextLength` bytes fits the batch under the limits of both managers. */ +function canJoin( + cmm: CachingMaterialsManager, + batch: Batch, + plaintextLength: number +) { + if (!isRecent(batch)) return false + const next = { + response: undefined as any, + now: Date.now(), + messagesEncrypted: batch.messages + 1, + bytesEncrypted: batch.bytes + plaintextLength, + } + return ( + !cmm._cacheEntryHasExceededLimits(next) && + !batch.requester._cacheEntryHasExceededLimits(next) + ) +} + +function startBatch( + batches: Batch[], + requester: CachingMaterialsManager, + plaintextLength: number +): Batch { + const batch: Batch = { + startedAt: Date.now(), + messages: 1, + bytes: plaintextLength, + requester, + members: [], } + batches.push(batch) + return batch +} + +function closeBatch(batches: Batch[], batch: Batch) { + const index = batches.indexOf(batch) + if (index !== -1) batches.splice(index, 1) +} + +async function joinBatch( + batch: Batch, + plaintextLength: number, + waitUntil: number +): Promise> { + batch.messages += 1 + batch.bytes += plaintextLength + let timer: any + const outcome = await new Promise | undefined>((resolve) => { + timer = setTimeout(resolve, Math.max(0, waitUntil - Date.now())) + batch.members.push({ plaintextLength, settle: resolve }) + }) + clearTimeout(timer) + /* The caller's reserved use stays counted, which only lowers the data key's remaining uses. */ + needs(outcome, 'Caching materials manager inFlightTTL exceeded') + return outcome +} + +function settleMembers(batch: Batch, outcome: Outcome) { + for (const member of batch.members) member.settle(outcome) +} + +async function sleep(ms: number) { + if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms)) } export function cacheEntryHasExceededLimits< diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 1c3eafad1..b4bd71400 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -8,6 +8,7 @@ import { cacheEntryHasExceededLimits, getEncryptionMaterials, decryptMaterials, + COALESCING, } from '../src/caching_cryptographic_materials_decorators' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' @@ -47,19 +48,27 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( .digest() ) -// A backing materials manager that takes 5 ms per request -// and returns a new data key each time. +// Shortened timings, so the tests run in milliseconds. +const FAST = { graceInterval: 20, inFlightTTL: 100 } +const DEFAULT = { ...COALESCING } + +// A backing materials manager that takes 5 ms per request and returns a new data key each time. +// `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. function slowBackingMaterialsManager({ - fail = false, + outcomes = ['ok'] as ('ok' | 'fail' | 'hang')[], materialSuite = suite, } = {}) { const calls = { encrypt: 0, decrypt: 0, active: 0, peak: 0 } + let n = 0 const respond = async () => { + const outcome = outcomes[Math.min(n++, outcomes.length - 1)] + if (outcome === 'hang') await new Promise(() => undefined) calls.active += 1 calls.peak = Math.max(calls.peak, calls.active) await new Promise((resolve) => setTimeout(resolve, 5)) calls.active -= 1 - if (fail) throw new Error('backing materials manager unavailable') + if (outcome === 'fail') + throw new Error('backing materials manager unavailable') } return { calls, @@ -115,55 +124,110 @@ const decryptRequest = { encryptedDataKeys: [edk], } -async function encryptConcurrently(cmm: any, count: number, request: any) { - return Promise.all( - Array.from({ length: count }, async () => - cmm.getEncryptionMaterials(request) - ) +function startEncrypts(cmm: any, lengths: number[], request: any = {}) { + return lengths.map(async (plaintextLength) => + cmm.getEncryptionMaterials({ + ...encryptRequest, + ...request, + plaintextLength, + }) ) } +async function encryptConcurrently(cmm: any, lengths: number[], request?: any) { + return Promise.all(startEncrypts(cmm, lengths, request)) +} + +const times = (count: number, length = 1) => + Array.from({ length: count }, () => length) + // Messages and bytes encrypted under each distinct data key. -function usageByDataKey(materials: any[], plaintextLength: number) { +function usageByDataKey(materials: any[], lengths: number[]) { const usage = new Map() - for (const material of materials) { + materials.forEach((material, i) => { const dataKey = Buffer.from( unwrapDataKey(material.getUnencryptedDataKey()) ).toString('hex') const entry = usage.get(dataKey) || { messages: 0, bytes: 0 } entry.messages += 1 - entry.bytes += plaintextLength + entry.bytes += lengths[i] usage.set(dataKey, entry) - } + }) return [...usage.values()] } describe('caching materials manager: concurrent cache misses (#1665)', () => { + beforeEach(() => Object.assign(COALESCING, FAST)) + afterEach(() => Object.assign(COALESCING, DEFAULT)) + it('concurrent encrypts share data keys without exceeding maxMessagesEncrypted', async () => { const backing = slowBackingMaterialsManager() const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) - const materials = await encryptConcurrently(cmm, 10, encryptRequest) + const materials = await encryptConcurrently(cmm, times(10)) expect(backing.calls.encrypt).to.equal(2) - expect(usageByDataKey(materials, 1).map((u) => u.messages)).to.deep.equal([ - 5, 5, - ]) + expect(backing.calls.peak).to.equal(2) + expect( + usageByDataKey(materials, times(10)).map((u) => u.messages) + ).to.deep.equal([5, 5]) }) it('concurrent encrypts share data keys without exceeding maxBytesEncrypted', async () => { const backing = slowBackingMaterialsManager() const cmm = cachingCMM(backing, { maxBytesEncrypted: 10 }) - const materials = await encryptConcurrently(cmm, 10, { - ...encryptRequest, - plaintextLength: 3, - }) + const materials = await encryptConcurrently(cmm, times(10, 3)) expect(backing.calls.encrypt).to.equal(4) - expect(usageByDataKey(materials, 3).map((u) => u.bytes)).to.deep.equal([ - 9, 9, 9, 3, - ]) + expect( + usageByDataKey(materials, times(10, 3)).map((u) => u.bytes) + ).to.deep.equal([9, 9, 9, 3]) + }) + + it('a large burst runs its backing requests in parallel', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) + + const materials = await encryptConcurrently(cmm, times(100)) + + expect(backing.calls.encrypt).to.equal(20) + expect(backing.calls.peak).to.equal(20) + for (const { messages } of usageByDataKey(materials, times(100))) { + expect(messages).to.be.at.most(5) + } + }) + + it('a warm entry serves callers until its limit, then one request serves the rest', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) + const warm = await cmm.getEncryptionMaterials(encryptRequest) + + const materials = await encryptConcurrently(cmm, times(10)) + + expect(backing.calls.encrypt).to.equal(3) + for (const { messages } of usageByDataKey( + [warm, ...materials], + times(11) + )) { + expect(messages).to.be.at.most(5) + } + }) + + it('keeps every data key within its limits for mixed plaintext sizes', async () => { + const backing = slowBackingMaterialsManager() + const cmm = cachingCMM(backing, { + maxBytesEncrypted: 10, + maxMessagesEncrypted: 3, + }) + const lengths = [6, 3, 1, 11, 4, 4, 2, 11, 9, 1, 1, 5] + + const materials = await encryptConcurrently(cmm, lengths) + + for (const { messages, bytes } of usageByDataKey(materials, lengths)) { + expect(messages).to.be.at.most(3) + if (messages > 1) expect(bytes).to.be.at.most(10) + } }) it('concurrent decrypts share one backing request', async () => { @@ -179,46 +243,130 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.decrypt).to.equal(1) }) - it('fails every waiting caller on a failed request, and retries on the next call', async () => { - const backing = slowBackingMaterialsManager({ fail: true }) + it('serves every waiting caller even when the cache evicts the entry at once', async () => { + const backing = slowBackingMaterialsManager() + const cache = getLocalCryptographicMaterialsCache(1) + // A cache that deletes each entry one microtask after it is put. + const evictingCache = { + ...cache, + putEncryptionMaterial(...args: [string, any, number, number?]) { + cache.putEncryptionMaterial(...args) + queueMicrotask(() => cache.del(args[0])) + }, + } + const cmm = cachingCMM(backing, { cache: evictingCache }) + + const materials = await encryptConcurrently(cmm, times(10)) + + expect(materials).to.have.lengthOf(10) + expect(backing.calls.encrypt).to.equal(1) + }) + + it('waiting callers retry a failed request once', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) - const results = await Promise.allSettled( - Array.from({ length: 10 }, async () => - cmm.getEncryptionMaterials(encryptRequest) + const results = await Promise.allSettled(startEncrypts(cmm, times(10))) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(backing.calls.encrypt).to.equal(2) + }) + + it('a request that keeps failing reaches every caller after one retry', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail'] }) + const cmm = cachingCMM(backing) + + const results = await Promise.allSettled(startEncrypts(cmm, times(10))) + + for (const result of results) { + expect(result.status).to.equal('rejected') + expect((result as PromiseRejectedResult).reason.message).to.equal( + 'backing materials manager unavailable' ) - ) - expect(results.every((r) => r.status === 'rejected')).to.equal(true) + } + expect(backing.calls.encrypt).to.equal(2) + }) + + it('fails waiting callers after inFlightTTL when the request hangs', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['hang'] }) + const cmm = cachingCMM(backing) + + const [, ...waiters] = startEncrypts(cmm, times(5)) + const results = await Promise.allSettled(waiters) + + for (const result of results) { + expect((result as PromiseRejectedResult).reason.message).to.equal( + 'Caching materials manager inFlightTTL exceeded' + ) + } expect(backing.calls.encrypt).to.equal(1) + }) + + it('starts another request for new callers once one hangs past graceInterval', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) + const cmm = cachingCMM(backing) - await cmm.getEncryptionMaterials(encryptRequest).catch(() => undefined) + startEncrypts(cmm, times(1)) + await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) + const materials = await encryptConcurrently(cmm, times(5)) + + expect(materials).to.have.lengthOf(5) expect(backing.calls.encrypt).to.equal(2) }) - describe('requests run in parallel when the response is not cached', () => { + it('fails waiting callers when the response cannot be cached', async () => { + const malformed = { + async getEncryptionMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { suite } + }, + async decryptMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { suite } + }, + } + const cmm = cachingCMM(malformed) + + const encrypts = await Promise.allSettled(startEncrypts(cmm, times(3))) + const decrypts = await Promise.allSettled( + Array.from({ length: 3 }, async () => + cmm.decryptMaterials(decryptRequest) + ) + ) + + for (const result of [...encrypts, ...decrypts]) { + expect(result.status).to.equal('rejected') + } + }) + + describe('requests run in parallel when the response cannot serve another caller', () => { const uncached = { 'plaintextLength exceeds maxBytesEncrypted': { backing: {}, cmm: { maxBytesEncrypted: 10 }, - request: { ...encryptRequest, plaintextLength: 11 }, + length: 11, + request: {}, peak: 10, }, 'two plaintexts exceed maxBytesEncrypted': { backing: {}, cmm: { maxBytesEncrypted: 10 }, - request: { ...encryptRequest, plaintextLength: 6 }, + length: 6, + request: {}, peak: 10, }, 'maxMessagesEncrypted is 1': { backing: {}, cmm: { maxMessagesEncrypted: 1 }, - request: encryptRequest, + length: 1, + request: {}, peak: 10, }, 'the backing suite is not cache safe': { backing: { materialSuite: uncacheableSuite }, cmm: {}, - request: { encryptionContext: {}, plaintextLength: 1 }, + length: 1, + request: { suite: undefined }, // The first response reveals the suite is not cache safe, // so the other 9 requests start after it, in parallel. peak: 9, @@ -227,68 +375,24 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { for (const [ name, - { backing: options, cmm: limits, request, peak }, + { backing: options, cmm: limits, length, request, peak }, ] of Object.entries(uncached)) { it(name, async () => { const backing = slowBackingMaterialsManager(options) const cmm = cachingCMM(backing, limits) - const materials = await encryptConcurrently(cmm, 10, request) + const materials = await encryptConcurrently( + cmm, + times(10, length), + request + ) expect(backing.calls.encrypt).to.equal(10) expect(backing.calls.peak).to.equal(peak) - expect(usageByDataKey(materials, 1)).to.have.lengthOf(10) + expect(usageByDataKey(materials, times(10, length))).to.have.lengthOf( + 10 + ) }) } }) - - it('serves every caller when the entry is evicted while waiters resume', async () => { - const backing = slowBackingMaterialsManager() - // A cache that deletes each entry one microtask after it is put. - const cache = getLocalCryptographicMaterialsCache(1) - const evictingCache = { - ...cache, - putEncryptionMaterial(...args: [string, any, number, number?]) { - cache.putEncryptionMaterial(...args) - queueMicrotask(() => cache.del(args[0])) - }, - } - const cmm = cachingCMM(backing, { cache: evictingCache }) - - const materials = await encryptConcurrently(cmm, 10, encryptRequest) - - expect(materials).to.have.lengthOf(10) - for (const material of materials) { - expect(material.hasUnencryptedDataKey).to.equal(true) - } - }) - - it('fails waiting callers when the response cannot be cached', async () => { - const malformed = { - async getEncryptionMaterials() { - await new Promise((resolve) => setTimeout(resolve, 5)) - return { suite } - }, - async decryptMaterials() { - await new Promise((resolve) => setTimeout(resolve, 5)) - return { suite } - }, - } - const cmm = cachingCMM(malformed) - - const encrypts = await Promise.allSettled( - Array.from({ length: 3 }, async () => - cmm.getEncryptionMaterials(encryptRequest) - ) - ) - const decrypts = await Promise.allSettled( - Array.from({ length: 3 }, async () => - cmm.decryptMaterials(decryptRequest) - ) - ) - - for (const result of [...encrypts, ...decrypts]) { - expect(result.status).to.equal('rejected') - } - }) }) diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts new file mode 100644 index 000000000..cb893bee0 --- /dev/null +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NodeAlgorithmSuite } from '@aws-crypto/material-management' +import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' + +/* A port of the MPL's StormTracker (StormTracker.dfy), + * which backs the hierarchical keyring's default cache in every other ESDK. + * Times are in milliseconds; the values are the MPL's DefaultStorm(). + * Tests shorten them, so read them at call time. + */ +export const STORM_TRACKING = { + // Within this long of expiring, one caller per graceInterval refreshes the entry. + gracePeriod: 10 * 1000, + // After a fetch has run this long, the next caller may start another. + graceInterval: 1 * 1000, + // The most keys that can be fetched at once. + fanOut: 20, + // A caller that has waited this long fails. + inFlightTTL: 10 * 1000, + // How long a waiting caller sleeps before checking the cache again. + sleepMilli: 20, +} + +export type CacheState = 'use' | 'fetch' | 'wait' + +export class StormTracker { + // Cache entry id -> when its current fetch started. + private readonly inFlight = new Map() + private lastPrune = 0 + + /* The entry is in the cache and not expired. */ + checkEntry(id: string, expiresAt: number, now: number): CacheState { + if (this.fanOutReached(now)) return 'use' + if (!this.inGracePeriod(expiresAt, now)) return 'use' + const started = this.inFlight.get(id) + if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { + return 'use' + } + this.inFlight.set(id, now) + return 'fetch' + } + + /* The entry is missing or expired. */ + checkNewEntry(id: string, now: number): CacheState { + if (this.fanOutReached(now)) return 'wait' + const started = this.inFlight.get(id) + if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { + return 'wait' + } + this.inFlight.set(id, now) + return 'fetch' + } + + /* Call before putting a fetched entry in the cache. + * A failed fetch leaves its mark, so waiters retry after graceInterval instead of at once. + */ + fetched(id: string) { + this.inFlight.delete(id) + } + + private inGracePeriod(expiresAt: number, now: number) { + return expiresAt - STORM_TRACKING.gracePeriod <= now + } + + private fanOutReached(now: number) { + this.pruneInFlight(now) + return this.inFlight.size >= STORM_TRACKING.fanOut + } + + /* Drops fetches older than inFlightTTL, at most once a second, and only when fanOut is reached. */ + private pruneInFlight(now: number) { + if (this.inFlight.size < STORM_TRACKING.fanOut) return + if (now - 1000 < this.lastPrune) return + this.lastPrune = now + for (const [id, started] of this.inFlight) { + if (now >= started + STORM_TRACKING.inFlightTTL) this.inFlight.delete(id) + } + } +} + +/* One tracker per cache, so keyrings that share a cache also share fetches. + * The map lives in this module: two copies of this package loaded in one process + * keep separate trackers for the same cache. + */ +const trackers = new WeakMap< + CryptographicMaterialsCache, + StormTracker +>() + +export function stormTrackerFor( + cmc: CryptographicMaterialsCache +): StormTracker { + let tracker = trackers.get(cmc) + if (!tracker) { + tracker = new StormTracker() + trackers.set(cmc, tracker) + } + return tracker +} + +export async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index d5e6093c5..46282b6bd 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -34,6 +34,11 @@ import { } from './constants' import { BranchKeyIdSupplier } from '@aws-crypto/kms-keyring' import { serializeFactory, uuidv4Factory } from '@aws-crypto/serialize' +import { + sleep, + STORM_TRACKING, + stormTrackerFor, +} from './branch_key_storm_tracker' export const stringToUtf8Bytes = (input: string): Buffer => Buffer.from(input, 'utf-8') @@ -193,82 +198,96 @@ export async function getBranchKeyMaterials( branchKeyVersion?: string ): Promise { const { keyStore, cacheLimitTtl } = hKeyring + const tracker = stormTrackerFor(cmc) + const waitUntil = Date.now() + STORM_TRACKING.inFlightTTL - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) - //# from the underlying [cryptographic materials cache](../local-cryptographic-materials-cache.md). - const cacheEntry = cmc.getBranchKeyMaterial(cacheEntryId) - let branchKeyMaterials: NodeBranchKeyMaterial - // if the cache entry is false, branch key materials were not found - if (!cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry)) { - /* Concurrent misses for the same cache entry share one keystore request. */ - branchKeyMaterials = await ensureBranchKeyMaterialsInFlight( - cmc, - cacheEntryId, - async () => { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is not found or the cache entry is expired, the hierarchical keyring MUST attempt to obtain the branch key materials - //# by querying the backing branch keystore specified in the [retrieve OnEncrypt branch key materials](#query-branch-keystore-onencrypt) section. - //# If the keyring is not able to retrieve [branch key materials](../structures.md#branch-key-materials) - //# through the underlying cryptographic materials cache or - //# it no longer has access to them through the backing keystore, OnEncrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# Otherwise, OnEncrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# Otherwise, OnDecrypt MUST fail. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: - const materials = branchKeyVersion - ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) - : // The complice needs a line - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: - //# - the `branchKeyId` used in this operation - await keyStore.getActiveBranchKey(branchKeyId) - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# If the Keystore's GetActiveBranchKey operation succeeds - //# the keyring MUST put the returned branch key materials in the cache using the - //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt - //# If the Keystore's GetBranchKeyVersion operation succeeds - //# the keyring MUST put the returned branch key materials in the cache using the - //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). - cmc.putBranchKeyMaterial(cacheEntryId, materials, cacheLimitTtl) - - /* The cache zeroes `materials` when it evicts them, - * which can happen before waiting callers resume. - * Share a copy instead. - */ - return deepCopyBranchKeyMaterial(materials) - } - ) - } else { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. - + /* Concurrent callers for one entry share a single keystore fetch: + * the tracker tells one caller to fetch and the rest to wait and check the cache again. + */ + for (;;) { + const now = Date.now() //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. - branchKeyMaterials = cacheEntry.response + //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) + //# from the underlying [cryptographic materials cache](../local-cryptographic-materials-cache.md). + const cacheEntry = cmc.getBranchKeyMaterial(cacheEntryId) + // if the cache entry is false, branch key materials were not found + const state = + !cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry) + ? tracker.checkNewEntry(cacheEntryId, now) + : tracker.checkEntry( + cacheEntryId, + /* The MPL rejects a TTL within the grace period. + * Such entries are not refreshed early here instead. + */ + cacheLimitTtl > STORM_TRACKING.gracePeriod + ? cacheEntry.now + cacheLimitTtl + : Infinity, + now + ) + + if (cacheEntry && state === 'use') { + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + + /* Hand back a copy the cache can never touch. The CMC zeroes a material's + * buffer in place on eviction; callers read the branch key AFTER this await, + * so a concurrent eviction (overwrite, TTL, or tail) could otherwise zero the + * buffer mid-derivation. */ + return deepCopyBranchKeyMaterial(cacheEntry.response) + } + if (state === 'fetch') break + + needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') + await sleep(STORM_TRACKING.sleepMilli) } - /* Hand back a copy the cache can never touch. The CMC zeroes a material's - * buffer in place on eviction; callers read the branch key AFTER this await, - * so a concurrent eviction (overwrite, TTL, or tail) could otherwise zero the - * buffer mid-derivation. */ + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If this is NOT true, then we MUST treat the cache entry as expired. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If this is NOT true, then we MUST treat the cache entry as expired. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is not found or the cache entry is expired, the hierarchical keyring MUST attempt to obtain the branch key materials + //# by querying the backing branch keystore specified in the [retrieve OnEncrypt branch key materials](#query-branch-keystore-onencrypt) section. + //# If the keyring is not able to retrieve [branch key materials](../structures.md#branch-key-materials) + //# through the underlying cryptographic materials cache or + //# it no longer has access to them through the backing keystore, OnEncrypt MUST fail. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# Otherwise, OnEncrypt MUST fail. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# Otherwise, OnDecrypt MUST fail. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: + const branchKeyMaterials = branchKeyVersion + ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) + : // The complice needs a line + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: + //# - the `branchKeyId` used in this operation + await keyStore.getActiveBranchKey(branchKeyId) + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# If the Keystore's GetActiveBranchKey operation succeeds + //# the keyring MUST put the returned branch key materials in the cache using the + //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt + //# If the Keystore's GetBranchKeyVersion operation succeeds + //# the keyring MUST put the returned branch key materials in the cache using the + //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). + tracker.fetched(cacheEntryId) + cmc.putBranchKeyMaterial(cacheEntryId, branchKeyMaterials, cacheLimitTtl) + return deepCopyBranchKeyMaterial(branchKeyMaterials) } @@ -283,36 +302,6 @@ function deepCopyBranchKeyMaterial( ) } -/* Tracks in-flight keystore requests per cache, - * so keyrings that share a cache also share requests. - * A request leaves the map when it finishes; only the cache keeps results. - * Callers already waiting get a failed request's error; later calls make a new request. - */ -const branchKeyMaterialsInFlight = new WeakMap< - CryptographicMaterialsCache, - Map> ->() - -async function ensureBranchKeyMaterialsInFlight( - cmc: CryptographicMaterialsCache, - cacheEntryId: string, - fetch: () => Promise -): Promise { - const inFlight = - branchKeyMaterialsInFlight.get(cmc) || - new Map>() - if (!branchKeyMaterialsInFlight.has(cmc)) { - branchKeyMaterialsInFlight.set(cmc, inFlight) - } - - const existing = inFlight.get(cacheEntryId) - if (existing) return existing - - const pending = fetch().finally(() => inFlight.delete(cacheEntryId)) - inFlight.set(cacheEntryId, pending) - return pending -} - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# If the input [encryption materials](../structures.md#encryption-materials) do not contain a plaintext data key, //# OnEncrypt MUST generate a random plaintext data key, according to the key length defined in the [algorithm suite](../algorithm-suites.md#encryption-key-length). diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 695fba807..c9fb9d856 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -30,6 +30,7 @@ import { } from '@aws-crypto/branch-keystore-node' import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' +import { STORM_TRACKING } from '../src/branch_key_storm_tracker' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -151,26 +152,43 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// A keystore stub that takes 5 ms per request, -// so concurrent operations all miss the cache before the first request returns. +// Shortened storm-tracking timings, so the tests run in milliseconds. +const FAST_STORM = { + ...STORM_TRACKING, + graceInterval: 40, + inFlightTTL: 200, + sleepMilli: 2, +} +const DEFAULT_STORM = { ...STORM_TRACKING } + +// A keystore stub where each request takes `ms` milliseconds. +// `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. function slowKeyStore( - fail = false -): Sinon.SinonStubbedInstance { + ms = 5, + outcomes: ('ok' | 'fail' | 'hang')[] = ['ok'] +): Sinon.SinonStubbedInstance & { peak: () => number } { const material = new NodeBranchKeyMaterial( Buffer.alloc(32, 1), BRANCH_KEY_ID_A, v4(), {} ) - const keyStore = - stubKeyStore() as Sinon.SinonStubbedInstance + const keyStore = stubKeyStore() as any + let calls = 0 + let active = 0 + let peak = 0 const fetch = async () => { - await new Promise((resolve) => setTimeout(resolve, 5)) - if (fail) throw new Error('keystore unavailable') + const outcome = outcomes[Math.min(calls++, outcomes.length - 1)] + if (outcome === 'hang') return new Promise(() => undefined) + peak = Math.max(peak, ++active) + await new Promise((resolve) => setTimeout(resolve, ms)) + active -= 1 + if (outcome === 'fail') throw new Error('keystore unavailable') return deepCopyBranchKeyMaterial(material) } keyStore.getActiveBranchKey.callsFake(fetch) keyStore.getBranchKeyVersion.callsFake(fetch) + keyStore.peak = () => peak return keyStore } @@ -199,14 +217,31 @@ async function encryptConcurrently( ) } -describe('KmsHierarchicalKeyRingNode: concurrent branch key cache misses (#1663)', () => { +async function encryptSettled(hkr: KmsHierarchicalKeyRingNode, count: number) { + return Promise.allSettled(startEncrypts(hkr, count)) +} + +function startEncrypts(hkr: KmsHierarchicalKeyRingNode, count: number) { + return Array.from({ length: count }, async () => + hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) + ) +} + +describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { + beforeEach(() => Object.assign(STORM_TRACKING, FAST_STORM)) + afterEach(() => Object.assign(STORM_TRACKING, DEFAULT_STORM)) + it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onEncrypt misses into one keystore call`, async () => { + // Default timings: thousands of waiters slow the event loop past the short graceInterval. + Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onDecrypt misses into one keystore call`, async () => { + // Default timings: thousands of waiters slow the event loop past the short graceInterval. + Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) @@ -235,22 +270,104 @@ describe('KmsHierarchicalKeyRingNode: concurrent branch key cache misses (#1663) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) - it('fails every waiting operation on a failed request, and retries on the next call', async () => { - const keyStore = slowKeyStore(true) + it('waiting callers retry after graceInterval instead of failing with the first error', async () => { + const keyStore = slowKeyStore(5, ['fail', 'ok']) + const results = await encryptSettled(keyringFor(keyStore), 10) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + }) + + it('retries a failing keystore at most once per graceInterval until inFlightTTL', async () => { + const keyStore = slowKeyStore(5, ['fail']) + const results = await encryptSettled(keyringFor(keyStore), 10) + + for (const result of results) expect(result.status).to.equal('rejected') + const maxCalls = FAST_STORM.inFlightTTL / FAST_STORM.graceInterval + 1 + expect(keyStore.getActiveBranchKey.callCount).to.be.at.most(maxCalls) + }) + + it('starts another fetch when the first hangs past graceInterval', async () => { + const keyStore = slowKeyStore(5, ['hang', 'ok']) const hkr = keyringFor(keyStore) - const results = await Promise.allSettled( - Array.from({ length: 10 }, async () => - hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) + // The first caller's fetch never returns; the other 9 must not wait on it. + const [, ...waiters] = startEncrypts(hkr, 10) + const results = await Promise.allSettled(waiters) + + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + expect(results).to.have.lengthOf(9) + for (const result of results) expect(result.status).to.equal('fulfilled') + }) + + it('fails waiting callers after inFlightTTL when every fetch hangs', async () => { + const keyStore = slowKeyStore(5, ['hang']) + const hkr = keyringFor(keyStore) + // Callers told to fetch hang forever; every caller left waiting fails. + const failures: string[] = [] + for (const p of startEncrypts(hkr, 10)) { + p.catch((e) => failures.push(e.message)) + } + await new Promise((resolve) => + setTimeout(resolve, 2 * FAST_STORM.inFlightTTL) + ) + + const fetchers = keyStore.getActiveBranchKey.callCount + expect(fetchers).to.be.at.most( + FAST_STORM.inFlightTTL / FAST_STORM.graceInterval + 1 + ) + expect(failures).to.have.lengthOf(10 - fetchers) + for (const message of failures) { + expect(message).to.equal('Storm cache inFlightTTL exceeded') + } + }) + + it('refreshes an entry in its grace period with one fetch while others use it', async () => { + const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) + try { + const keyStore = slowKeyStore(20) + const hkr = keyringFor(keyStore) + await encryptConcurrently(hkr, 1) + clock.tick(TTL * 1000 - STORM_TRACKING.gracePeriod / 2) + + let finished = 0 + const all = Promise.all( + Array.from({ length: 10 }, async () => { + await hkr.onEncrypt( + new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A) + ) + finished += 1 + }) + ) + // The refresh takes 20 ms; the other 9 callers use the cached entry meanwhile. + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(finished).to.equal(9) + + await all + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + } finally { + clock.restore() + } + }) + + it('fetches at most fanOut keys at once', async () => { + STORM_TRACKING.fanOut = 2 + const keyStore = slowKeyStore(20) + const cache = getLocalCryptographicMaterialsCache(100) + const hKeyring = { + keyStore, + cacheLimitTtl: TTL * 1000, + cacheEntryHasExceededLimits: () => false, + } as any + + await Promise.all( + ['a', 'b', 'c', 'd'].map(async (id) => + getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') ) ) - expect(results.every((r) => r.status === 'rejected')).to.equal(true) - expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - await hkr - .onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) - .catch(() => undefined) - expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + expect(keyStore.getBranchKeyVersion.callCount).to.equal(4) + expect(keyStore.peak()).to.equal(2) }) }) From 866bb30e53ced90c5005e009d9fe7dcd10fcfea2 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Thu, 8 Oct 2026 22:00:16 +0000 Subject: [PATCH 07/38] docs: clarify storm tracking and batching comments --- ...hing_cryptographic_materials_decorators.ts | 38 ++++++++++--------- .../src/branch_key_storm_tracker.ts | 19 +++++----- .../src/kms_hkeyring_node_helpers.ts | 4 +- .../kms_hkeyring_node.concurrency.test.ts | 6 +-- 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 4cf7b7d90..c4b296208 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -22,12 +22,12 @@ import { } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -/* Timing for coalesced backing requests, from the MPL's storm-tracking cache defaults. - * Times are in milliseconds. - * Tests shorten them, so read them at call time. +/* Timing for coalesced backing requests, in milliseconds. + * The values match the MPL's storm-tracking cache defaults. + * Read them at call time: tests shorten them. */ export const COALESCING = { - // A request this old takes no new callers; the next caller starts another. + // A request this old accepts no new callers; the next caller starts its own. graceInterval: 1000, // A caller that has waited this long fails. inFlightTTL: 10 * 1000, @@ -123,10 +123,10 @@ export function getEncryptionMaterials({ const waitUntil = Date.now() + COALESCING.inFlightTTL let retried = false - /* Concurrent misses join an in-flight backing request that still has room in its limits. - * Each caller that joins reserves its use of the data key before the response arrives, - * so the data key is never used past maxMessagesEncrypted or maxBytesEncrypted. - * A caller that does not fit starts its own request, so requests run in parallel. + /* A caller that misses joins an in-flight backing request if its data key has room left. + * Joining reserves the caller's use before the response arrives, + * so a data key never goes past maxMessagesEncrypted or maxBytesEncrypted. + * A caller that does not fit starts its own request, in parallel with the others. */ for (;;) { const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) @@ -169,8 +169,8 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - /* A batch over its limits has no members: none could join it. - * The requester's own use exceeds the limits, as in cacheEncryptionMaterial. + /* Only a request whose own use exceeds the limits gets here, so no caller joined it. + * Like cacheEncryptionMaterial, return material over its limits without a clone. */ if (this._cacheEntryHasExceededLimits(used)) { settleMembers(batch, { uncached: true }) @@ -182,7 +182,7 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - /* Count every member's use against the entry this request just put. */ + /* Count each member's use against the entry just put, so later cache hits see it. */ for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -262,7 +262,7 @@ export function decryptMaterials({ const waitUntil = Date.now() + COALESCING.inFlightTTL let retried = false - /* Concurrent misses join the in-flight backing request. */ + /* A caller that misses joins the in-flight backing request. */ for (;;) { const entry = this._cache.getDecryptionMaterial(cacheKey) /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ @@ -277,7 +277,7 @@ export function decryptMaterials({ const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material /* A failed request is retried once, then its error reaches the caller. - * Decrypt requests always cache their response, so the outcome is a failure. + * A decrypt request has no uncached outcome, so here the outcome is a failure. */ if (retried) throw (outcome as Failed).error retried = true @@ -304,7 +304,7 @@ export function decryptMaterials({ } /* An in-flight backing request and the callers waiting on it. - * `messages` and `bytes` count the uses reserved by the requester and its members. + * `messages` and `bytes` total the uses reserved by the requester and its members. */ interface Batch { startedAt: number @@ -319,10 +319,10 @@ interface Failed { } type Outcome = { material: M } | { uncached: true } | Failed -/* Open requests per cache and cache key, +/* Open requests, by cache and then cache key, * so caching materials managers that share a cache also share requests. - * The map lives in this module: two copies of this package loaded in one process - * keep separate requests for the same cache. + * Two copies of this package in one process each keep their own map, + * so they do not share requests for the same cache. */ const batchesByCache = new WeakMap< CryptographicMaterialsCache, @@ -403,7 +403,9 @@ async function joinBatch( batch.members.push({ plaintextLength, settle: resolve }) }) clearTimeout(timer) - /* The caller's reserved use stays counted, which only lowers the data key's remaining uses. */ + /* A caller that times out leaves its reserved use counted. + * That can only leave the data key fewer uses, never more. + */ needs(outcome, 'Caching materials manager inFlightTTL exceeded') return outcome } diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index cb893bee0..9ea3ced0f 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -4,10 +4,10 @@ import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' -/* A port of the MPL's StormTracker (StormTracker.dfy), - * which backs the hierarchical keyring's default cache in every other ESDK. - * Times are in milliseconds; the values are the MPL's DefaultStorm(). - * Tests shorten them, so read them at call time. +/* A port of the MPL's StormTracker (StormTracker.dfy). + * The MPL-based ESDKs use it for the hierarchical keyring's default cache. + * Times are in milliseconds, and the values match the MPL's DefaultStorm(). + * Read them at call time: tests shorten them. */ export const STORM_TRACKING = { // Within this long of expiring, one caller per graceInterval refreshes the entry. @@ -29,7 +29,7 @@ export class StormTracker { private readonly inFlight = new Map() private lastPrune = 0 - /* The entry is in the cache and not expired. */ + /* For an entry that is in the cache and not expired. */ checkEntry(id: string, expiresAt: number, now: number): CacheState { if (this.fanOutReached(now)) return 'use' if (!this.inGracePeriod(expiresAt, now)) return 'use' @@ -41,7 +41,7 @@ export class StormTracker { return 'fetch' } - /* The entry is missing or expired. */ + /* For an entry that is missing or expired. */ checkNewEntry(id: string, now: number): CacheState { if (this.fanOutReached(now)) return 'wait' const started = this.inFlight.get(id) @@ -53,7 +53,8 @@ export class StormTracker { } /* Call before putting a fetched entry in the cache. - * A failed fetch leaves its mark, so waiters retry after graceInterval instead of at once. + * A failed fetch never calls this, so the entry stays marked + * and waiters retry after graceInterval instead of at once. */ fetched(id: string) { this.inFlight.delete(id) @@ -80,8 +81,8 @@ export class StormTracker { } /* One tracker per cache, so keyrings that share a cache also share fetches. - * The map lives in this module: two copies of this package loaded in one process - * keep separate trackers for the same cache. + * Two copies of this package in one process each keep their own trackers, + * so they do not share fetches for the same cache. */ const trackers = new WeakMap< CryptographicMaterialsCache, diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 46282b6bd..6bd158898 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -216,8 +216,8 @@ export async function getBranchKeyMaterials( ? tracker.checkNewEntry(cacheEntryId, now) : tracker.checkEntry( cacheEntryId, - /* The MPL rejects a TTL within the grace period. - * Such entries are not refreshed early here instead. + /* The MPL rejects a keyring whose TTL is within the grace period. + * Here, such a keyring skips the early refresh instead. */ cacheLimitTtl > STORM_TRACKING.gracePeriod ? cacheEntry.now + cacheLimitTtl diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index c9fb9d856..e0d3c1fbc 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -232,7 +232,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { afterEach(() => Object.assign(STORM_TRACKING, DEFAULT_STORM)) it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onEncrypt misses into one keystore call`, async () => { - // Default timings: thousands of waiters slow the event loop past the short graceInterval. + // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) @@ -240,7 +240,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { }) it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onDecrypt misses into one keystore call`, async () => { - // Default timings: thousands of waiters slow the event loop past the short graceInterval. + // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) @@ -303,7 +303,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { it('fails waiting callers after inFlightTTL when every fetch hangs', async () => { const keyStore = slowKeyStore(5, ['hang']) const hkr = keyringFor(keyStore) - // Callers told to fetch hang forever; every caller left waiting fails. + // Each caller told to fetch hangs forever; every other caller fails after inFlightTTL. const failures: string[] = [] for (const p of startEncrypts(hkr, 10)) { p.catch((e) => failures.push(e.message)) From cefbdf9161afbdd49e6faddc4e3d09011439b0f1 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 20:30:27 +0000 Subject: [PATCH 08/38] refactor: describe concurrent cache misses as sharing one backing request --- ...hing_cryptographic_materials_decorators.ts | 74 +++++++++++-------- ...c_materials_decorators.concurrency.test.ts | 8 +- .../src/branch_key_storm_tracker.ts | 45 ++++++----- .../src/kms_hkeyring_node_helpers.ts | 8 +- .../kms_hkeyring_node.concurrency.test.ts | 6 +- 5 files changed, 79 insertions(+), 62 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index c4b296208..0fe7b323b 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -22,12 +22,13 @@ import { } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -/* Timing for coalesced backing requests, in milliseconds. - * The values match the MPL's storm-tracking cache defaults. - * Read them at call time: tests shorten them. +/* Concurrent cache misses for the same entry share one backing request: + * one caller asks the backing materials manager and the rest wait for its answer (see Batch). + * These limits stop a slow or stuck request from holding up the waiting callers. + * Milliseconds, using the MPL storm-tracking cache defaults. Tests change them. */ -export const COALESCING = { - // A request this old accepts no new callers; the next caller starts its own. +export const SHARED_REQUESTS = { + // After a request has run this long, new callers stop waiting on it and make their own. graceInterval: 1000, // A caller that has waited this long fails. inFlightTTL: 10 * 1000, @@ -115,18 +116,17 @@ export function getEncryptionMaterials({ }) const fetch = async () => this._backingMaterialsManager - /* Strip any information about the plaintext from the backing request, - * because the resulting response may be used to encrypt multiple plaintexts. + /* No plaintextLength: the data key is cached and reused for other messages, + * so the backing request must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) const batches = openBatches>(this._cache, cacheKey) - const waitUntil = Date.now() + COALESCING.inFlightTTL + const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false - /* A caller that misses joins an in-flight backing request if its data key has room left. - * Joining reserves the caller's use before the response arrives, - * so a data key never goes past maxMessagesEncrypted or maxBytesEncrypted. - * A caller that does not fit starts its own request, in parallel with the others. + /* On a miss, share another caller's in-flight request instead of making a new one, + * as long as its data key can still encrypt this message within maxMessagesEncrypted and maxBytesEncrypted. + * Otherwise, make a new request; it runs in parallel with the others. */ for (;;) { const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) @@ -141,6 +141,7 @@ export function getEncryptionMaterials({ if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) if ('material' in outcome) return outcome.material + /* The response could not be cached, so it cannot be shared; make our own request. */ if ('uncached' in outcome) { return cacheEncryptionMaterial( this, @@ -149,10 +150,12 @@ export function getEncryptionMaterials({ plaintextLength ) } - /* A failed request is retried once, then its error reaches the caller. */ + /* The request we waited on failed. + * Try again once its graceInterval has passed; a second failure goes to the caller. + */ if (retried) throw outcome.error retried = true - await sleep(batch.startedAt + COALESCING.graceInterval - Date.now()) + await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } const batch = startBatch(batches, this, plaintextLength) @@ -169,8 +172,9 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - /* Only a request whose own use exceeds the limits gets here, so no caller joined it. - * Like cacheEncryptionMaterial, return material over its limits without a clone. + /* This message alone exceeds the limits (e.g. plaintext over maxBytesEncrypted), + * so nobody could have joined, and the data key must not be cached. + * Return it without a clone, as cacheEncryptionMaterial does. */ if (this._cacheEntryHasExceededLimits(used)) { settleMembers(batch, { uncached: true }) @@ -182,7 +186,9 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - /* Count each member's use against the entry just put, so later cache hits see it. */ + /* The new entry counts only this caller's message. + * Add the waiting callers' messages, so the entry's limits stay accurate for later cache hits. + */ for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -259,10 +265,12 @@ export function decryptMaterials({ request ) const batches = openBatches>(this._cache, cacheKey) - const waitUntil = Date.now() + COALESCING.inFlightTTL + const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false - /* A caller that misses joins the in-flight backing request. */ + /* On a miss, share another caller's in-flight request instead of making a new one. + * Decrypt materials have no reuse limits, so every caller can share the same request. + */ for (;;) { const entry = this._cache.getDecryptionMaterial(cacheKey) /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ @@ -276,12 +284,12 @@ export function decryptMaterials({ if (!batch) break const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material - /* A failed request is retried once, then its error reaches the caller. - * A decrypt request has no uncached outcome, so here the outcome is a failure. + /* The request we waited on failed (decrypt responses are always cached). + * Try again once its graceInterval has passed; a second failure goes to the caller. */ if (retried) throw (outcome as Failed).error retried = true - await sleep(batch.startedAt + COALESCING.graceInterval - Date.now()) + await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } const batch = startBatch(batches, this, 0) @@ -303,8 +311,9 @@ export function decryptMaterials({ } } -/* An in-flight backing request and the callers waiting on it. - * `messages` and `bytes` total the uses reserved by the requester and its members. +/* One request to the backing materials manager, plus the callers waiting for its answer. + * `messages` and `bytes` count what its data key will encrypt for all of them, + * so canJoin can keep the data key within its limits before it even exists. */ interface Batch { startedAt: number @@ -319,10 +328,8 @@ interface Failed { } type Outcome = { material: M } | { uncached: true } | Failed -/* Open requests, by cache and then cache key, - * so caching materials managers that share a cache also share requests. - * Two copies of this package in one process each keep their own map, - * so they do not share requests for the same cache. +/* In-flight batches by cache, then cache key. + * Keyed by cache so caching materials managers that share a cache also share requests. */ const batchesByCache = new WeakMap< CryptographicMaterialsCache, @@ -347,10 +354,13 @@ function openBatches( } function isRecent(batch: Batch) { - return Date.now() < batch.startedAt + COALESCING.graceInterval + return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } -/* Whether one more use of `plaintextLength` bytes fits the batch under the limits of both managers. */ +/* Whether this caller's message still fits in the batch's data key. + * The batch may belong to another caching materials manager on the same cache, + * so check both managers' limits. + */ function canJoin( cmm: CachingMaterialsManager, batch: Batch, @@ -403,8 +413,8 @@ async function joinBatch( batch.members.push({ plaintextLength, settle: resolve }) }) clearTimeout(timer) - /* A caller that times out leaves its reserved use counted. - * That can only leave the data key fewer uses, never more. + /* A caller that times out stays counted in the batch. + * The data key may then encrypt one message fewer than allowed; never one more. */ needs(outcome, 'Caching materials manager inFlightTTL exceeded') return outcome diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index b4bd71400..7986e9dbf 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -8,7 +8,7 @@ import { cacheEntryHasExceededLimits, getEncryptionMaterials, decryptMaterials, - COALESCING, + SHARED_REQUESTS, } from '../src/caching_cryptographic_materials_decorators' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' @@ -50,7 +50,7 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( // Shortened timings, so the tests run in milliseconds. const FAST = { graceInterval: 20, inFlightTTL: 100 } -const DEFAULT = { ...COALESCING } +const DEFAULT = { ...SHARED_REQUESTS } // A backing materials manager that takes 5 ms per request and returns a new data key each time. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. @@ -157,8 +157,8 @@ function usageByDataKey(materials: any[], lengths: number[]) { } describe('caching materials manager: concurrent cache misses (#1665)', () => { - beforeEach(() => Object.assign(COALESCING, FAST)) - afterEach(() => Object.assign(COALESCING, DEFAULT)) + beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) + afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) it('concurrent encrypts share data keys without exceeding maxMessagesEncrypted', async () => { const backing = slowBackingMaterialsManager() diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index 9ea3ced0f..7ab40f2d5 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -4,32 +4,38 @@ import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' -/* A port of the MPL's StormTracker (StormTracker.dfy). - * The MPL-based ESDKs use it for the hierarchical keyring's default cache. - * Times are in milliseconds, and the values match the MPL's DefaultStorm(). - * Read them at call time: tests shorten them. +/* Stops concurrent callers from all fetching the same branch key from the keystore. + * For a cache entry, the tracker tells each caller to + * use the cached entry, fetch it from the keystore, + * or wait and check the cache again because another caller is fetching it. + * + * Ported from the MPL's StormTracker.dfy, + * which the other ESDKs use by default for the hierarchical keyring. + * Timings are in milliseconds, with the MPL's defaults. Tests change them. */ export const STORM_TRACKING = { - // Within this long of expiring, one caller per graceInterval refreshes the entry. + // In the last 10 s before an entry expires, one caller refreshes it while the rest keep using it. gracePeriod: 10 * 1000, - // After a fetch has run this long, the next caller may start another. + // A fetch running longer than this may be stuck or have failed; the next caller fetches again. graceInterval: 1 * 1000, - // The most keys that can be fetched at once. + // At most this many different branch keys are fetched at once; callers for other keys wait. fanOut: 20, // A caller that has waited this long fails. inFlightTTL: 10 * 1000, - // How long a waiting caller sleeps before checking the cache again. + // How often a waiting caller checks the cache. sleepMilli: 20, } export type CacheState = 'use' | 'fetch' | 'wait' export class StormTracker { - // Cache entry id -> when its current fetch started. + // Cache entries being fetched, and when each fetch started. private readonly inFlight = new Map() private lastPrune = 0 - /* For an entry that is in the cache and not expired. */ + /* The entry is cached and not expired: use it, + * unless it is about to expire and nobody is refreshing it yet. + */ checkEntry(id: string, expiresAt: number, now: number): CacheState { if (this.fanOutReached(now)) return 'use' if (!this.inGracePeriod(expiresAt, now)) return 'use' @@ -41,7 +47,9 @@ export class StormTracker { return 'fetch' } - /* For an entry that is missing or expired. */ + /* The entry is missing or expired: fetch it, + * unless another caller started fetching it less than graceInterval ago. + */ checkNewEntry(id: string, now: number): CacheState { if (this.fanOutReached(now)) return 'wait' const started = this.inFlight.get(id) @@ -52,9 +60,9 @@ export class StormTracker { return 'fetch' } - /* Call before putting a fetched entry in the cache. - * A failed fetch never calls this, so the entry stays marked - * and waiters retry after graceInterval instead of at once. + /* Call when a fetch succeeds. + * A failed fetch does not call this, so waiting callers fetch again after graceInterval, + * one at a time, instead of all at once. */ fetched(id: string) { this.inFlight.delete(id) @@ -69,7 +77,9 @@ export class StormTracker { return this.inFlight.size >= STORM_TRACKING.fanOut } - /* Drops fetches older than inFlightTTL, at most once a second, and only when fanOut is reached. */ + /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. + * As in the MPL, only when fanOut is reached, and at most once a second. + */ private pruneInFlight(now: number) { if (this.inFlight.size < STORM_TRACKING.fanOut) return if (now - 1000 < this.lastPrune) return @@ -80,10 +90,7 @@ export class StormTracker { } } -/* One tracker per cache, so keyrings that share a cache also share fetches. - * Two copies of this package in one process each keep their own trackers, - * so they do not share fetches for the same cache. - */ +/* One tracker per cache, so keyrings that share a cache also share fetches. */ const trackers = new WeakMap< CryptographicMaterialsCache, StormTracker diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 6bd158898..8b23a0243 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -201,8 +201,8 @@ export async function getBranchKeyMaterials( const tracker = stormTrackerFor(cmc) const waitUntil = Date.now() + STORM_TRACKING.inFlightTTL - /* Concurrent callers for one entry share a single keystore fetch: - * the tracker tells one caller to fetch and the rest to wait and check the cache again. + /* Concurrent callers for the same branch key share one keystore fetch. + * The tracker tells one caller to fetch; the rest wait and check the cache again. */ for (;;) { const now = Date.now() @@ -216,8 +216,8 @@ export async function getBranchKeyMaterials( ? tracker.checkNewEntry(cacheEntryId, now) : tracker.checkEntry( cacheEntryId, - /* The MPL rejects a keyring whose TTL is within the grace period. - * Here, such a keyring skips the early refresh instead. + /* A TTL of gracePeriod or less would refresh on every call. + * The MPL rejects such keyrings; here they skip the early refresh instead. */ cacheLimitTtl > STORM_TRACKING.gracePeriod ? cacheEntry.now + cacheLimitTtl diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index e0d3c1fbc..59dcb1d4c 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -231,7 +231,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { beforeEach(() => Object.assign(STORM_TRACKING, FAST_STORM)) afterEach(() => Object.assign(STORM_TRACKING, DEFAULT_STORM)) - it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onEncrypt misses into one keystore call`, async () => { + it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() @@ -239,7 +239,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) - it(`coalesces ${CONCURRENT_OPERATIONS} concurrent onDecrypt misses into one keystore call`, async () => { + it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() @@ -259,7 +259,7 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { for (const pdk of recovered) expect(pdk).to.deep.equal(expectedPdk) }) - it('coalesces misses across keyrings that share a cache and partition', async () => { + it('keyrings that share a cache and partition share keystore calls', async () => { const keyStore = slowKeyStore() const cache = getLocalCryptographicMaterialsCache(100) const partitionId = v4() From e207f7d6b3c481bdb473ea8ad3827cd285e982b3 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 13:49:44 -0700 Subject: [PATCH 09/38] m --- ...hing_cryptographic_materials_decorators.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 0fe7b323b..7107f9849 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -22,16 +22,16 @@ import { } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -/* Concurrent cache misses for the same entry share one backing request: - * one caller asks the backing materials manager and the rest wait for its answer (see Batch). - * These limits stop a slow or stuck request from holding up the waiting callers. - * Milliseconds, using the MPL storm-tracking cache defaults. Tests change them. +/* If the cache is accessed concurrently from multiple threads, + * cache misses for the same entry will share one single backing request to the backing materials manager. + * One caller asks the backing materials manager, and the rest wait for its answer. + * These limits stop a slow or stuck request from holding up any waiting callers. */ export const SHARED_REQUESTS = { // After a request has run this long, new callers stop waiting on it and make their own. - graceInterval: 1000, + graceInterval: 1000, // milliseconds == 1 second // A caller that has waited this long fails. - inFlightTTL: 10 * 1000, + inFlightTTL: 10 * 1000, // milliseconds == 10 seconds } export function decorateProperties( @@ -116,7 +116,8 @@ export function getEncryptionMaterials({ }) const fetch = async () => this._backingMaterialsManager - /* No plaintextLength: the data key is cached and reused for other messages, + /* Drop plaintext length from this temp backing materials manager. + * The data key is cached and reused for other messages, * so the backing request must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) @@ -124,11 +125,11 @@ export function getEncryptionMaterials({ const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false - /* On a miss, share another caller's in-flight request instead of making a new one, - * as long as its data key can still encrypt this message within maxMessagesEncrypted and maxBytesEncrypted. - * Otherwise, make a new request; it runs in parallel with the others. - */ + for (;;) { + /* On a miss, share another caller's in-flight request instead of making a new one + * as long as its data key can still encrypt this message within maxMessagesEncrypted and maxBytesEncrypted. + */ const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ if (entry && !this._cacheEntryHasExceededLimits(entry)) { @@ -151,7 +152,8 @@ export function getEncryptionMaterials({ ) } /* The request we waited on failed. - * Try again once its graceInterval has passed; a second failure goes to the caller. + * Try again once its graceInterval has passed. + * A second failure will go to the caller. */ if (retried) throw outcome.error retried = true From f3a8760b8b49e92d898f3ae3695b327a334dcf48 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 20:53:23 +0000 Subject: [PATCH 10/38] docs: clarify shared request comments --- ...hing_cryptographic_materials_decorators.ts | 39 ++++++++++++------- .../src/kms_hkeyring_node_helpers.ts | 3 +- .../kms_hkeyring_node.concurrency.test.ts | 9 +++-- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 7107f9849..cb3b91482 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -22,8 +22,8 @@ import { } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -/* If the cache is accessed concurrently from multiple threads, - * cache misses for the same entry will share one single backing request to the backing materials manager. +/* If many encrypt or decrypt calls use the cache at the same time, + * cache misses for the same entry share one request to the backing materials manager. * One caller asks the backing materials manager, and the rest wait for its answer. * These limits stop a slow or stuck request from holding up any waiting callers. */ @@ -116,19 +116,19 @@ export function getEncryptionMaterials({ }) const fetch = async () => this._backingMaterialsManager - /* Drop plaintext length from this temp backing materials manager. - * The data key is cached and reused for other messages, - * so the backing request must not depend on this message's length. + /* Leave plaintextLength out of the request to the backing materials manager. + * The data key it returns is cached and reused for other messages, + * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) const batches = openBatches>(this._cache, cacheKey) const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false - for (;;) { /* On a miss, share another caller's in-flight request instead of making a new one * as long as its data key can still encrypt this message within maxMessagesEncrypted and maxBytesEncrypted. + * Otherwise, this caller makes its own request, in parallel with the others. */ const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ @@ -142,7 +142,9 @@ export function getEncryptionMaterials({ if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) if ('material' in outcome) return outcome.material - /* The response could not be cached, so it cannot be shared; make our own request. */ + /* The shared request's data key could not be cached (its suite is not cache safe), + * so it cannot be shared, and this caller makes its own request. + */ if ('uncached' in outcome) { return cacheEncryptionMaterial( this, @@ -151,9 +153,9 @@ export function getEncryptionMaterials({ plaintextLength ) } - /* The request we waited on failed. - * Try again once its graceInterval has passed. - * A second failure will go to the caller. + /* The request this caller waited on failed. + * Wait until that request is graceInterval old, then try once more. + * If the retry also fails, throw its error. */ if (retried) throw outcome.error retried = true @@ -286,8 +288,10 @@ export function decryptMaterials({ if (!batch) break const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material - /* The request we waited on failed (decrypt responses are always cached). - * Try again once its graceInterval has passed; a second failure goes to the caller. + /* The request this caller waited on failed. + * (Decrypt responses are always cached, so failure is the only other outcome.) + * Wait until that request is graceInterval old, then try once more. + * If the retry also fails, throw its error. */ if (retried) throw (outcome as Failed).error retried = true @@ -325,6 +329,9 @@ interface Batch { members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } +/* What a waiting caller gets when the shared request finishes: + * the material, `uncached` if the material cannot be shared, or the request's error. + */ interface Failed { error: unknown } @@ -355,6 +362,7 @@ function openBatches( return batches } +/* A request older than graceInterval may be stuck, so new callers stop sharing it. */ function isRecent(batch: Batch) { return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } @@ -402,6 +410,9 @@ function closeBatch(batches: Batch[], batch: Batch) { if (index !== -1) batches.splice(index, 1) } +/* Wait for the shared request's answer, counting this caller's message against its data key. + * Fails after inFlightTTL. + */ async function joinBatch( batch: Batch, plaintextLength: number, @@ -415,8 +426,8 @@ async function joinBatch( batch.members.push({ plaintextLength, settle: resolve }) }) clearTimeout(timer) - /* A caller that times out stays counted in the batch. - * The data key may then encrypt one message fewer than allowed; never one more. + /* A caller that times out was counted when it joined, and stays counted. + * So the data key may encrypt one message fewer than its limits allow, never one more. */ needs(outcome, 'Caching materials manager inFlightTTL exceeded') return outcome diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 8b23a0243..0325e7c81 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -216,7 +216,8 @@ export async function getBranchKeyMaterials( ? tracker.checkNewEntry(cacheEntryId, now) : tracker.checkEntry( cacheEntryId, - /* A TTL of gracePeriod or less would refresh on every call. + /* With a TTL of gracePeriod (10 s) or less, an entry is always about to expire, + * so it would be refreshed every graceInterval. * The MPL rejects such keyrings; here they skip the early refresh instead. */ cacheLimitTtl > STORM_TRACKING.gracePeriod diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 59dcb1d4c..66a4cc763 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -232,7 +232,8 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { afterEach(() => Object.assign(STORM_TRACKING, DEFAULT_STORM)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. + // Use the default timings: 3000 callers slow the event loop enough + // that the shortened graceInterval would expire and start a second fetch. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) @@ -240,7 +241,8 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // Default timings: 3000 waiters slow the event loop until a fetch outlasts the short graceInterval. + // Use the default timings: 3000 callers slow the event loop enough + // that the shortened graceInterval would expire and start a second fetch. Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) @@ -385,7 +387,8 @@ describe('getBranchKeyMaterials: eviction while a request settles', () => { it('returns an intact branch key when another put evicts the entry before callers resume', async () => { const cache = getLocalCryptographicMaterialsCache(1) - // Another operation's put lands one microtask after this request's put. + // Another branch key is cached one microtask after this one, + // which evicts and zeroes this entry in the one-entry cache. const racingCache = { ...cache, putBranchKeyMaterial( From 13ba7fa3edc1e01297210eb91e184ba8d0d5c14e Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 13:57:58 -0700 Subject: [PATCH 11/38] m --- .../src/caching_cryptographic_materials_decorators.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index cb3b91482..c8d606c7f 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -126,18 +126,18 @@ export function getEncryptionMaterials({ let retried = false for (;;) { - /* On a miss, share another caller's in-flight request instead of making a new one - * as long as its data key can still encrypt this message within maxMessagesEncrypted and maxBytesEncrypted. - * Otherwise, this caller makes its own request, in parallel with the others. - */ + /* If the cache has a valid entry, return it */ const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) - /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ if (entry && !this._cacheEntryHasExceededLimits(entry)) { return cloneResponse(entry.response) } else { this._cache.del(cacheKey) } + /* On a miss, share another caller's in-flight request instead of making a new one + * as long as its data key is still valid + * (i.e. is within maxMessagesEncrypted and maxBytesEncrypted). + */ const batch = batches.find((b) => canJoin(this, b, plaintextLength)) if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) From f5fcf98e221301780eeb64875838b1486c3f2fc5 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 21:01:35 +0000 Subject: [PATCH 12/38] docs: place shared request comments next to the code they describe --- ...hing_cryptographic_materials_decorators.ts | 63 +++++++++++++------ .../src/branch_key_storm_tracker.ts | 20 +++--- .../src/kms_hkeyring_node_helpers.ts | 5 +- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index c8d606c7f..63a93d7db 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -121,12 +121,15 @@ export function getEncryptionMaterials({ * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) + // Requests for this cache entry that other callers have in flight. const batches = openBatches>(this._cache, cacheKey) + // When this caller stops waiting on other callers' requests and fails. const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL + // Whether this caller has already retried after a shared request failed. let retried = false for (;;) { - /* If the cache has a valid entry, return it */ + /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) if (entry && !this._cacheEntryHasExceededLimits(entry)) { return cloneResponse(entry.response) @@ -137,11 +140,15 @@ export function getEncryptionMaterials({ /* On a miss, share another caller's in-flight request instead of making a new one * as long as its data key is still valid * (i.e. is within maxMessagesEncrypted and maxBytesEncrypted). + * If no request has room, stop waiting and make a new one below. */ const batch = batches.find((b) => canJoin(this, b, plaintextLength)) if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) + + // The shared request succeeded: use its data key. if ('material' in outcome) return outcome.material + /* The shared request's data key could not be cached (its suite is not cache safe), * so it cannot be shared, and this caller makes its own request. */ @@ -153,54 +160,63 @@ export function getEncryptionMaterials({ plaintextLength ) } + /* The request this caller waited on failed. - * Wait until that request is graceInterval old, then try once more. - * If the retry also fails, throw its error. + * If this caller already retried once, throw the error. */ if (retried) throw outcome.error + // Otherwise, wait until that request is graceInterval old, then check the cache again. retried = true await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } + // No request to share: make one, and let callers that arrive meanwhile share it. const batch = startBatch(batches, this, plaintextLength) try { const material = await fetch() + // New callers can no longer join; the ones already waiting get this result below. closeBatch(batches, batch) + + // A data key whose suite is not cache safe cannot be shared: + // waiting callers make their own requests. if (!material.suite.cacheSafe) { settleMembers(batch, { uncached: true }) return material } + + /* This message alone exceeds the limits (e.g. plaintext over maxBytesEncrypted), + * so nobody could have joined, and the data key must not be cached. + * Return it without a clone, as cacheEncryptionMaterial does. + */ const used = { response: material, now: Date.now(), messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - /* This message alone exceeds the limits (e.g. plaintext over maxBytesEncrypted), - * so nobody could have joined, and the data key must not be cached. - * Return it without a clone, as cacheEncryptionMaterial does. - */ if (this._cacheEntryHasExceededLimits(used)) { settleMembers(batch, { uncached: true }) return material } + + // Cache the data key, counting this caller's message. this._cache.putEncryptionMaterial( cacheKey, material, plaintextLength, this._maxAge ) - /* The new entry counts only this caller's message. - * Add the waiting callers' messages, so the entry's limits stay accurate for later cache hits. - */ + // Count the waiting callers' messages too, so later cache hits see the real usage. for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } + // Give each waiting caller its own copy of the material. for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) } return cloneResponse(material) } catch (error) { + // The request failed: waiting callers get the error and may retry. closeBatch(batches, batch) settleMembers(batch, { error }) throw error @@ -268,48 +284,59 @@ export function decryptMaterials({ this._partition, request ) + // Requests for this cache entry that other callers have in flight. const batches = openBatches>(this._cache, cacheKey) + // When this caller stops waiting on other callers' requests and fails. const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL + // Whether this caller has already retried after a shared request failed. let retried = false - /* On a miss, share another caller's in-flight request instead of making a new one. - * Decrypt materials have no reuse limits, so every caller can share the same request. - */ for (;;) { - const entry = this._cache.getDecryptionMaterial(cacheKey) /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + const entry = this._cache.getDecryptionMaterial(cacheKey) if (entry && !this._cacheEntryHasExceededLimits(entry)) { return cloneResponse(entry.response) } else { this._cache.del(cacheKey) } + /* On a miss, share another caller's in-flight request instead of making a new one. + * Decrypt materials have no reuse limits, so any recent request can be shared. + * If there is none, stop waiting and make a new one below. + */ const batch = batches.find(isRecent) if (!batch) break const outcome = await joinBatch(batch, 0, waitUntil) + + // The shared request succeeded: use its material. if ('material' in outcome) return outcome.material - /* The request this caller waited on failed. - * (Decrypt responses are always cached, so failure is the only other outcome.) - * Wait until that request is graceInterval old, then try once more. - * If the retry also fails, throw its error. + + /* The request this caller waited on failed + * (decrypt responses are always cached, so failure is the only other outcome). + * If this caller already retried once, throw the error. */ if (retried) throw (outcome as Failed).error + // Otherwise, wait until that request is graceInterval old, then check the cache again. retried = true await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } + // No request to share: make one, and let callers that arrive meanwhile share it. const batch = startBatch(batches, this, 0) try { const material = await this._backingMaterialsManager.decryptMaterials( request ) + // New callers can no longer join; the ones already waiting get this result below. closeBatch(batches, batch) this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) + // Give each waiting caller its own copy of the material. for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) } return cloneResponse(material) } catch (error) { + // The request failed: waiting callers get the error and may retry. closeBatch(batches, batch) settleMembers(batch, { error }) throw error diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index 7ab40f2d5..c6ee1d8f8 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -33,29 +33,32 @@ export class StormTracker { private readonly inFlight = new Map() private lastPrune = 0 - /* The entry is cached and not expired: use it, - * unless it is about to expire and nobody is refreshing it yet. - */ + /* For an entry that is cached and not expired. */ checkEntry(id: string, expiresAt: number, now: number): CacheState { + // Too many fetches in flight: use the cached entry, even if it is about to expire. if (this.fanOutReached(now)) return 'use' + // Not about to expire: use it. if (!this.inGracePeriod(expiresAt, now)) return 'use' + // About to expire, but another caller is already refreshing it: use it meanwhile. const started = this.inFlight.get(id) if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { return 'use' } + // About to expire and nobody is refreshing it: this caller refreshes it. this.inFlight.set(id, now) return 'fetch' } - /* The entry is missing or expired: fetch it, - * unless another caller started fetching it less than graceInterval ago. - */ + /* For an entry that is missing or expired. */ checkNewEntry(id: string, now: number): CacheState { + // Too many fetches in flight: wait for one to finish. if (this.fanOutReached(now)) return 'wait' + // Another caller started fetching it less than graceInterval ago: wait for that fetch. const started = this.inFlight.get(id) if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { return 'wait' } + // Nobody is fetching it, or that fetch may be stuck or have failed: this caller fetches it. this.inFlight.set(id, now) return 'fetch' } @@ -77,10 +80,9 @@ export class StormTracker { return this.inFlight.size >= STORM_TRACKING.fanOut } - /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. - * As in the MPL, only when fanOut is reached, and at most once a second. - */ + /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. */ private pruneInFlight(now: number) { + // As in the MPL, only when fanOut is reached, and at most once a second. if (this.inFlight.size < STORM_TRACKING.fanOut) return if (now - 1000 < this.lastPrune) return this.lastPrune = now diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 0325e7c81..b5b5a5772 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -199,10 +199,11 @@ export async function getBranchKeyMaterials( ): Promise { const { keyStore, cacheLimitTtl } = hKeyring const tracker = stormTrackerFor(cmc) + // When this caller stops waiting for other callers' fetches and fails. const waitUntil = Date.now() + STORM_TRACKING.inFlightTTL /* Concurrent callers for the same branch key share one keystore fetch. - * The tracker tells one caller to fetch; the rest wait and check the cache again. + * Each pass, the tracker tells this caller to use the cached entry, fetch it, or wait. */ for (;;) { const now = Date.now() @@ -239,8 +240,10 @@ export async function getBranchKeyMaterials( * buffer mid-derivation. */ return deepCopyBranchKeyMaterial(cacheEntry.response) } + // This caller fetches from the keystore below. if (state === 'fetch') break + // Another caller is fetching: check the cache again shortly, until inFlightTTL runs out. needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') await sleep(STORM_TRACKING.sleepMilli) } From 5ac6d25222d8992e761541bfc80678c8799d6081 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 21:07:45 +0000 Subject: [PATCH 13/38] docs: trim shared request comments --- ...hing_cryptographic_materials_decorators.ts | 57 ++++--------------- .../src/branch_key_storm_tracker.ts | 20 +++---- .../src/kms_hkeyring_node_helpers.ts | 8 +-- 3 files changed, 21 insertions(+), 64 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 63a93d7db..45bd26089 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -121,11 +121,8 @@ export function getEncryptionMaterials({ * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - // Requests for this cache entry that other callers have in flight. const batches = openBatches>(this._cache, cacheKey) - // When this caller stops waiting on other callers' requests and fails. const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL - // Whether this caller has already retried after a shared request failed. let retried = false for (;;) { @@ -137,21 +134,13 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - /* On a miss, share another caller's in-flight request instead of making a new one - * as long as its data key is still valid - * (i.e. is within maxMessagesEncrypted and maxBytesEncrypted). - * If no request has room, stop waiting and make a new one below. - */ + // If another caller's request has room for this message, wait for it instead of making a new one. const batch = batches.find((b) => canJoin(this, b, plaintextLength)) if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) - - // The shared request succeeded: use its data key. if ('material' in outcome) return outcome.material - /* The shared request's data key could not be cached (its suite is not cache safe), - * so it cannot be shared, and this caller makes its own request. - */ + // Its data key can't be cached (the suite isn't cache safe), so it can't be shared. if ('uncached' in outcome) { return cacheEncryptionMaterial( this, @@ -161,33 +150,25 @@ export function getEncryptionMaterials({ ) } - /* The request this caller waited on failed. - * If this caller already retried once, throw the error. - */ + // That request failed. Retry once, after it has had graceInterval to finish. if (retried) throw outcome.error - // Otherwise, wait until that request is graceInterval old, then check the cache again. retried = true await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } - // No request to share: make one, and let callers that arrive meanwhile share it. + // Callers that miss while this request runs can wait for it. const batch = startBatch(batches, this, plaintextLength) try { const material = await fetch() - // New callers can no longer join; the ones already waiting get this result below. closeBatch(batches, batch) - // A data key whose suite is not cache safe cannot be shared: - // waiting callers make their own requests. + // Can't cache it, so the waiting callers can't share it. if (!material.suite.cacheSafe) { settleMembers(batch, { uncached: true }) return material } - /* This message alone exceeds the limits (e.g. plaintext over maxBytesEncrypted), - * so nobody could have joined, and the data key must not be cached. - * Return it without a clone, as cacheEncryptionMaterial does. - */ + // This message alone is over the limits, so nobody joined, and it can't be cached. const used = { response: material, now: Date.now(), @@ -199,24 +180,21 @@ export function getEncryptionMaterials({ return material } - // Cache the data key, counting this caller's message. this._cache.putEncryptionMaterial( cacheKey, material, plaintextLength, this._maxAge ) - // Count the waiting callers' messages too, so later cache hits see the real usage. + // Count the waiting callers' messages against the cache entry too. for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } - // Give each waiting caller its own copy of the material. for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) } return cloneResponse(material) } catch (error) { - // The request failed: waiting callers get the error and may retry. closeBatch(batches, batch) settleMembers(batch, { error }) throw error @@ -284,11 +262,8 @@ export function decryptMaterials({ this._partition, request ) - // Requests for this cache entry that other callers have in flight. const batches = openBatches>(this._cache, cacheKey) - // When this caller stops waiting on other callers' requests and fails. const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL - // Whether this caller has already retried after a shared request failed. let retried = false for (;;) { @@ -300,43 +275,31 @@ export function decryptMaterials({ this._cache.del(cacheKey) } - /* On a miss, share another caller's in-flight request instead of making a new one. - * Decrypt materials have no reuse limits, so any recent request can be shared. - * If there is none, stop waiting and make a new one below. - */ + // If another caller has a request in flight, wait for it instead of making a new one. const batch = batches.find(isRecent) if (!batch) break const outcome = await joinBatch(batch, 0, waitUntil) - - // The shared request succeeded: use its material. if ('material' in outcome) return outcome.material - /* The request this caller waited on failed - * (decrypt responses are always cached, so failure is the only other outcome). - * If this caller already retried once, throw the error. - */ + // That request failed. Retry once, after it has had graceInterval to finish. if (retried) throw (outcome as Failed).error - // Otherwise, wait until that request is graceInterval old, then check the cache again. retried = true await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) } - // No request to share: make one, and let callers that arrive meanwhile share it. + // Callers that miss while this request runs can wait for it. const batch = startBatch(batches, this, 0) try { const material = await this._backingMaterialsManager.decryptMaterials( request ) - // New callers can no longer join; the ones already waiting get this result below. closeBatch(batches, batch) this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - // Give each waiting caller its own copy of the material. for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) } return cloneResponse(material) } catch (error) { - // The request failed: waiting callers get the error and may retry. closeBatch(batches, batch) settleMembers(batch, { error }) throw error diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index c6ee1d8f8..53c47cbd1 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -33,32 +33,29 @@ export class StormTracker { private readonly inFlight = new Map() private lastPrune = 0 - /* For an entry that is cached and not expired. */ + /* For a cached entry that hasn't expired. + * Use it, unless it's about to expire and nobody is refreshing it yet. + */ checkEntry(id: string, expiresAt: number, now: number): CacheState { - // Too many fetches in flight: use the cached entry, even if it is about to expire. if (this.fanOutReached(now)) return 'use' - // Not about to expire: use it. if (!this.inGracePeriod(expiresAt, now)) return 'use' - // About to expire, but another caller is already refreshing it: use it meanwhile. const started = this.inFlight.get(id) if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { return 'use' } - // About to expire and nobody is refreshing it: this caller refreshes it. this.inFlight.set(id, now) return 'fetch' } - /* For an entry that is missing or expired. */ + /* For an entry that's missing or expired. + * Fetch it, unless another caller started fetching it within the last graceInterval. + */ checkNewEntry(id: string, now: number): CacheState { - // Too many fetches in flight: wait for one to finish. if (this.fanOutReached(now)) return 'wait' - // Another caller started fetching it less than graceInterval ago: wait for that fetch. const started = this.inFlight.get(id) if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { return 'wait' } - // Nobody is fetching it, or that fetch may be stuck or have failed: this caller fetches it. this.inFlight.set(id, now) return 'fetch' } @@ -80,9 +77,10 @@ export class StormTracker { return this.inFlight.size >= STORM_TRACKING.fanOut } - /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. */ + /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. + * Like the MPL, only bother when fanOut is reached, and at most once a second. + */ private pruneInFlight(now: number) { - // As in the MPL, only when fanOut is reached, and at most once a second. if (this.inFlight.size < STORM_TRACKING.fanOut) return if (now - 1000 < this.lastPrune) return this.lastPrune = now diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index b5b5a5772..b48b653d0 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -199,12 +199,9 @@ export async function getBranchKeyMaterials( ): Promise { const { keyStore, cacheLimitTtl } = hKeyring const tracker = stormTrackerFor(cmc) - // When this caller stops waiting for other callers' fetches and fails. const waitUntil = Date.now() + STORM_TRACKING.inFlightTTL - /* Concurrent callers for the same branch key share one keystore fetch. - * Each pass, the tracker tells this caller to use the cached entry, fetch it, or wait. - */ + // Concurrent callers for the same branch key share one keystore fetch. for (;;) { const now = Date.now() //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt @@ -240,10 +237,9 @@ export async function getBranchKeyMaterials( * buffer mid-derivation. */ return deepCopyBranchKeyMaterial(cacheEntry.response) } - // This caller fetches from the keystore below. if (state === 'fetch') break - // Another caller is fetching: check the cache again shortly, until inFlightTTL runs out. + // Someone else is fetching it. Check the cache again shortly. needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') await sleep(STORM_TRACKING.sleepMilli) } From 33d4f2874ce246573c869654d226df3d97d93263 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 15:57:34 -0700 Subject: [PATCH 14/38] fix(kms-keyring-node): make grace period opt-in and share fetch errors Early refresh of cached branch keys is now an opt-in gracePeriod keyring option (seconds, default 0). With the default, a cached branch key is used until it expires and the keystore is not called, matching the behavior before storm tracking. When a keystore fetch fails, callers waiting on it now fail right away with the keystore's error instead of timing out after inFlightTTL with a generic error. Failed fetches no longer count toward fanOut, so failing branch keys cannot block fetches for other branch keys. --- .../src/branch_key_storm_tracker.ts | 52 +++++++--- .../kms-keyring-node/src/kms_hkeyring_node.ts | 25 +++++ .../src/kms_hkeyring_node_helpers.ts | 55 +++++++---- .../kms_hkeyring_node.concurrency.test.ts | 98 ++++++++++++++++--- .../kms_hkeyring_node.constructor.test.ts | 53 ++++++++++ 5 files changed, 237 insertions(+), 46 deletions(-) diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index 53c47cbd1..a49fe1611 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -12,11 +12,10 @@ import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' * Ported from the MPL's StormTracker.dfy, * which the other ESDKs use by default for the hierarchical keyring. * Timings are in milliseconds, with the MPL's defaults. Tests change them. + * The grace period (refreshing an entry before it expires) is a keyring option, off by default. */ export const STORM_TRACKING = { - // In the last 10 s before an entry expires, one caller refreshes it while the rest keep using it. - gracePeriod: 10 * 1000, - // A fetch running longer than this may be stuck or have failed; the next caller fetches again. + // A fetch running longer than this may be stuck; the next caller fetches again. graceInterval: 1 * 1000, // At most this many different branch keys are fetched at once; callers for other keys wait. fanOut: 20, @@ -32,13 +31,21 @@ export class StormTracker { // Cache entries being fetched, and when each fetch started. private readonly inFlight = new Map() private lastPrune = 0 + // The latest failed fetch for each cache entry, so callers waiting on it get its error. + private readonly failures = new Map() + private lastFailurePrune = 0 /* For a cached entry that hasn't expired. - * Use it, unless it's about to expire and nobody is refreshing it yet. + * Use it, unless it's within gracePeriod of expiring and nobody is refreshing it yet. */ - checkEntry(id: string, expiresAt: number, now: number): CacheState { + checkEntry( + id: string, + expiresAt: number, + gracePeriod: number, + now: number + ): CacheState { if (this.fanOutReached(now)) return 'use' - if (!this.inGracePeriod(expiresAt, now)) return 'use' + if (now < expiresAt - gracePeriod) return 'use' const started = this.inFlight.get(id) if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { return 'use' @@ -60,16 +67,26 @@ export class StormTracker { return 'fetch' } - /* Call when a fetch succeeds. - * A failed fetch does not call this, so waiting callers fetch again after graceInterval, - * one at a time, instead of all at once. - */ + /* Call when a fetch succeeds. */ fetched(id: string) { this.inFlight.delete(id) + this.failures.delete(id) + } + + /* Call when a fetch fails. + * Callers waiting on it fail with the same error instead of timing out, + * and the entry stops counting toward fanOut, so the next caller can fetch right away. + */ + failed(id: string, error: unknown, now: number) { + this.inFlight.delete(id) + this.pruneFailures(now) + this.failures.set(id, { at: now, error }) } - private inGracePeriod(expiresAt: number, now: number) { - return expiresAt - STORM_TRACKING.gracePeriod <= now + /* The error of a fetch for this entry that failed at or after `since`, if any. */ + failureSince(id: string, since: number) { + const failure = this.failures.get(id) + return failure && since <= failure.at ? failure : undefined } private fanOutReached(now: number) { @@ -88,6 +105,17 @@ export class StormTracker { if (now >= started + STORM_TRACKING.inFlightTTL) this.inFlight.delete(id) } } + + /* Forget failures older than inFlightTTL. Every caller that could have waited on them has given up. + * At most once a second. + */ + private pruneFailures(now: number) { + if (now - 1000 < this.lastFailurePrune) return + this.lastFailurePrune = now + for (const [id, { at }] of this.failures) { + if (now >= at + STORM_TRACKING.inFlightTTL) this.failures.delete(id) + } + } } /* One tracker per cache, so keyrings that share a cache also share fetches. */ diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index f0a5ad8c2..c74cad7f4 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -75,6 +75,12 @@ export interface KmsHierarchicalKeyRingNodeInput { //# - MAY provide a [Partition ID](#partition-id) partitionId?: string utf8Sorting?: boolean + /* Seconds before a cached branch key expires during which one caller refreshes it + * from the keystore while other callers keep using the cached key. + * Defaults to 0: cached branch keys are never refreshed early. + * Must be less than cacheLimitTtl. + */ + gracePeriod?: number } export interface IKmsHierarchicalKeyRingNode extends KeyringNode { @@ -82,6 +88,8 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { branchKeyIdSupplier?: Readonly keyStore: Readonly cacheLimitTtl: number + // Milliseconds. + gracePeriod?: number _onEncrypt(material: NodeEncryptionMaterial): Promise _onDecrypt( material: NodeDecryptionMaterial, @@ -102,6 +110,7 @@ export class KmsHierarchicalKeyRingNode public declare keyStore: Readonly public declare _logicalKeyStoreName: Buffer public declare cacheLimitTtl: number + public declare gracePeriod: number public declare maxCacheSize?: number public declare _cmc: CryptographicMaterialsCache declare readonly _partition: Buffer @@ -116,6 +125,7 @@ export class KmsHierarchicalKeyRingNode maxCacheSize, partitionId, utf8Sorting, + gracePeriod, }: KmsHierarchicalKeyRingNodeInput) { super() @@ -216,6 +226,21 @@ export class KmsHierarchicalKeyRingNode // convert seconds to milliseconds readOnlyProperty(this, 'cacheLimitTtl', cacheLimitTtl * 1000) + /* Precondition: The grace period must be a number */ + needs( + gracePeriod === undefined || typeof gracePeriod === 'number', + 'The grace period must be a number' + ) + /* Precondition: The grace period must be non-negative and less than the cache limit TTL */ + needs( + gracePeriod === undefined || + gracePeriod === 0 || + (0 < gracePeriod && gracePeriod < cacheLimitTtl), + 'The grace period must be non-negative and less than the cache limit TTL' + ) + // convert seconds to milliseconds + readOnlyProperty(this, 'gracePeriod', (gracePeriod || 0) * 1000) + readOnlyProperty(this, 'branchKeyId', branchKeyId) readOnlyProperty( diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index b48b653d0..7276fe3a9 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -198,8 +198,12 @@ export async function getBranchKeyMaterials( branchKeyVersion?: string ): Promise { const { keyStore, cacheLimitTtl } = hKeyring + // Milliseconds. 0 (the default) never refreshes an entry before it expires. + const gracePeriod = hKeyring.gracePeriod || 0 const tracker = stormTrackerFor(cmc) - const waitUntil = Date.now() + STORM_TRACKING.inFlightTTL + const startedAt = Date.now() + const waitUntil = startedAt + STORM_TRACKING.inFlightTTL + let waited = false // Concurrent callers for the same branch key share one keystore fetch. for (;;) { @@ -209,20 +213,22 @@ export async function getBranchKeyMaterials( //# from the underlying [cryptographic materials cache](../local-cryptographic-materials-cache.md). const cacheEntry = cmc.getBranchKeyMaterial(cacheEntryId) // if the cache entry is false, branch key materials were not found - const state = + const missing = !cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry) - ? tracker.checkNewEntry(cacheEntryId, now) - : tracker.checkEntry( - cacheEntryId, - /* With a TTL of gracePeriod (10 s) or less, an entry is always about to expire, - * so it would be refreshed every graceInterval. - * The MPL rejects such keyrings; here they skip the early refresh instead. - */ - cacheLimitTtl > STORM_TRACKING.gracePeriod - ? cacheEntry.now + cacheLimitTtl - : Infinity, - now - ) + // If the fetch this caller waited on failed, fail with its error. + const failure = + waited && missing && tracker.failureSince(cacheEntryId, startedAt) + if (failure) throw failure.error + const state = missing + ? tracker.checkNewEntry(cacheEntryId, now) + : gracePeriod > 0 + ? tracker.checkEntry( + cacheEntryId, + cacheEntry.now + cacheLimitTtl, + gracePeriod, + now + ) + : 'use' if (cacheEntry && state === 'use') { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt @@ -240,6 +246,7 @@ export async function getBranchKeyMaterials( if (state === 'fetch') break // Someone else is fetching it. Check the cache again shortly. + waited = true needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') await sleep(STORM_TRACKING.sleepMilli) } @@ -268,13 +275,19 @@ export async function getBranchKeyMaterials( //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: - const branchKeyMaterials = branchKeyVersion - ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) - : // The complice needs a line - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt - //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: - //# - the `branchKeyId` used in this operation - await keyStore.getActiveBranchKey(branchKeyId) + let branchKeyMaterials: NodeBranchKeyMaterial + try { + branchKeyMaterials = branchKeyVersion + ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) + : // The complice needs a line + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt + //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: + //# - the `branchKeyId` used in this operation + await keyStore.getActiveBranchKey(branchKeyId) + } catch (error) { + tracker.failed(cacheEntryId, error, Date.now()) + throw error + } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt //# If the Keystore's GetActiveBranchKey operation succeeds diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 66a4cc763..12b176c62 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -30,7 +30,7 @@ import { } from '@aws-crypto/branch-keystore-node' import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' -import { STORM_TRACKING } from '../src/branch_key_storm_tracker' +import { STORM_TRACKING, StormTracker } from '../src/branch_key_storm_tracker' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -195,7 +195,8 @@ function slowKeyStore( function keyringFor( keyStore: BranchKeyStoreNode, cache?: KmsHierarchicalKeyRingNode['_cmc'], - partitionId?: string + partitionId?: string, + gracePeriod?: number ): KmsHierarchicalKeyRingNode { return new KmsHierarchicalKeyRingNode({ branchKeyIdSupplier: BRANCH_KEY_ID_SUPPLIER, @@ -203,6 +204,7 @@ function keyringFor( cacheLimitTtl: TTL, cache, partitionId, + gracePeriod, }) } @@ -272,21 +274,44 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) - it('waiting callers retry after graceInterval instead of failing with the first error', async () => { + it('waiting callers fail right away with the keystore error', async () => { const keyStore = slowKeyStore(5, ['fail', 'ok']) - const results = await encryptSettled(keyringFor(keyStore), 10) + const hkr = keyringFor(keyStore) + const started = Date.now() + const results = await encryptSettled(hkr, 10) + + expect(Date.now() - started).to.be.below(FAST_STORM.graceInterval) + for (const result of results) { + expect((result as PromiseRejectedResult).reason.message).to.equal( + 'keystore unavailable' + ) + } + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + // The next caller fetches again right away. + await encryptConcurrently(hkr, 1) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) - it('retries a failing keystore at most once per graceInterval until inFlightTTL', async () => { - const keyStore = slowKeyStore(5, ['fail']) - const results = await encryptSettled(keyringFor(keyStore), 10) + it('failed fetches do not count toward fanOut', async () => { + STORM_TRACKING.fanOut = 2 + const keyStore = slowKeyStore(5, ['fail', 'fail', 'ok']) + const cache = getLocalCryptographicMaterialsCache(100) + const hKeyring = { + keyStore, + cacheLimitTtl: TTL * 1000, + cacheEntryHasExceededLimits: () => false, + } as any + const fetch = async (id: string) => + getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') + + const failed = await Promise.allSettled([fetch('bad-1'), fetch('bad-2')]) + for (const result of failed) expect(result.status).to.equal('rejected') - for (const result of results) expect(result.status).to.equal('rejected') - const maxCalls = FAST_STORM.inFlightTTL / FAST_STORM.graceInterval + 1 - expect(keyStore.getActiveBranchKey.callCount).to.be.at.most(maxCalls) + const started = Date.now() + await fetch('good') + expect(Date.now() - started).to.be.below(FAST_STORM.graceInterval) + expect(keyStore.getBranchKeyVersion.callCount).to.equal(3) }) it('starts another fetch when the first hangs past graceInterval', async () => { @@ -324,13 +349,29 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { } }) - it('refreshes an entry in its grace period with one fetch while others use it', async () => { + it('does not refresh an entry before it expires by default', async () => { const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) try { const keyStore = slowKeyStore(20) const hkr = keyringFor(keyStore) await encryptConcurrently(hkr, 1) - clock.tick(TTL * 1000 - STORM_TRACKING.gracePeriod / 2) + clock.tick(TTL * 1000 - 1) + + await encryptConcurrently(hkr, 10) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + } finally { + clock.restore() + } + }) + + it('with a gracePeriod, refreshes an entry in its grace period with one fetch while others use it', async () => { + const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) + try { + const keyStore = slowKeyStore(20) + const gracePeriod = 10 // seconds + const hkr = keyringFor(keyStore, undefined, undefined, gracePeriod) + await encryptConcurrently(hkr, 1) + clock.tick(TTL * 1000 - (gracePeriod * 1000) / 2) let finished = 0 const all = Promise.all( @@ -373,6 +414,37 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { }) }) +describe('StormTracker: failures', () => { + it('reports a failure only to callers that started waiting before it', () => { + const tracker = new StormTracker() + const error = new Error('nope') + tracker.failed('id', error, 100) + + expect(tracker.failureSince('id', 100)?.error).to.equal(error) + expect(tracker.failureSince('id', 101)).to.equal(undefined) + expect(tracker.failureSince('other', 0)).to.equal(undefined) + }) + + it('a successful fetch clears the failure', () => { + const tracker = new StormTracker() + tracker.failed('id', new Error('nope'), 100) + tracker.fetched('id') + expect(tracker.failureSince('id', 0)).to.equal(undefined) + }) + + it('forgets failures older than inFlightTTL, at most once a second', () => { + const tracker = new StormTracker() + const ttl = STORM_TRACKING.inFlightTTL + tracker.failed('old', new Error('old'), 2000) // prunes (nothing to prune yet) + tracker.failed('other', new Error('other'), 2500) // within a second: skips pruning + expect(tracker.failureSince('old', 0)).to.not.equal(undefined) + // 'old' is now older than inFlightTTL and gets pruned; 'other' is not yet. + tracker.failed('newest', new Error('newest'), 2000 + ttl + 1) + expect(tracker.failureSince('old', 0)).to.equal(undefined) + expect(tracker.failureSince('other', 0)).to.not.equal(undefined) + }) +}) + describe('getBranchKeyMaterials: eviction while a request settles', () => { const material = (fill: number) => new NodeBranchKeyMaterial(Buffer.alloc(32, fill), BRANCH_KEY_ID_A, v4(), {}) diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts index cf95070b1..d0716458e 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts @@ -160,6 +160,59 @@ describe('KmsHierarchicalKeyRingNode: constructor', () => { //# The maximum amount of time in seconds that an entry within the cache may be used before it MUST be evicted. //# The client MUST set a time-to-live (TTL) for [branch key materials](../structures.md#branch-key-materials) in the underlying cache. //# This value MUST be greater than zero. + it('The grace period defaults to 0 and is stored in milliseconds', () => { + expect( + new KmsHierarchicalKeyRingNode({ branchKeyId, keyStore, cacheLimitTtl }) + .gracePeriod + ).to.equal(0) + expect( + new KmsHierarchicalKeyRingNode({ + branchKeyId, + keyStore, + cacheLimitTtl: 60, + gracePeriod: 10, + }).gracePeriod + ).to.equal(10 * 1000) + expect( + new KmsHierarchicalKeyRingNode({ + branchKeyId, + keyStore, + cacheLimitTtl: 0, + gracePeriod: 0, + }).gracePeriod + ).to.equal(0) + }) + + it('Precondition: The grace period must be a number', () => { + for (const gracePeriod of ['1', true, {}, [], null]) { + expect( + () => + new KmsHierarchicalKeyRingNode({ + branchKeyId, + keyStore, + cacheLimitTtl, + gracePeriod: gracePeriod as any, + }) + ).to.throw('The grace period must be a number') + } + }) + + it('Precondition: The grace period must be non-negative and less than the cache limit TTL', () => { + for (const gracePeriod of [-1, 60, 61, NaN, Infinity]) { + expect( + () => + new KmsHierarchicalKeyRingNode({ + branchKeyId, + keyStore, + cacheLimitTtl: 60, + gracePeriod, + }) + ).to.throw( + 'The grace period must be non-negative and less than the cache limit TTL' + ) + } + }) + it('Precondition: Cache limit TTL must be non-negative and less than or equal to (Number.MAX_SAFE_INTEGER / 1000) seconds', () => { expect( new KmsHierarchicalKeyRingNode({ From c87f3c112ca57cd2bf972038ebccfaf99a25d60e Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Fri, 9 Oct 2026 15:57:34 -0700 Subject: [PATCH 15/38] fix(cache-material): forget finished shared requests Remove a cache key's in-flight list once it is empty, and drop stuck requests nobody can join, so the map does not grow with every cache key ever used. Move SHARED_REQUESTS and the in-flight map to an internal module that the package index does not export. --- ...hing_cryptographic_materials_decorators.ts | 160 ++++++++++-------- modules/cache-material/src/shared_requests.ts | 27 +++ ...c_materials_decorators.concurrency.test.ts | 44 ++++- 3 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 modules/cache-material/src/shared_requests.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 45bd26089..babf49fb8 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -21,18 +21,7 @@ import { Entry, } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' - -/* If many encrypt or decrypt calls use the cache at the same time, - * cache misses for the same entry share one request to the backing materials manager. - * One caller asks the backing materials manager, and the rest wait for its answer. - * These limits stop a slow or stuck request from holding up any waiting callers. - */ -export const SHARED_REQUESTS = { - // After a request has run this long, new callers stop waiting on it and make their own. - graceInterval: 1000, // milliseconds == 1 second - // A caller that has waited this long fails. - inFlightTTL: 10 * 1000, // milliseconds == 10 seconds -} +import { SHARED_REQUESTS, batchesByCache } from './shared_requests' export function decorateProperties( obj: CachingMaterialsManager, @@ -121,7 +110,6 @@ export function getEncryptionMaterials({ * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - const batches = openBatches>(this._cache, cacheKey) const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false @@ -135,7 +123,11 @@ export function getEncryptionMaterials({ } // If another caller's request has room for this message, wait for it instead of making a new one. - const batch = batches.find((b) => canJoin(this, b, plaintextLength)) + const batch = findBatch>( + this._cache, + cacheKey, + (b) => canJoin(this, b, plaintextLength) + ) if (!batch) break const outcome = await joinBatch(batch, plaintextLength, waitUntil) if ('material' in outcome) return outcome.material @@ -157,10 +149,15 @@ export function getEncryptionMaterials({ } // Callers that miss while this request runs can wait for it. - const batch = startBatch(batches, this, plaintextLength) + const batch = startBatch>( + this._cache, + cacheKey, + this, + plaintextLength + ) try { const material = await fetch() - closeBatch(batches, batch) + closeBatch(this._cache, cacheKey, batch) // Can't cache it, so the waiting callers can't share it. if (!material.suite.cacheSafe) { @@ -195,7 +192,7 @@ export function getEncryptionMaterials({ } return cloneResponse(material) } catch (error) { - closeBatch(batches, batch) + closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { error }) throw error } @@ -208,37 +205,35 @@ function cacheEncryptionMaterial( material: EncryptionMaterial, plaintextLength: number ): EncryptionMaterial { - { - /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ - if (!material.suite.cacheSafe) return material - - /* It is possible for an entry to exceed limits immediately. - * The simplest case is to need to encrypt more than then maxBytesEncrypted. - * In this case, I return the response to encrypt the data, - * but do not put a know invalid item into the cache. + /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ + if (!material.suite.cacheSafe) return material + + /* It is possible for an entry to exceed limits immediately. + * The simplest case is to need to encrypt more than then maxBytesEncrypted. + * In this case, I return the response to encrypt the data, + * but do not put a know invalid item into the cache. + */ + const testEntry = { + response: material, + now: Date.now(), + messagesEncrypted: 1, + bytesEncrypted: plaintextLength, + } + if (!cmm._cacheEntryHasExceededLimits(testEntry)) { + cmm._cache.putEncryptionMaterial( + cacheKey, + material, + plaintextLength, + cmm._maxAge + ) + return cloneResponse(material) + } else { + /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. + * If it is cloned, and the clone is returned, + * then there exist a copy of the unencrypted data key. + * It is true that this data would be caught by GC, it is better to just not rely on that. */ - const testEntry = { - response: material, - now: Date.now(), - messagesEncrypted: 1, - bytesEncrypted: plaintextLength, - } - if (!cmm._cacheEntryHasExceededLimits(testEntry)) { - cmm._cache.putEncryptionMaterial( - cacheKey, - material, - plaintextLength, - cmm._maxAge - ) - return cloneResponse(material) - } else { - /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. - * If it is cloned, and the clone is returned, - * then there exist a copy of the unencrypted data key. - * It is true that this data would be caught by GC, it is better to just not rely on that. - */ - return material - } + return material } } @@ -262,7 +257,6 @@ export function decryptMaterials({ this._partition, request ) - const batches = openBatches>(this._cache, cacheKey) const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL let retried = false @@ -276,7 +270,11 @@ export function decryptMaterials({ } // If another caller has a request in flight, wait for it instead of making a new one. - const batch = batches.find(isRecent) + const batch = findBatch>( + this._cache, + cacheKey, + isRecent + ) if (!batch) break const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material @@ -288,19 +286,24 @@ export function decryptMaterials({ } // Callers that miss while this request runs can wait for it. - const batch = startBatch(batches, this, 0) + const batch = startBatch>( + this._cache, + cacheKey, + this, + 0 + ) try { const material = await this._backingMaterialsManager.decryptMaterials( request ) - closeBatch(batches, batch) + closeBatch(this._cache, cacheKey, batch) this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) } return cloneResponse(material) } catch (error) { - closeBatch(batches, batch) + closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { error }) throw error } @@ -327,29 +330,16 @@ interface Failed { } type Outcome = { material: M } | { uncached: true } | Failed -/* In-flight batches by cache, then cache key. - * Keyed by cache so caching materials managers that share a cache also share requests. +/* Look batches up by cache and key every time instead of holding on to a list, + * because closeBatch deletes a key's list once it is empty. */ -const batchesByCache = new WeakMap< - CryptographicMaterialsCache, - Map[]> ->() - -function openBatches( +function findBatch( cache: CryptographicMaterialsCache, - cacheKey: string -): Batch[] { - let byKey = batchesByCache.get(cache) - if (!byKey) { - byKey = new Map() - batchesByCache.set(cache, byKey) - } - let batches = byKey.get(cacheKey) - if (!batches) { - batches = [] - byKey.set(cacheKey, batches) - } - return batches + cacheKey: string, + predicate: (batch: Batch) => boolean +): Batch | undefined { + const batches = batchesByCache.get(cache)?.get(cacheKey) + return batches?.find(predicate) } /* A request older than graceInterval may be stuck, so new callers stop sharing it. */ @@ -380,10 +370,21 @@ function canJoin( } function startBatch( - batches: Batch[], + cache: CryptographicMaterialsCache, + cacheKey: string, requester: CachingMaterialsManager, plaintextLength: number ): Batch { + let byKey = batchesByCache.get(cache) + if (!byKey) { + byKey = new Map() + batchesByCache.set(cache, byKey) + } + /* Drop batches nobody can join anymore. + * A request that never settles never calls closeBatch, so this is what forgets it. + */ + const batches = (byKey.get(cacheKey) || []).filter(isRecent) + byKey.set(cacheKey, batches) const batch: Batch = { startedAt: Date.now(), messages: 1, @@ -395,9 +396,18 @@ function startBatch( return batch } -function closeBatch(batches: Batch[], batch: Batch) { +/* Remove the batch, and the cache key's list once it is empty, so the map does not grow forever. */ +function closeBatch( + cache: CryptographicMaterialsCache, + cacheKey: string, + batch: Batch +) { + const byKey = batchesByCache.get(cache) + const batches = byKey?.get(cacheKey) + if (!byKey || !batches) return const index = batches.indexOf(batch) if (index !== -1) batches.splice(index, 1) + if (!batches.length) byKey.delete(cacheKey) } /* Wait for the shared request's answer, counting this caller's message against its data key. diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts new file mode 100644 index 000000000..57d177665 --- /dev/null +++ b/modules/cache-material/src/shared_requests.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CryptographicMaterialsCache } from './cryptographic_materials_cache' + +/* If many encrypt or decrypt calls use the cache at the same time, + * cache misses for the same entry share one request to the backing materials manager. + * One caller asks the backing materials manager, and the rest wait for its answer. + * These limits stop a slow or stuck request from holding up any waiting callers. + * + * Internal: not exported from the package index. Tests change these values. + */ +export const SHARED_REQUESTS = { + // After a request has run this long, new callers stop waiting on it and make their own. + graceInterval: 1000, // milliseconds == 1 second + // A caller that has waited this long fails. + inFlightTTL: 10 * 1000, // milliseconds == 10 seconds +} + +/* In-flight batches (one request plus its waiting callers) by cache, then cache key. + * Keyed by cache so caching materials managers that share a cache also share requests. + * A cache key is removed once it has no batches, so this does not grow with every key ever used. + */ +export const batchesByCache = new WeakMap< + CryptographicMaterialsCache, + Map +>() diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 7986e9dbf..22949fc9e 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -8,8 +8,9 @@ import { cacheEntryHasExceededLimits, getEncryptionMaterials, decryptMaterials, - SHARED_REQUESTS, } from '../src/caching_cryptographic_materials_decorators' +import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' +import * as cacheMaterial from '../src/index' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' import { createHash, randomBytes } from 'crypto' @@ -339,6 +340,47 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { } }) + it('forgets a cache key once its requests finish', async () => { + const backing = slowBackingMaterialsManager() + const cache = getLocalCryptographicMaterialsCache(100) + const cmm = cachingCMM(backing, { cache, maxMessagesEncrypted: 2 }) + + await encryptConcurrently(cmm, times(5)) + await Promise.all( + Array.from({ length: 5 }, async () => + cmm.decryptMaterials(decryptRequest) + ) + ) + await Promise.allSettled( + startEncrypts( + cachingCMM(slowBackingMaterialsManager({ outcomes: ['fail'] }), { + cache, + }), + times(3), + { encryptionContext: { a: 'b' } } + ) + ) + + expect(batchesByCache.get(cache)?.size).to.equal(0) + }) + + it('forgets a request that never settles once a newer one starts', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) + const cache = getLocalCryptographicMaterialsCache(100) + const cmm = cachingCMM(backing, { cache }) + + startEncrypts(cmm, times(1)) + await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) + await encryptConcurrently(cmm, times(3)) + + expect(batchesByCache.get(cache)?.size).to.equal(0) + }) + + it('does not export the shared request internals from the package', () => { + expect(cacheMaterial).to.not.have.property('SHARED_REQUESTS') + expect(cacheMaterial).to.not.have.property('batchesByCache') + }) + describe('requests run in parallel when the response cannot serve another caller', () => { const uncached = { 'plaintextLength exceeds maxBytesEncrypted': { From 1dd154f6ce0d7149cbb8cf36c7aab6acbbded272 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 10:35:53 -0700 Subject: [PATCH 16/38] fix(cache-material): keep default behavior, add opt-in stormTracking Concurrent cache misses still share one backing request by default, but sharing no longer adds new failures or waits: - No timeout for waiting callers and no limit on requests in flight. - If the shared request fails, each waiting caller makes its own request, as it would without sharing. The new stormTracking option (graceInterval, fanOut, inFlightTTL, in milliseconds) opts in to MPL StormTracking-style behavior. A caller that times out is removed from the shared request, so the request no longer clones the data key for it or counts its message. --- ...hing_cryptographic_materials_decorators.ts | 273 +++++++++++++----- modules/cache-material/src/shared_requests.ts | 72 ++++- ...c_materials_decorators.concurrency.test.ts | 176 +++++++++-- ...cryptographic_materials_decorators.test.ts | 76 +++++ 4 files changed, 496 insertions(+), 101 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index babf49fb8..018810297 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -21,7 +21,12 @@ import { Entry, } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -import { SHARED_REQUESTS, batchesByCache } from './shared_requests' +import { + SHARED_REQUESTS, + SharingSettings, + batchesByCache, + resolveStormTracking, +} from './shared_requests' export function decorateProperties( obj: CachingMaterialsManager, @@ -34,6 +39,7 @@ export function decorateProperties( maxBytesEncrypted, maxMessagesEncrypted, partition, + stormTracking, } = input /* Precondition: A caching material manager needs a cache. */ @@ -76,6 +82,7 @@ export function decorateProperties( maxMessagesEncrypted || Maximum.MESSAGES_PER_CACHED_KEY_LIMIT ) readOnlyProperty(obj, '_partition', partition) + readOnlyProperty(obj, '_stormTracking', resolveStormTracking(stormTracking)) } export function getEncryptionMaterials({ @@ -110,8 +117,8 @@ export function getEncryptionMaterials({ * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL - let retried = false + const settings = sharingSettings(this) + const waitUntil = Date.now() + settings.inFlightTTL for (;;) { /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ @@ -126,14 +133,22 @@ export function getEncryptionMaterials({ const batch = findBatch>( this._cache, cacheKey, - (b) => canJoin(this, b, plaintextLength) + (b) => canJoin(this, settings, b, plaintextLength) ) - if (!batch) break + if (!batch) { + /* Check synchronously, so no other caller can start a request for this key in between. */ + if (!fanOutReached(this._cache, settings, waitUntil)) break + await sleep(SHARED_REQUESTS.sleepMilli) + continue + } const outcome = await joinBatch(batch, plaintextLength, waitUntil) if ('material' in outcome) return outcome.material - // Its data key can't be cached (the suite isn't cache safe), so it can't be shared. - if ('uncached' in outcome) { + /* Make this caller's own request, as it would without sharing, if + * the shared data key can't be cached (the suite isn't cache safe), or + * the shared request failed and storm tracking is off. + */ + if ('uncached' in outcome || !settings.stormTracking) { return cacheEncryptionMaterial( this, cacheKey, @@ -142,10 +157,8 @@ export function getEncryptionMaterials({ ) } - // That request failed. Retry once, after it has had graceInterval to finish. - if (retried) throw outcome.error - retried = true - await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) + // With storm tracking, try again once the failed request is graceInterval old, like the MPL. + await sleep(batch.startedAt + settings.graceInterval - Date.now()) } // Callers that miss while this request runs can wait for it. @@ -153,49 +166,56 @@ export function getEncryptionMaterials({ this._cache, cacheKey, this, + settings, plaintextLength ) + let material: EncryptionMaterial try { - const material = await fetch() + material = await fetch() + } catch (error) { closeBatch(this._cache, cacheKey, batch) + settleMembers(batch, { error }) + throw error + } + closeBatch(this._cache, cacheKey, batch) - // Can't cache it, so the waiting callers can't share it. - if (!material.suite.cacheSafe) { - settleMembers(batch, { uncached: true }) - return material - } + // Can't cache it, so the waiting callers can't share it. + if (!material.suite.cacheSafe) { + settleMembers(batch, { uncached: true }) + return material + } - // This message alone is over the limits, so nobody joined, and it can't be cached. - const used = { - response: material, - now: Date.now(), - messagesEncrypted: batch.messages, - bytesEncrypted: batch.bytes, - } - if (this._cacheEntryHasExceededLimits(used)) { - settleMembers(batch, { uncached: true }) - return material - } + // This message alone is over the limits, so nobody joined, and it can't be cached. + const used = { + response: material, + now: Date.now(), + messagesEncrypted: batch.messages, + bytesEncrypted: batch.bytes, + } + if (this._cacheEntryHasExceededLimits(used)) { + settleMembers(batch, { uncached: true }) + return material + } + try { this._cache.putEncryptionMaterial( cacheKey, material, plaintextLength, this._maxAge ) - // Count the waiting callers' messages against the cache entry too. - for (const member of batch.members) { - this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) - } - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) - } - return cloneResponse(material) } catch (error) { - closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { error }) throw error } + // Count the waiting callers' messages against the cache entry too. + for (const member of batch.members) { + this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + } + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) } } @@ -257,8 +277,15 @@ export function decryptMaterials({ this._partition, request ) - const waitUntil = Date.now() + SHARED_REQUESTS.inFlightTTL - let retried = false + const fetchAndCache = async () => { + const material = await this._backingMaterialsManager.decryptMaterials( + request + ) + this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) + return material + } + const settings = sharingSettings(this) + const waitUntil = Date.now() + settings.inFlightTTL for (;;) { /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ @@ -273,16 +300,22 @@ export function decryptMaterials({ const batch = findBatch>( this._cache, cacheKey, - isRecent + (b) => isRecent(settings, b) ) - if (!batch) break + if (!batch) { + /* Check synchronously, so no other caller can start a request for this key in between. */ + if (!fanOutReached(this._cache, settings, waitUntil)) break + await sleep(SHARED_REQUESTS.sleepMilli) + continue + } const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material - // That request failed. Retry once, after it has had graceInterval to finish. - if (retried) throw (outcome as Failed).error - retried = true - await sleep(batch.startedAt + SHARED_REQUESTS.graceInterval - Date.now()) + // The shared request failed. Without storm tracking, make this caller's own request. + if (!settings.stormTracking) return cloneResponse(await fetchAndCache()) + + // With storm tracking, try again once the failed request is graceInterval old, like the MPL. + await sleep(batch.startedAt + settings.graceInterval - Date.now()) } // Callers that miss while this request runs can wait for it. @@ -290,26 +323,43 @@ export function decryptMaterials({ this._cache, cacheKey, this, + settings, 0 ) + let material: DecryptionMaterial try { - const material = await this._backingMaterialsManager.decryptMaterials( - request - ) - closeBatch(this._cache, cacheKey, batch) - this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) - } - return cloneResponse(material) + material = await fetchAndCache() } catch (error) { closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { error }) throw error } + closeBatch(this._cache, cacheKey, batch) + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) } } +const IN_FLIGHT_TTL_EXCEEDED = 'Caching materials manager inFlightTTL exceeded' + +/* The caching materials manager's sharing settings. + * Without stormTracking: no fanOut limit and no timeout for waiting callers. + */ +function sharingSettings( + cmm: CachingMaterialsManager +): Readonly { + return ( + cmm._stormTracking || { + stormTracking: false, + graceInterval: SHARED_REQUESTS.graceInterval, + fanOut: Infinity, + inFlightTTL: Infinity, + } + ) +} + /* One request to the backing materials manager, plus the callers waiting for its answer. * `messages` and `bytes` count what its data key will encrypt for all of them, * so canJoin can keep the data key within its limits before it even exists. @@ -319,16 +369,18 @@ interface Batch { messages: number bytes: number requester: CachingMaterialsManager - members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] + members: Member[] +} + +interface Member { + plaintextLength: number + settle: (outcome: Outcome) => void } /* What a waiting caller gets when the shared request finishes: * the material, `uncached` if the material cannot be shared, or the request's error. */ -interface Failed { - error: unknown -} -type Outcome = { material: M } | { uncached: true } | Failed +type Outcome = { material: M } | { uncached: true } | { error: unknown } /* Look batches up by cache and key every time instead of holding on to a list, * because closeBatch deletes a key's list once it is empty. @@ -343,8 +395,8 @@ function findBatch( } /* A request older than graceInterval may be stuck, so new callers stop sharing it. */ -function isRecent(batch: Batch) { - return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval +function isRecent(settings: SharingSettings, batch: Batch) { + return Date.now() < batch.startedAt + settings.graceInterval } /* Whether this caller's message still fits in the batch's data key. @@ -353,10 +405,11 @@ function isRecent(batch: Batch) { */ function canJoin( cmm: CachingMaterialsManager, + settings: SharingSettings, batch: Batch, plaintextLength: number ) { - if (!isRecent(batch)) return false + if (!isRecent(settings, batch)) return false const next = { response: undefined as any, now: Date.now(), @@ -369,10 +422,41 @@ function canJoin( ) } +/* With storm tracking, whether fanOut requests are already in flight on this cache. + * A caller that has waited past inFlightTTL for one to finish fails. + */ +function fanOutReached( + cache: CryptographicMaterialsCache, + settings: SharingSettings, + waitUntil: number +): boolean { + if (inFlightCount(cache, settings) < settings.fanOut) return false + needs(Date.now() <= waitUntil, IN_FLIGHT_TTL_EXCEEDED) + return true +} + +/* Requests in flight on this cache, for any cache key. + * Like the MPL, a request older than inFlightTTL no longer counts. + */ +function inFlightCount( + cache: CryptographicMaterialsCache, + settings: SharingSettings +) { + const now = Date.now() + let count = 0 + for (const batches of batchesByCache.get(cache)?.values() || []) { + for (const batch of batches as Batch[]) { + if (now < batch.startedAt + settings.inFlightTTL) count += 1 + } + } + return count +} + function startBatch( cache: CryptographicMaterialsCache, cacheKey: string, requester: CachingMaterialsManager, + settings: SharingSettings, plaintextLength: number ): Batch { let byKey = batchesByCache.get(cache) @@ -383,7 +467,9 @@ function startBatch( /* Drop batches nobody can join anymore. * A request that never settles never calls closeBatch, so this is what forgets it. */ - const batches = (byKey.get(cacheKey) || []).filter(isRecent) + const batches = (byKey.get(cacheKey) || []).filter((b) => + isRecent(settings, b) + ) byKey.set(cacheKey, batches) const batch: Batch = { startedAt: Date.now(), @@ -411,7 +497,7 @@ function closeBatch( } /* Wait for the shared request's answer, counting this caller's message against its data key. - * Fails after inFlightTTL. + * With storm tracking, fails once waitUntil passes. */ async function joinBatch( batch: Batch, @@ -420,16 +506,24 @@ async function joinBatch( ): Promise> { batch.messages += 1 batch.bytes += plaintextLength - let timer: any + let timer: ReturnType | undefined const outcome = await new Promise | undefined>((resolve) => { - timer = setTimeout(resolve, Math.max(0, waitUntil - Date.now())) - batch.members.push({ plaintextLength, settle: resolve }) + const member = { plaintextLength, settle: resolve } + batch.members.push(member) + if (waitUntil === Infinity) return + timer = setTimeout(() => { + /* A caller that times out never gets the data key, + * so the request no longer clones it for this caller or counts its message. + */ + const index = batch.members.indexOf(member) + if (index !== -1) batch.members.splice(index, 1) + batch.messages -= 1 + batch.bytes -= plaintextLength + resolve(undefined) + }, Math.max(0, waitUntil - Date.now())) }) - clearTimeout(timer) - /* A caller that times out was counted when it joined, and stays counted. - * So the data key may encrypt one message fewer than its limits allow, never one more. - */ - needs(outcome, 'Caching materials manager inFlightTTL exceeded') + if (timer) clearTimeout(timer) + needs(outcome, IN_FLIGHT_TTL_EXCEEDED) return outcome } @@ -481,8 +575,41 @@ export interface CachingMaterialsManagerInput< maxBytesEncrypted?: number maxMessagesEncrypted?: number maxAge: number + /* Opt in to behaving like the MPL's StormTracking cache. + * See CachingMaterialsManagerStormTracking. + */ + stormTracking?: CachingMaterialsManagerStormTracking }> {} +/* Opt-in settings that make the caching materials manager behave like + * the MPL's StormTracking cache. Times are in milliseconds, like maxAge. + * + * Without stormTracking, concurrent cache misses still share one request, + * but a caller never fails or waits in a way it would not without sharing: + * there is no limit on requests in flight, no timeout for waiting callers, + * and if the shared request fails, each waiting caller makes its own request. + * This differs from the MPL so that existing customers see no change in behavior. + * A future major version may align these defaults with the MPL. + * + * With stormTracking, unset settings take the MPL's defaults. + */ +export interface CachingMaterialsManagerStormTracking { + /* After a request has run this long, new callers stop waiting on it and make their own. + * If a request fails, waiting callers try again once it is this old. + * Default 1000 (1 second). + */ + graceInterval?: number + /* At most this many requests to the backing materials manager are in flight at once, + * across every entry in the cache. Other callers wait. + * Default 20. + */ + fanOut?: number + /* A caller that has waited this long fails. + * Default 10000 (10 seconds). Must be at least graceInterval. + */ + inFlightTTL?: number +} + export interface CachingMaterialsManagerDecorateInput< S extends SupportedAlgorithmSuites > extends CachingMaterialsManagerInput { @@ -498,6 +625,8 @@ export interface CachingMaterialsManager readonly _maxBytesEncrypted: number readonly _maxMessagesEncrypted: number readonly _maxAge: number + // Set by decorateProperties when the stormTracking input is given. + readonly _stormTracking?: Readonly _cacheEntryHasExceededLimits: CacheEntryHasExceededLimits } diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 57d177665..19004cbea 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -1,20 +1,84 @@ // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { needs } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from './cryptographic_materials_cache' +import type { CachingMaterialsManagerStormTracking } from './caching_cryptographic_materials_decorators' /* If many encrypt or decrypt calls use the cache at the same time, * cache misses for the same entry share one request to the backing materials manager. * One caller asks the backing materials manager, and the rest wait for its answer. - * These limits stop a slow or stuck request from holding up any waiting callers. * * Internal: not exported from the package index. Tests change these values. */ export const SHARED_REQUESTS = { - // After a request has run this long, new callers stop waiting on it and make their own. + /* Without storm tracking: after a request has run this long, it may be stuck, + * so new callers stop waiting on it and make their own. + */ graceInterval: 1000, // milliseconds == 1 second - // A caller that has waited this long fails. - inFlightTTL: 10 * 1000, // milliseconds == 10 seconds + // With storm tracking: how often a caller waiting for fanOut to free up checks again. + sleepMilli: 20, +} + +/* How a caching materials manager shares requests. */ +export interface SharingSettings { + // false: the defaults described on CachingMaterialsManagerStormTracking. + stormTracking: boolean + graceInterval: number + fanOut: number + inFlightTTL: number +} + +export const STORM_TRACKING_DEFAULTS = Object.freeze({ + graceInterval: 1000, + fanOut: 20, + inFlightTTL: 10 * 1000, +}) + +export function resolveStormTracking( + stormTracking?: CachingMaterialsManagerStormTracking +): Readonly | undefined { + if (stormTracking === undefined) return undefined + /* Precondition: stormTracking must be an object. */ + needs( + stormTracking && typeof stormTracking === 'object', + 'stormTracking must be an object.' + ) + const { + graceInterval = STORM_TRACKING_DEFAULTS.graceInterval, + fanOut = STORM_TRACKING_DEFAULTS.fanOut, + inFlightTTL = STORM_TRACKING_DEFAULTS.inFlightTTL, + } = stormTracking + /* Precondition: stormTracking.graceInterval must be a positive number. */ + needs( + isPositive(graceInterval), + 'stormTracking.graceInterval must be a positive number.' + ) + /* Precondition: stormTracking.inFlightTTL must be a positive number. */ + needs( + isPositive(inFlightTTL), + 'stormTracking.inFlightTTL must be a positive number.' + ) + /* Precondition: stormTracking.fanOut must be a positive integer. */ + needs( + Number.isSafeInteger(fanOut) && fanOut > 0, + 'stormTracking.fanOut must be a positive integer.' + ) + /* Precondition: stormTracking.graceInterval must not exceed stormTracking.inFlightTTL. */ + needs( + graceInterval <= inFlightTTL, + 'stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.' + ) + return Object.freeze({ + stormTracking: true, + graceInterval, + fanOut, + inFlightTTL, + }) +} + +function isPositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 } /* In-flight batches (one request plus its waiting callers) by cache, then cache key. diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 22949fc9e..19b38db11 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -6,8 +6,10 @@ import { expect } from 'chai' import { cacheEntryHasExceededLimits, + decorateProperties, getEncryptionMaterials, decryptMaterials, + CachingMaterialsManagerStormTracking, } from '../src/caching_cryptographic_materials_decorators' import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' import * as cacheMaterial from '../src/index' @@ -50,14 +52,19 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( ) // Shortened timings, so the tests run in milliseconds. -const FAST = { graceInterval: 20, inFlightTTL: 100 } +const FAST = { graceInterval: 20, sleepMilli: 2 } const DEFAULT = { ...SHARED_REQUESTS } +const FAST_STORM: CachingMaterialsManagerStormTracking = { + graceInterval: 20, + inFlightTTL: 100, +} -// A backing materials manager that takes 5 ms per request and returns a new data key each time. +// A backing materials manager that takes `ms` milliseconds per request and returns a new data key each time. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. function slowBackingMaterialsManager({ outcomes = ['ok'] as ('ok' | 'fail' | 'hang')[], materialSuite = suite, + ms = 5, } = {}) { const calls = { encrypt: 0, decrypt: 0, active: 0, peak: 0 } let n = 0 @@ -66,7 +73,7 @@ function slowBackingMaterialsManager({ if (outcome === 'hang') await new Promise(() => undefined) calls.active += 1 calls.peak = Math.max(calls.peak, calls.active) - await new Promise((resolve) => setTimeout(resolve, 5)) + await new Promise((resolve) => setTimeout(resolve, ms)) calls.active -= 1 if (outcome === 'fail') throw new Error('backing materials manager unavailable') @@ -102,20 +109,28 @@ function cachingCMM( { maxMessagesEncrypted = 1000, maxBytesEncrypted = 1000, - cache = getLocalCryptographicMaterialsCache(100), + cache = getLocalCryptographicMaterialsCache(100) as any, + stormTracking = undefined as + | CachingMaterialsManagerStormTracking + | undefined, } = {} ) { - return { - _partition: 'partition', - _maxAge: 1000 * 60, - _maxBytesEncrypted: maxBytesEncrypted, - _maxMessagesEncrypted: maxMessagesEncrypted, - _cache: cache, - _backingMaterialsManager: backing, + const cmm = { _cacheEntryHasExceededLimits: cacheEntryHasExceededLimits(), getEncryptionMaterials: getEncryptionMaterials(cacheKeyHelpers), decryptMaterials: decryptMaterials(cacheKeyHelpers), } as any + decorateProperties(cmm, { + cache, + backingMaterials: backing, + backingMaterialsManager: backing, + partition: 'partition', + maxAge: 1000 * 60, + maxBytesEncrypted, + maxMessagesEncrypted, + stormTracking, + }) + return cmm } const encryptRequest = { suite, encryptionContext: {}, plaintextLength: 1 } @@ -263,43 +278,53 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(1) }) - it('waiting callers retry a failed request once', async () => { + it('by default, waiting callers make their own request when the shared one fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) const results = await Promise.allSettled(startEncrypts(cmm, times(10))) + // Only the caller whose request failed sees the error, as without sharing. expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(backing.calls.encrypt).to.equal(2) + expect(backing.calls.encrypt).to.equal(10) }) - it('a request that keeps failing reaches every caller after one retry', async () => { + it('by default, a failing backing materials manager fails each caller with its own error', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail'] }) const cmm = cachingCMM(backing) const results = await Promise.allSettled(startEncrypts(cmm, times(10))) for (const result of results) { - expect(result.status).to.equal('rejected') expect((result as PromiseRejectedResult).reason.message).to.equal( 'backing materials manager unavailable' ) } - expect(backing.calls.encrypt).to.equal(2) + expect(backing.calls.encrypt).to.equal(10) }) - it('fails waiting callers after inFlightTTL when the request hangs', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['hang'] }) + it('by default, decrypts make their own request when the shared one fails', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) - const [, ...waiters] = startEncrypts(cmm, times(5)) - const results = await Promise.allSettled(waiters) - - for (const result of results) { - expect((result as PromiseRejectedResult).reason.message).to.equal( - 'Caching materials manager inFlightTTL exceeded' + const results = await Promise.allSettled( + Array.from({ length: 5 }, async () => + cmm.decryptMaterials(decryptRequest) ) - } + ) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(backing.calls.decrypt).to.equal(5) + }) + + it('by default, waiting callers do not time out', async () => { + // A request slower than the storm tracking inFlightTTL still serves every waiting caller. + const backing = slowBackingMaterialsManager({ ms: 150 }) + const cmm = cachingCMM(backing) + + const materials = await encryptConcurrently(cmm, times(5)) + + expect(materials).to.have.lengthOf(5) expect(backing.calls.encrypt).to.equal(1) }) @@ -438,3 +463,104 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { } }) }) + +describe('caching materials manager: concurrent cache misses with stormTracking', () => { + beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) + afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) + + it('waiting callers try again after a failed request, and share the retry', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) + const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) + + const results = await Promise.allSettled(startEncrypts(cmm, times(10))) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(backing.calls.encrypt).to.equal(2) + }) + + it('decrypts try again after a failed request, and share the retry', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) + const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) + + const results = await Promise.allSettled( + Array.from({ length: 5 }, async () => + cmm.decryptMaterials(decryptRequest) + ) + ) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(backing.calls.decrypt).to.equal(2) + }) + + it('fails waiting callers after inFlightTTL when the request hangs', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['hang'] }) + const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) + + const [, ...waiters] = startEncrypts(cmm, times(5)) + const results = await Promise.allSettled(waiters) + + for (const result of results) { + expect((result as PromiseRejectedResult).reason.message).to.equal( + 'Caching materials manager inFlightTTL exceeded' + ) + } + expect(backing.calls.encrypt).to.equal(1) + }) + + it('a caller that times out is not given or charged for the data key', async () => { + // The request outlasts inFlightTTL, so the 4 waiting callers time out before it returns. + const backing = slowBackingMaterialsManager({ ms: 150 }) + const cmm = cachingCMM(backing, { + maxMessagesEncrypted: 5, + stormTracking: FAST_STORM, + }) + + const [first, ...waiters] = startEncrypts(cmm, times(5)) + const results = await Promise.allSettled(waiters) + for (const result of results) expect(result.status).to.equal('rejected') + const leader = await first + + // Only the first message used the data key, so 4 more can reuse it. + const later = [] + for (let i = 0; i < 4; i++) + later.push(await cmm.getEncryptionMaterials(encryptRequest)) + expect(backing.calls.encrypt).to.equal(1) + expect(usageByDataKey([leader, ...later], times(5))).to.have.lengthOf(1) + }) + + it('makes at most fanOut backing requests at once', async () => { + const backing = slowBackingMaterialsManager({ ms: 10 }) + const cmm = cachingCMM(backing, { + stormTracking: { ...FAST_STORM, fanOut: 2 }, + }) + + await Promise.all( + ['a', 'b', 'c', 'd'].map(async (a) => + cmm.getEncryptionMaterials({ + ...encryptRequest, + encryptionContext: { a }, + }) + ) + ) + + expect(backing.calls.encrypt).to.equal(4) + expect(backing.calls.peak).to.equal(2) + }) + + it('a request older than inFlightTTL stops counting toward fanOut', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) + const cmm = cachingCMM(backing, { + stormTracking: { ...FAST_STORM, fanOut: 1 }, + }) + + startEncrypts(cmm, times(1), { encryptionContext: { a: 'a' } }) + await new Promise((resolve) => setTimeout(resolve, 1)) + const started = Date.now() + await encryptConcurrently(cmm, times(1), { encryptionContext: { a: 'b' } }) + + expect(Date.now() - started).to.be.at.least( + (FAST_STORM.inFlightTTL as number) - 10 + ) + expect(backing.calls.encrypt).to.equal(2) + }) +}) diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts index c439dd148..a21816f6f 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts @@ -109,6 +109,82 @@ describe('decorateProperties', () => { } as any expect(() => decorateProperties(test, input)).to.throw() }) + + describe('stormTracking', () => { + const decorate = (stormTracking: any) => { + const test = {} as any + decorateProperties(test, { + cache: 'cache' as any, + backingMaterialsManager: 'backingMaterialsManager' as any, + maxAge: 10, + partition: 'something', + stormTracking, + } as any) + return test._stormTracking + } + + it('is off unless given', () => { + expect(decorate(undefined)).to.equal(undefined) + }) + + it('takes the MPL defaults for unset settings', () => { + expect(decorate({})).to.deep.equal({ + stormTracking: true, + graceInterval: 1000, + fanOut: 20, + inFlightTTL: 10000, + }) + expect( + decorate({ graceInterval: 5, fanOut: 3, inFlightTTL: 50 }) + ).to.deep.equal({ + stormTracking: true, + graceInterval: 5, + fanOut: 3, + inFlightTTL: 50, + }) + }) + + it('Precondition: stormTracking must be an object.', () => { + for (const value of [null, true, 1, 'x']) { + expect(() => decorate(value)).to.throw( + 'stormTracking must be an object.' + ) + } + }) + + it('Precondition: stormTracking.graceInterval must be a positive number.', () => { + for (const graceInterval of [0, -1, NaN, Infinity, '1', null]) { + expect(() => decorate({ graceInterval })).to.throw( + 'stormTracking.graceInterval must be a positive number.' + ) + } + }) + + it('Precondition: stormTracking.inFlightTTL must be a positive number.', () => { + for (const inFlightTTL of [0, -1, NaN, Infinity, '1', null]) { + expect(() => decorate({ inFlightTTL })).to.throw( + 'stormTracking.inFlightTTL must be a positive number.' + ) + } + }) + + it('Precondition: stormTracking.fanOut must be a positive integer.', () => { + for (const fanOut of [0, -1, 1.5, NaN, Infinity, '1', null]) { + expect(() => decorate({ fanOut })).to.throw( + 'stormTracking.fanOut must be a positive integer.' + ) + } + }) + + it('Precondition: stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.', () => { + expect(() => decorate({ graceInterval: 11, inFlightTTL: 10 })).to.throw( + 'stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.' + ) + expect( + decorate({ graceInterval: 10, inFlightTTL: 10 }).graceInterval + ).to.equal(10) + }) + }) }) describe('cacheEntryHasExceededLimits', () => { From 19cc6727ded0c2728bfafd26802dc34c2cede6f0 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 10:35:54 -0700 Subject: [PATCH 17/38] fix(kms-keyring-node): keep default behavior, add opt-in stormTracking Concurrent misses for the same branch key share one keystore fetch by default. Waiters are notified when the fetch settles; on failure each waiter makes its own fetch. There is no fanOut limit, no waiter timeout, and no early refresh by default. Replace the unreleased top-level gracePeriod option with stormTracking (gracePeriod, graceInterval, fanOut, inFlightTTL, in seconds), which ports the MPL StormTracker with its defaults and validation rules. Also: - Track each fetch by identity or token, so a late fetch for an entry can't clear or fail a newer fetch for the same entry. - If an early refresh fails, use the still-valid cached branch key. --- .../src/branch_key_storm_tracker.ts | 263 +++++++++---- .../kms-keyring-node/src/kms_hkeyring_node.ts | 116 +++++- .../src/kms_hkeyring_node_helpers.ts | 144 +++---- .../kms_hkeyring_node.concurrency.test.ts | 371 ++++++++++++------ .../kms_hkeyring_node.constructor.test.ts | 118 +++--- 5 files changed, 678 insertions(+), 334 deletions(-) diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index a49fe1611..66ef77256 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -1,39 +1,155 @@ // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { NodeAlgorithmSuite } from '@aws-crypto/material-management' +import { + NodeAlgorithmSuite, + NodeBranchKeyMaterial, +} from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' -/* Stops concurrent callers from all fetching the same branch key from the keystore. - * For a cache entry, the tracker tells each caller to - * use the cached entry, fetch it from the keystore, - * or wait and check the cache again because another caller is fetching it. +/* Concurrent callers that miss the cache for the same branch key share one keystore fetch. + * + * By default, sharing never makes a caller fail or wait in a way it would not without sharing: + * one caller fetches, the others wait for its answer, and if that fetch fails, + * each waiting caller makes its own fetch. There is no limit on fetches in flight + * and no timeout for waiting callers. * - * Ported from the MPL's StormTracker.dfy, + * The keyring's stormTracking option instead ports the MPL's StormTracker.dfy, * which the other ESDKs use by default for the hierarchical keyring. - * Timings are in milliseconds, with the MPL's defaults. Tests change them. - * The grace period (refreshing an entry before it expires) is a keyring option, off by default. + * + * Internal: not exported from the package index. Tests change these values. */ -export const STORM_TRACKING = { - // A fetch running longer than this may be stuck; the next caller fetches again. - graceInterval: 1 * 1000, - // At most this many different branch keys are fetched at once; callers for other keys wait. - fanOut: 20, - // A caller that has waited this long fails. - inFlightTTL: 10 * 1000, - // How often a waiting caller checks the cache. +export const SHARED_FETCHES = { + /* By default: after a fetch has run this long it may be stuck, + * so new callers stop waiting on it and make their own. + */ + graceInterval: 1000, // milliseconds + // With stormTracking: how often a waiting caller checks the cache again. sleepMilli: 20, } +/* The stormTracking settings, in milliseconds. */ +export interface StormTrackingSettings { + gracePeriod: number + graceInterval: number + fanOut: number + inFlightTTL: number +} + +/* The MPL's DefaultStorm() settings, in seconds. */ +export const STORM_TRACKING_DEFAULTS = Object.freeze({ + gracePeriod: 10, + graceInterval: 1, + fanOut: 20, + inFlightTTL: 10, +}) + +/* --------------------------------------------------------------------------- + * Default: share one fetch, and fall back to each caller's own fetch on failure. + */ + +interface SharedFetch { + startedAt: number + members: ((outcome: SharedOutcome) => void)[] +} +type SharedOutcome = { material: NodeBranchKeyMaterial } | { failed: true } + +/* In-flight fetches by cache, then cache entry id. + * Keyed by cache so keyrings that share a cache also share fetches. + * Each fetch is its own object, so a slow fetch that settles late + * only removes itself, never a newer fetch for the same entry. + */ +const sharedFetches = new WeakMap< + CryptographicMaterialsCache, + Map +>() + +export function sharedFetchesFor( + cmc: CryptographicMaterialsCache +) { + return sharedFetches.get(cmc) +} + +function isRecent(fetch: SharedFetch) { + return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval +} + +/* Fetch the branch key materials, sharing a fetch another caller already started. + * `fetch` asks the keystore; `put` caches the result. + * Returns materials this caller owns: a copy no cache will ever zero. + */ +export async function sharedFetch( + cmc: CryptographicMaterialsCache, + cacheEntryId: string, + fetch: () => Promise, + put: (material: NodeBranchKeyMaterial) => void +): Promise { + const byId = sharedFetches.get(cmc) || new Map() + sharedFetches.set(cmc, byId) + // Drop fetches nobody can join anymore. A fetch that never settles is forgotten here. + const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) + const current = fetches[fetches.length - 1] + + if (current) { + // Another caller is fetching this branch key. Wait for its answer. + const outcome = await new Promise((resolve) => + current.members.push(resolve) + ) + if ('material' in outcome) return outcome.material + // That fetch failed. Make this caller's own fetch, as it would without sharing. + const material = await fetch() + put(material) + return deepCopyBranchKeyMaterial(material) + } + + const mine: SharedFetch = { startedAt: Date.now(), members: [] } + fetches.push(mine) + byId.set(cacheEntryId, fetches) + const close = () => { + const list = byId.get(cacheEntryId) + if (!list) return + const index = list.indexOf(mine) + if (index !== -1) list.splice(index, 1) + if (!list.length) byId.delete(cacheEntryId) + } + + let material: NodeBranchKeyMaterial + try { + material = await fetch() + } catch (error) { + close() + for (const settle of mine.members) settle({ failed: true }) + throw error + } + close() + /* Give each waiting caller its own copy before caching the material, + * because the cache zeroes the material's buffer when it evicts it. + */ + for (const settle of mine.members) { + settle({ material: deepCopyBranchKeyMaterial(material) }) + } + put(material) + return deepCopyBranchKeyMaterial(material) +} + +/* --------------------------------------------------------------------------- + * With stormTracking: the MPL's StormTracker. + */ + export type CacheState = 'use' | 'fetch' | 'wait' +// Identifies one fetch, so a late fetch can't be mistaken for a newer one. +export type FetchToken = symbol +/* Tells each caller to use the cached entry, fetch it from the keystore, + * or wait and check the cache again because another caller is fetching it. + */ export class StormTracker { - // Cache entries being fetched, and when each fetch started. - private readonly inFlight = new Map() + // Cache entries being fetched: when each fetch started, and a token for that fetch. + private readonly inFlight = new Map< + string, + { startedAt: number; token: FetchToken } + >() private lastPrune = 0 - // The latest failed fetch for each cache entry, so callers waiting on it get its error. - private readonly failures = new Map() - private lastFailurePrune = 0 /* For a cached entry that hasn't expired. * Use it, unless it's within gracePeriod of expiring and nobody is refreshing it yet. @@ -41,79 +157,68 @@ export class StormTracker { checkEntry( id: string, expiresAt: number, - gracePeriod: number, - now: number - ): CacheState { - if (this.fanOutReached(now)) return 'use' - if (now < expiresAt - gracePeriod) return 'use' - const started = this.inFlight.get(id) - if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { - return 'use' + now: number, + settings: StormTrackingSettings + ): { state: CacheState; token?: FetchToken } { + if (this.fanOutReached(now, settings)) return { state: 'use' } + if (now < expiresAt - settings.gracePeriod) return { state: 'use' } + const current = this.inFlight.get(id) + if (current && now < current.startedAt + settings.graceInterval) { + return { state: 'use' } } - this.inFlight.set(id, now) - return 'fetch' + return this.start(id, now) } /* For an entry that's missing or expired. * Fetch it, unless another caller started fetching it within the last graceInterval. + * Like the MPL, a failed fetch stays in flight, so waiting callers fetch again + * once it is graceInterval old. */ - checkNewEntry(id: string, now: number): CacheState { - if (this.fanOutReached(now)) return 'wait' - const started = this.inFlight.get(id) - if (started !== undefined && now < started + STORM_TRACKING.graceInterval) { - return 'wait' + checkNewEntry( + id: string, + now: number, + settings: StormTrackingSettings + ): { state: CacheState; token?: FetchToken } { + if (this.fanOutReached(now, settings)) return { state: 'wait' } + const current = this.inFlight.get(id) + if (current && now < current.startedAt + settings.graceInterval) { + return { state: 'wait' } } - this.inFlight.set(id, now) - return 'fetch' + return this.start(id, now) } - /* Call when a fetch succeeds. */ - fetched(id: string) { - this.inFlight.delete(id) - this.failures.delete(id) + /* Call when a fetch succeeds. + * Only the newest fetch for an entry clears it, so a slow fetch that + * finishes late can't make callers think a newer fetch is done. + */ + fetched(id: string, token: FetchToken) { + if (this.inFlight.get(id)?.token === token) this.inFlight.delete(id) } - /* Call when a fetch fails. - * Callers waiting on it fail with the same error instead of timing out, - * and the entry stops counting toward fanOut, so the next caller can fetch right away. - */ - failed(id: string, error: unknown, now: number) { - this.inFlight.delete(id) - this.pruneFailures(now) - this.failures.set(id, { at: now, error }) + inFlightCount() { + return this.inFlight.size } - /* The error of a fetch for this entry that failed at or after `since`, if any. */ - failureSince(id: string, since: number) { - const failure = this.failures.get(id) - return failure && since <= failure.at ? failure : undefined + private start(id: string, now: number) { + const token = Symbol('fetch') + this.inFlight.set(id, { startedAt: now, token }) + return { state: 'fetch' as const, token } } - private fanOutReached(now: number) { - this.pruneInFlight(now) - return this.inFlight.size >= STORM_TRACKING.fanOut + private fanOutReached(now: number, settings: StormTrackingSettings) { + this.pruneInFlight(now, settings) + return this.inFlight.size >= settings.fanOut } - /* Forget fetches older than inFlightTTL, so stuck fetches stop counting toward fanOut. + /* Forget fetches older than inFlightTTL, so stuck or failed fetches stop counting toward fanOut. * Like the MPL, only bother when fanOut is reached, and at most once a second. */ - private pruneInFlight(now: number) { - if (this.inFlight.size < STORM_TRACKING.fanOut) return + private pruneInFlight(now: number, settings: StormTrackingSettings) { + if (this.inFlight.size < settings.fanOut) return if (now - 1000 < this.lastPrune) return this.lastPrune = now - for (const [id, started] of this.inFlight) { - if (now >= started + STORM_TRACKING.inFlightTTL) this.inFlight.delete(id) - } - } - - /* Forget failures older than inFlightTTL. Every caller that could have waited on them has given up. - * At most once a second. - */ - private pruneFailures(now: number) { - if (now - 1000 < this.lastFailurePrune) return - this.lastFailurePrune = now - for (const [id, { at }] of this.failures) { - if (now >= at + STORM_TRACKING.inFlightTTL) this.failures.delete(id) + for (const [id, { startedAt }] of this.inFlight) { + if (now >= startedAt + settings.inFlightTTL) this.inFlight.delete(id) } } } @@ -138,3 +243,15 @@ export function stormTrackerFor( export async function sleep(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)) } + +/* A copy of the materials with its own buffer, which no cache will ever zero. */ +export function deepCopyBranchKeyMaterial( + material: NodeBranchKeyMaterial +): NodeBranchKeyMaterial { + return new NodeBranchKeyMaterial( + Buffer.from(material.branchKey()), + material.branchKeyIdentifier, + material.branchKeyVersion.toString('utf-8'), + { ...material.encryptionContext } + ) +} diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index c74cad7f4..1306455c3 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -50,6 +50,10 @@ import { isBranchKeyIdSupplier, } from '@aws-crypto/kms-keyring' import { randomBytes } from 'crypto' +import { + StormTrackingSettings, + STORM_TRACKING_DEFAULTS, +} from './branch_key_storm_tracker' export interface KmsHierarchicalKeyRingNodeInput { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#initialization @@ -75,12 +79,43 @@ export interface KmsHierarchicalKeyRingNodeInput { //# - MAY provide a [Partition ID](#partition-id) partitionId?: string utf8Sorting?: boolean - /* Seconds before a cached branch key expires during which one caller refreshes it - * from the keystore while other callers keep using the cached key. - * Defaults to 0: cached branch keys are never refreshed early. - * Must be less than cacheLimitTtl. + /* Opt in to behaving like the MPL's StormTracking cache. + * See KmsHierarchicalKeyRingStormTracking. + */ + stormTracking?: KmsHierarchicalKeyRingStormTracking +} + +/* Opt-in settings that make the keyring's branch key cache behave like + * the MPL's StormTracking cache, which the other ESDKs use by default. + * Times are in seconds, like cacheLimitTtl. + * + * Without stormTracking, concurrent cache misses for the same branch key + * still share one keystore fetch, but a caller never fails or waits in a way + * it would not without sharing: there is no limit on fetches in flight, + * no timeout for waiting callers, cached branch keys are never refreshed early, + * and if the shared fetch fails, each waiting caller makes its own fetch. + * This differs from the MPL so that existing customers see no change in behavior. + * A future major version may align these defaults with the MPL. + * + * With stormTracking, unset settings take the MPL's defaults, + * and the settings must satisfy the MPL's rules: + * graceInterval <= inFlightTTL <= gracePeriod < cacheLimitTtl. + * One difference from the MPL: if an early refresh fails, + * the caller uses the still-valid cached branch key instead of failing. + */ +export interface KmsHierarchicalKeyRingStormTracking { + /* Before a cached branch key expires, for this long one caller at a time refreshes it + * from the keystore while other callers keep using the cached key. Default 10. */ gracePeriod?: number + /* After a fetch has run this long, the next caller fetches again. Default 1. */ + graceInterval?: number + /* At most this many branch keys are fetched at once, across every keyring that + * shares the cache. Callers for other branch keys wait. Default 20. + */ + fanOut?: number + /* A caller that has waited this long fails. Default 10. */ + inFlightTTL?: number } export interface IKmsHierarchicalKeyRingNode extends KeyringNode { @@ -88,8 +123,8 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { branchKeyIdSupplier?: Readonly keyStore: Readonly cacheLimitTtl: number - // Milliseconds. - gracePeriod?: number + // Milliseconds. Set only when the stormTracking input is given. + _stormTracking?: Readonly _onEncrypt(material: NodeEncryptionMaterial): Promise _onDecrypt( material: NodeDecryptionMaterial, @@ -110,7 +145,7 @@ export class KmsHierarchicalKeyRingNode public declare keyStore: Readonly public declare _logicalKeyStoreName: Buffer public declare cacheLimitTtl: number - public declare gracePeriod: number + public declare _stormTracking?: Readonly public declare maxCacheSize?: number public declare _cmc: CryptographicMaterialsCache declare readonly _partition: Buffer @@ -125,7 +160,7 @@ export class KmsHierarchicalKeyRingNode maxCacheSize, partitionId, utf8Sorting, - gracePeriod, + stormTracking, }: KmsHierarchicalKeyRingNodeInput) { super() @@ -226,20 +261,11 @@ export class KmsHierarchicalKeyRingNode // convert seconds to milliseconds readOnlyProperty(this, 'cacheLimitTtl', cacheLimitTtl * 1000) - /* Precondition: The grace period must be a number */ - needs( - gracePeriod === undefined || typeof gracePeriod === 'number', - 'The grace period must be a number' - ) - /* Precondition: The grace period must be non-negative and less than the cache limit TTL */ - needs( - gracePeriod === undefined || - gracePeriod === 0 || - (0 < gracePeriod && gracePeriod < cacheLimitTtl), - 'The grace period must be non-negative and less than the cache limit TTL' + readOnlyProperty( + this, + '_stormTracking', + resolveStormTracking(stormTracking, cacheLimitTtl) ) - // convert seconds to milliseconds - readOnlyProperty(this, 'gracePeriod', (gracePeriod || 0) * 1000) readOnlyProperty(this, 'branchKeyId', branchKeyId) @@ -521,3 +547,51 @@ immutableClass(KmsHierarchicalKeyRingNode) //# Users MUST make sure that all of Partition ID, Logical Key Store Name of the Key Store for the Hierarchical Keyring //# and Branch Key ID are set to be the same for two Hierarchical Keyrings if and only they want the keyrings to share //# cache entries. + +function resolveStormTracking( + stormTracking: KmsHierarchicalKeyRingStormTracking | undefined, + cacheLimitTtl: number +): Readonly | undefined { + if (stormTracking === undefined) return undefined + /* Precondition: stormTracking must be an object */ + needs( + stormTracking && typeof stormTracking === 'object', + 'stormTracking must be an object' + ) + const { + gracePeriod = STORM_TRACKING_DEFAULTS.gracePeriod, + graceInterval = STORM_TRACKING_DEFAULTS.graceInterval, + fanOut = STORM_TRACKING_DEFAULTS.fanOut, + inFlightTTL = STORM_TRACKING_DEFAULTS.inFlightTTL, + } = stormTracking + /* Precondition: stormTracking times must be positive numbers */ + needs( + [gracePeriod, graceInterval, inFlightTTL].every( + (value) => + typeof value === 'number' && Number.isFinite(value) && value > 0 + ), + 'stormTracking gracePeriod, graceInterval, and inFlightTTL must be positive numbers' + ) + /* Precondition: stormTracking fanOut must be a positive integer */ + needs( + Number.isSafeInteger(fanOut) && fanOut > 0, + 'stormTracking fanOut must be a positive integer' + ) + /* Precondition: stormTracking settings must be consistent, as in the MPL */ + needs( + graceInterval <= inFlightTTL && inFlightTTL <= gracePeriod, + 'stormTracking graceInterval must not exceed inFlightTTL, and inFlightTTL must not exceed gracePeriod' + ) + /* Precondition: stormTracking gracePeriod must be less than the cache limit TTL */ + needs( + gracePeriod < cacheLimitTtl, + 'stormTracking gracePeriod must be less than the cache limit TTL' + ) + // convert seconds to milliseconds + return Object.freeze({ + gracePeriod: gracePeriod * 1000, + graceInterval: graceInterval * 1000, + fanOut, + inFlightTTL: inFlightTTL * 1000, + }) +} diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 7276fe3a9..aed0d3a34 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -35,8 +35,11 @@ import { import { BranchKeyIdSupplier } from '@aws-crypto/kms-keyring' import { serializeFactory, uuidv4Factory } from '@aws-crypto/serialize' import { + deepCopyBranchKeyMaterial, + FetchToken, + sharedFetch, sleep, - STORM_TRACKING, + SHARED_FETCHES, stormTrackerFor, } from './branch_key_storm_tracker' @@ -198,65 +201,28 @@ export async function getBranchKeyMaterials( branchKeyVersion?: string ): Promise { const { keyStore, cacheLimitTtl } = hKeyring - // Milliseconds. 0 (the default) never refreshes an entry before it expires. - const gracePeriod = hKeyring.gracePeriod || 0 - const tracker = stormTrackerFor(cmc) - const startedAt = Date.now() - const waitUntil = startedAt + STORM_TRACKING.inFlightTTL - let waited = false - // Concurrent callers for the same branch key share one keystore fetch. - for (;;) { - const now = Date.now() + /* A valid cached entry, as a copy the cache can never touch. The CMC zeroes a material's + * buffer in place on eviction; callers read the branch key AFTER this await, + * so a concurrent eviction (overwrite, TTL, or tail) could otherwise zero the + * buffer mid-derivation. */ + const lookUp = () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) //# from the underlying [cryptographic materials cache](../local-cryptographic-materials-cache.md). const cacheEntry = cmc.getBranchKeyMaterial(cacheEntryId) // if the cache entry is false, branch key materials were not found - const missing = - !cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry) - // If the fetch this caller waited on failed, fail with its error. - const failure = - waited && missing && tracker.failureSince(cacheEntryId, startedAt) - if (failure) throw failure.error - const state = missing - ? tracker.checkNewEntry(cacheEntryId, now) - : gracePeriod > 0 - ? tracker.checkEntry( - cacheEntryId, - cacheEntry.now + cacheLimitTtl, - gracePeriod, - now - ) - : 'use' - - if (cacheEntry && state === 'use') { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. - + if (!cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry)) { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + //# If this is NOT true, then we MUST treat the cache entry as expired. - /* Hand back a copy the cache can never touch. The CMC zeroes a material's - * buffer in place on eviction; callers read the branch key AFTER this await, - * so a concurrent eviction (overwrite, TTL, or tail) could otherwise zero the - * buffer mid-derivation. */ - return deepCopyBranchKeyMaterial(cacheEntry.response) + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If this is NOT true, then we MUST treat the cache entry as expired. + return undefined } - if (state === 'fetch') break - - // Someone else is fetching it. Check the cache again shortly. - waited = true - needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') - await sleep(STORM_TRACKING.sleepMilli) + return cacheEntry } - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If this is NOT true, then we MUST treat the cache entry as expired. - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# If a cache entry is not found or the cache entry is expired, the hierarchical keyring MUST attempt to obtain the branch key materials //# by querying the backing branch keystore specified in the [retrieve OnEncrypt branch key materials](#query-branch-keystore-onencrypt) section. @@ -275,19 +241,14 @@ export async function getBranchKeyMaterials( //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#getitem-branch-keystore-ondecrypt //# OnDecrypt MUST call the Keystore's [GetBranchKeyVersion](../branch-key-store.md#getbranchkeyversion) operation with the following inputs: - let branchKeyMaterials: NodeBranchKeyMaterial - try { - branchKeyMaterials = branchKeyVersion + const fetch = async () => + branchKeyVersion ? await keyStore.getBranchKeyVersion(branchKeyId, branchKeyVersion) : // The complice needs a line //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt //# OnEncrypt MUST call the Keystore's [GetActiveBranchKey](../branch-key-store.md#getactivebranchkey) operation with the following inputs: //# - the `branchKeyId` used in this operation await keyStore.getActiveBranchKey(branchKeyId) - } catch (error) { - tracker.failed(cacheEntryId, error, Date.now()) - throw error - } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#query-branch-keystore-onencrypt //# If the Keystore's GetActiveBranchKey operation succeeds @@ -298,21 +259,66 @@ export async function getBranchKeyMaterials( //# If the Keystore's GetBranchKeyVersion operation succeeds //# the keyring MUST put the returned branch key materials in the cache using the //# formula defined in [Appendix A](#appendix-a-cache-entry-identifier-formulas). - tracker.fetched(cacheEntryId) - cmc.putBranchKeyMaterial(cacheEntryId, branchKeyMaterials, cacheLimitTtl) + const put = (material: NodeBranchKeyMaterial) => + cmc.putBranchKeyMaterial(cacheEntryId, material, cacheLimitTtl) - return deepCopyBranchKeyMaterial(branchKeyMaterials) -} + const storm = hKeyring._stormTracking + if (!storm) { + const cacheEntry = lookUp() + if (cacheEntry) { + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. -function deepCopyBranchKeyMaterial( - material: NodeBranchKeyMaterial -): NodeBranchKeyMaterial { - return new NodeBranchKeyMaterial( - Buffer.from(material.branchKey()), - material.branchKeyIdentifier, - material.branchKeyVersion.toString('utf-8'), - { ...material.encryptionContext } - ) + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + return deepCopyBranchKeyMaterial(cacheEntry.response) + } + // Concurrent callers for the same branch key share one keystore fetch. + return sharedFetch(cmc, cacheEntryId, fetch, put) + } + + // With stormTracking, behave like the MPL's StormTracking cache. + const tracker = stormTrackerFor(cmc) + const waitUntil = Date.now() + storm.inFlightTTL + let token: FetchToken | undefined + while (!token) { + const now = Date.now() + const cacheEntry = lookUp() + const decision = cacheEntry + ? tracker.checkEntry( + cacheEntryId, + cacheEntry.now + cacheLimitTtl, + now, + storm + ) + : tracker.checkNewEntry(cacheEntryId, now, storm) + if (cacheEntry && decision.state === 'use') { + return deepCopyBranchKeyMaterial(cacheEntry.response) + } + if (decision.state === 'fetch') { + token = decision.token + continue + } + // Someone else is fetching it. Check the cache again shortly. + needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') + await sleep(SHARED_FETCHES.sleepMilli) + } + + let branchKeyMaterials: NodeBranchKeyMaterial + try { + branchKeyMaterials = await fetch() + } catch (error) { + /* If this was an early refresh of an entry in its grace period, + * the cached entry is still valid, so use it instead of failing. + * This differs from the MPL, which fails the call. + */ + const cacheEntry = lookUp() + if (cacheEntry) return deepCopyBranchKeyMaterial(cacheEntry.response) + throw error + } + tracker.fetched(cacheEntryId, token) + put(branchKeyMaterials) + return deepCopyBranchKeyMaterial(branchKeyMaterials) } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 12b176c62..8bf0e958c 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -17,7 +17,10 @@ import { TEST_ESDK_ALG_SUITE, TTL, } from './fixtures' -import { KmsHierarchicalKeyRingNode } from '../src/kms_hkeyring_node' +import { + KmsHierarchicalKeyRingNode, + KmsHierarchicalKeyRingStormTracking, +} from '../src/kms_hkeyring_node' import { BRANCH_KEY_ID_SUPPLIER, deepCopyBranchKeyMaterial, @@ -30,7 +33,13 @@ import { } from '@aws-crypto/branch-keystore-node' import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' -import { STORM_TRACKING, StormTracker } from '../src/branch_key_storm_tracker' +import { + FetchToken, + SHARED_FETCHES, + StormTracker, + StormTrackingSettings, + sharedFetchesFor, +} from '../src/branch_key_storm_tracker' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -152,14 +161,15 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// Shortened storm-tracking timings, so the tests run in milliseconds. -const FAST_STORM = { - ...STORM_TRACKING, - graceInterval: 40, - inFlightTTL: 200, - sleepMilli: 2, +// Shortened timings, so the tests run in milliseconds. +const FAST = { graceInterval: 40, sleepMilli: 2 } +const DEFAULT = { ...SHARED_FETCHES } +// Seconds, like the keyring option. +const FAST_STORM: KmsHierarchicalKeyRingStormTracking = { + gracePeriod: 0.2, + graceInterval: 0.04, + inFlightTTL: 0.2, } -const DEFAULT_STORM = { ...STORM_TRACKING } // A keystore stub where each request takes `ms` milliseconds. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. @@ -194,9 +204,15 @@ function slowKeyStore( function keyringFor( keyStore: BranchKeyStoreNode, - cache?: KmsHierarchicalKeyRingNode['_cmc'], - partitionId?: string, - gracePeriod?: number + { + cache, + partitionId, + stormTracking, + }: { + cache?: KmsHierarchicalKeyRingNode['_cmc'] + partitionId?: string + stormTracking?: KmsHierarchicalKeyRingStormTracking + } = {} ): KmsHierarchicalKeyRingNode { return new KmsHierarchicalKeyRingNode({ branchKeyIdSupplier: BRANCH_KEY_ID_SUPPLIER, @@ -204,48 +220,51 @@ function keyringFor( cacheLimitTtl: TTL, cache, partitionId, - gracePeriod, + stormTracking, }) } +// A minimal keyring for calling getBranchKeyMaterials directly. +function hKeyringFor(keyStore: any, stormTracking?: StormTrackingSettings) { + return { + keyStore, + cacheLimitTtl: TTL * 1000, + cacheEntryHasExceededLimits: () => false, + _stormTracking: stormTracking, + } as any +} + +function startEncrypts(hkr: KmsHierarchicalKeyRingNode, count: number) { + return Array.from({ length: count }, async () => + hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) + ) +} + async function encryptConcurrently( hkr: KmsHierarchicalKeyRingNode, count: number ) { - return Promise.all( - Array.from({ length: count }, async () => - hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) - ) - ) + return Promise.all(startEncrypts(hkr, count)) } async function encryptSettled(hkr: KmsHierarchicalKeyRingNode, count: number) { return Promise.allSettled(startEncrypts(hkr, count)) } -function startEncrypts(hkr: KmsHierarchicalKeyRingNode, count: number) { - return Array.from({ length: count }, async () => - hkr.onEncrypt(new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A)) - ) -} +const wait = async (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) -describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { - beforeEach(() => Object.assign(STORM_TRACKING, FAST_STORM)) - afterEach(() => Object.assign(STORM_TRACKING, DEFAULT_STORM)) +describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { + beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) + afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // Use the default timings: 3000 callers slow the event loop enough - // that the shortened graceInterval would expire and start a second fetch. - Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // Use the default timings: 3000 callers slow the event loop enough - // that the shortened graceInterval would expire and start a second fetch. - Object.assign(STORM_TRACKING, DEFAULT_STORM) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) @@ -268,122 +287,223 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { const cache = getLocalCryptographicMaterialsCache(100) const partitionId = v4() await Promise.all([ - encryptConcurrently(keyringFor(keyStore, cache, partitionId), 10), - encryptConcurrently(keyringFor(keyStore, cache, partitionId), 10), + encryptConcurrently(keyringFor(keyStore, { cache, partitionId }), 10), + encryptConcurrently(keyringFor(keyStore, { cache, partitionId }), 10), ]) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) - it('waiting callers fail right away with the keystore error', async () => { + it('waiting callers make their own fetch when the shared one fails', async () => { const keyStore = slowKeyStore(5, ['fail', 'ok']) - const hkr = keyringFor(keyStore) - const started = Date.now() - const results = await encryptSettled(hkr, 10) + const results = await encryptSettled(keyringFor(keyStore), 10) + + // Only the caller whose fetch failed sees the error, as without sharing. + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(keyStore.getActiveBranchKey.callCount).to.equal(10) + }) + + it('a failing keystore fails each caller with its own error', async () => { + const keyStore = slowKeyStore(5, ['fail']) + const results = await encryptSettled(keyringFor(keyStore), 10) - expect(Date.now() - started).to.be.below(FAST_STORM.graceInterval) for (const result of results) { expect((result as PromiseRejectedResult).reason.message).to.equal( 'keystore unavailable' ) } + expect(keyStore.getActiveBranchKey.callCount).to.equal(10) + }) + + it('waiting callers do not time out', async () => { + // Slower than the storm tracking inFlightTTL used below; every caller still succeeds. + const keyStore = slowKeyStore(250) + await encryptConcurrently(keyringFor(keyStore), 10) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + }) + + it('starts another fetch for new callers once one hangs past graceInterval', async () => { + const keyStore = slowKeyStore(5, ['hang', 'ok']) + const hkr = keyringFor(keyStore) + + startEncrypts(hkr, 1) + await wait(FAST.graceInterval + 5) + await encryptConcurrently(hkr, 5) - // The next caller fetches again right away. - await encryptConcurrently(hkr, 1) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) - it('failed fetches do not count toward fanOut', async () => { - STORM_TRACKING.fanOut = 2 - const keyStore = slowKeyStore(5, ['fail', 'fail', 'ok']) + it('a stale fetch that fails late does not affect callers waiting on a newer one', async () => { + const deferreds: { + resolve: (m: NodeBranchKeyMaterial) => void + reject: (e: Error) => void + }[] = [] + const keyStore = { + getBranchKeyVersion: async () => + new Promise((resolve, reject) => + deferreds.push({ resolve, reject }) + ), + } const cache = getLocalCryptographicMaterialsCache(100) - const hKeyring = { - keyStore, - cacheLimitTtl: TTL * 1000, - cacheEntryHasExceededLimits: () => false, - } as any - const fetch = async (id: string) => - getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') + const get = async () => + getBranchKeyMaterials( + hKeyringFor(keyStore), + cache, + BRANCH_KEY_ID_A, + 'entry', + 'version' + ) - const failed = await Promise.allSettled([fetch('bad-1'), fetch('bad-2')]) - for (const result of failed) expect(result.status).to.equal('rejected') + const stale = get() + await wait(FAST.graceInterval + 5) + const newer = [get(), get(), get()] + await wait(1) + expect(deferreds).to.have.lengthOf(2) - const started = Date.now() - await fetch('good') - expect(Date.now() - started).to.be.below(FAST_STORM.graceInterval) - expect(keyStore.getBranchKeyVersion.callCount).to.equal(3) + deferreds[0].reject(new Error('stale')) + const [staleResult] = await Promise.allSettled([stale]) + expect((staleResult as PromiseRejectedResult).reason.message).to.equal( + 'stale' + ) + deferreds[1].resolve( + new NodeBranchKeyMaterial(Buffer.alloc(32, 7), BRANCH_KEY_ID_A, v4(), {}) + ) + for (const result of await Promise.all(newer)) { + expect(result.branchKey()).to.deep.equal(Buffer.alloc(32, 7)) + } + expect(deferreds).to.have.lengthOf(2) + }) + + it('has no limit on branch keys fetched at once', async () => { + const keyStore = slowKeyStore(20) + const cache = getLocalCryptographicMaterialsCache(100) + const hKeyring = hKeyringFor(keyStore) + + await Promise.all( + Array.from({ length: 30 }, async (_, i) => + getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, `id-${i}`, 'v') + ) + ) + + expect(keyStore.peak()).to.equal(30) + }) + + it('forgets a cache entry once its fetches settle', async () => { + const cache = getLocalCryptographicMaterialsCache(100) + await encryptConcurrently(keyringFor(slowKeyStore(), { cache }), 5) + await encryptSettled(keyringFor(slowKeyStore(5, ['fail']), { cache }), 5) + expect(sharedFetchesFor(cache)?.size).to.equal(0) + }) + + it('does not refresh an entry before it expires', async () => { + const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) + try { + const keyStore = slowKeyStore(20) + const hkr = keyringFor(keyStore) + await encryptConcurrently(hkr, 1) + clock.tick(TTL * 1000 - 1) + + await encryptConcurrently(hkr, 10) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + } finally { + clock.restore() + } + }) +}) + +describe('KmsHierarchicalKeyRingNode: stormTracking', () => { + beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) + afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) + + it('concurrent misses share one keystore call', async () => { + const keyStore = slowKeyStore() + await encryptConcurrently( + keyringFor(keyStore, { stormTracking: FAST_STORM }), + 100 + ) + expect(keyStore.getActiveBranchKey.callCount).to.equal(1) + }) + + it('after a failed fetch, one waiting caller fetches again once it is graceInterval old', async () => { + const keyStore = slowKeyStore(5, ['fail', 'ok']) + const results = await encryptSettled( + keyringFor(keyStore, { stormTracking: FAST_STORM }), + 10 + ) + + expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) it('starts another fetch when the first hangs past graceInterval', async () => { const keyStore = slowKeyStore(5, ['hang', 'ok']) - const hkr = keyringFor(keyStore) + const hkr = keyringFor(keyStore, { stormTracking: FAST_STORM }) // The first caller's fetch never returns; the other 9 must not wait on it. const [, ...waiters] = startEncrypts(hkr, 10) const results = await Promise.allSettled(waiters) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) - expect(results).to.have.lengthOf(9) for (const result of results) expect(result.status).to.equal('fulfilled') }) it('fails waiting callers after inFlightTTL when every fetch hangs', async () => { const keyStore = slowKeyStore(5, ['hang']) - const hkr = keyringFor(keyStore) + const hkr = keyringFor(keyStore, { stormTracking: FAST_STORM }) // Each caller told to fetch hangs forever; every other caller fails after inFlightTTL. const failures: string[] = [] for (const p of startEncrypts(hkr, 10)) { p.catch((e) => failures.push(e.message)) } - await new Promise((resolve) => - setTimeout(resolve, 2 * FAST_STORM.inFlightTTL) - ) + const inFlightTTL = (FAST_STORM.inFlightTTL as number) * 1000 + const graceInterval = (FAST_STORM.graceInterval as number) * 1000 + await wait(2 * inFlightTTL) const fetchers = keyStore.getActiveBranchKey.callCount - expect(fetchers).to.be.at.most( - FAST_STORM.inFlightTTL / FAST_STORM.graceInterval + 1 - ) + expect(fetchers).to.be.at.most(inFlightTTL / graceInterval + 1) expect(failures).to.have.lengthOf(10 - fetchers) for (const message of failures) { expect(message).to.equal('Storm cache inFlightTTL exceeded') } }) - it('does not refresh an entry before it expires by default', async () => { - const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) - try { - const keyStore = slowKeyStore(20) - const hkr = keyringFor(keyStore) - await encryptConcurrently(hkr, 1) - clock.tick(TTL * 1000 - 1) + it('fetches at most fanOut keys at once', async () => { + const keyStore = slowKeyStore(20) + const cache = getLocalCryptographicMaterialsCache(100) + const hKeyring = hKeyringFor(keyStore, { + gracePeriod: 200, + graceInterval: 40, + inFlightTTL: 200, + fanOut: 2, + }) - await encryptConcurrently(hkr, 10) - expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - } finally { - clock.restore() - } + await Promise.all( + ['a', 'b', 'c', 'd'].map(async (id) => + getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') + ) + ) + + expect(keyStore.getBranchKeyVersion.callCount).to.equal(4) + expect(keyStore.peak()).to.equal(2) }) - it('with a gracePeriod, refreshes an entry in its grace period with one fetch while others use it', async () => { + it('refreshes an entry in its grace period with one fetch while others use it', async () => { const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) try { const keyStore = slowKeyStore(20) const gracePeriod = 10 // seconds - const hkr = keyringFor(keyStore, undefined, undefined, gracePeriod) + const hkr = keyringFor(keyStore, { stormTracking: { gracePeriod } }) await encryptConcurrently(hkr, 1) clock.tick(TTL * 1000 - (gracePeriod * 1000) / 2) let finished = 0 const all = Promise.all( - Array.from({ length: 10 }, async () => { - await hkr.onEncrypt( - new NodeEncryptionMaterial(TEST_ESDK_ALG_SUITE, EC_A) - ) + startEncrypts(hkr, 10).map(async (p) => { + await p finished += 1 }) ) // The refresh takes 20 ms; the other 9 callers use the cached entry meanwhile. - await new Promise((resolve) => setTimeout(resolve, 5)) + await wait(5) expect(finished).to.equal(9) await all @@ -393,55 +513,62 @@ describe('KmsHierarchicalKeyRingNode: storm tracking (#1663)', () => { } }) - it('fetches at most fanOut keys at once', async () => { - STORM_TRACKING.fanOut = 2 - const keyStore = slowKeyStore(20) - const cache = getLocalCryptographicMaterialsCache(100) - const hKeyring = { - keyStore, - cacheLimitTtl: TTL * 1000, - cacheEntryHasExceededLimits: () => false, - } as any - - await Promise.all( - ['a', 'b', 'c', 'd'].map(async (id) => - getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') - ) - ) + it('uses the cached entry when an early refresh fails', async () => { + const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) + try { + const keyStore = slowKeyStore(5, ['ok', 'fail']) + const gracePeriod = 10 // seconds + const hkr = keyringFor(keyStore, { stormTracking: { gracePeriod } }) + await encryptConcurrently(hkr, 1) + clock.tick(TTL * 1000 - (gracePeriod * 1000) / 2) - expect(keyStore.getBranchKeyVersion.callCount).to.equal(4) - expect(keyStore.peak()).to.equal(2) + await encryptConcurrently(hkr, 1) + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + } finally { + clock.restore() + } }) }) -describe('StormTracker: failures', () => { - it('reports a failure only to callers that started waiting before it', () => { +describe('StormTracker', () => { + const settings = { + gracePeriod: 10000, + graceInterval: 1000, + fanOut: 20, + inFlightTTL: 10000, + } + + it('only the newest fetch for an entry marks it fetched', () => { const tracker = new StormTracker() - const error = new Error('nope') - tracker.failed('id', error, 100) + const stale = tracker.checkNewEntry('id', 0, settings) + const newer = tracker.checkNewEntry('id', 1000, settings) + expect(stale.state).to.equal('fetch') + expect(newer.state).to.equal('fetch') + + // The stale fetch finishing late leaves the newer one in flight. + tracker.fetched('id', stale.token as FetchToken) + expect(tracker.checkNewEntry('id', 1001, settings).state).to.equal('wait') - expect(tracker.failureSince('id', 100)?.error).to.equal(error) - expect(tracker.failureSince('id', 101)).to.equal(undefined) - expect(tracker.failureSince('other', 0)).to.equal(undefined) + tracker.fetched('id', newer.token as FetchToken) + expect(tracker.checkNewEntry('id', 1002, settings).state).to.equal('fetch') }) - it('a successful fetch clears the failure', () => { + it('a failed fetch stays in flight until graceInterval, like the MPL', () => { const tracker = new StormTracker() - tracker.failed('id', new Error('nope'), 100) - tracker.fetched('id') - expect(tracker.failureSince('id', 0)).to.equal(undefined) + tracker.checkNewEntry('id', 0, settings) + expect(tracker.checkNewEntry('id', 999, settings).state).to.equal('wait') + expect(tracker.checkNewEntry('id', 1000, settings).state).to.equal('fetch') }) - it('forgets failures older than inFlightTTL, at most once a second', () => { + it('forgets fetches older than inFlightTTL once fanOut is reached', () => { const tracker = new StormTracker() - const ttl = STORM_TRACKING.inFlightTTL - tracker.failed('old', new Error('old'), 2000) // prunes (nothing to prune yet) - tracker.failed('other', new Error('other'), 2500) // within a second: skips pruning - expect(tracker.failureSince('old', 0)).to.not.equal(undefined) - // 'old' is now older than inFlightTTL and gets pruned; 'other' is not yet. - tracker.failed('newest', new Error('newest'), 2000 + ttl + 1) - expect(tracker.failureSince('old', 0)).to.equal(undefined) - expect(tracker.failureSince('other', 0)).to.not.equal(undefined) + const small = { ...settings, fanOut: 1 } + tracker.checkNewEntry('a', 2000, small) + expect(tracker.checkNewEntry('b', 2000, small).state).to.equal('wait') + expect( + tracker.checkNewEntry('b', 2000 + small.inFlightTTL, small).state + ).to.equal('fetch') + expect(tracker.inFlightCount()).to.equal(1) }) }) diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts index d0716458e..e9f08dd9e 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts @@ -155,64 +155,84 @@ describe('KmsHierarchicalKeyRingNode: constructor', () => { }) }) - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#cache-limit-ttl - //= type=test - //# The maximum amount of time in seconds that an entry within the cache may be used before it MUST be evicted. - //# The client MUST set a time-to-live (TTL) for [branch key materials](../structures.md#branch-key-materials) in the underlying cache. - //# This value MUST be greater than zero. - it('The grace period defaults to 0 and is stored in milliseconds', () => { - expect( - new KmsHierarchicalKeyRingNode({ branchKeyId, keyStore, cacheLimitTtl }) - .gracePeriod - ).to.equal(0) - expect( - new KmsHierarchicalKeyRingNode({ - branchKeyId, - keyStore, - cacheLimitTtl: 60, - gracePeriod: 10, - }).gracePeriod - ).to.equal(10 * 1000) - expect( + describe('stormTracking', () => { + const build = (stormTracking: any, ttl = 60) => new KmsHierarchicalKeyRingNode({ branchKeyId, keyStore, - cacheLimitTtl: 0, - gracePeriod: 0, - }).gracePeriod - ).to.equal(0) - }) + cacheLimitTtl: ttl, + stormTracking, + })._stormTracking + + it('is off unless given', () => { + expect(build(undefined)).to.equal(undefined) + }) - it('Precondition: The grace period must be a number', () => { - for (const gracePeriod of ['1', true, {}, [], null]) { + it('takes the MPL defaults for unset settings, stored in milliseconds', () => { + expect(build({})).to.deep.equal({ + gracePeriod: 10000, + graceInterval: 1000, + fanOut: 20, + inFlightTTL: 10000, + }) expect( - () => - new KmsHierarchicalKeyRingNode({ - branchKeyId, - keyStore, - cacheLimitTtl, - gracePeriod: gracePeriod as any, - }) - ).to.throw('The grace period must be a number') - } - }) + build({ gracePeriod: 30, graceInterval: 2, fanOut: 5, inFlightTTL: 20 }) + ).to.deep.equal({ + gracePeriod: 30000, + graceInterval: 2000, + fanOut: 5, + inFlightTTL: 20000, + }) + }) + + it('Precondition: stormTracking must be an object', () => { + for (const value of [null, true, 1, 'x']) { + expect(() => build(value)).to.throw('stormTracking must be an object') + } + }) + + it('Precondition: stormTracking times must be positive numbers', () => { + for (const key of ['gracePeriod', 'graceInterval', 'inFlightTTL']) { + for (const value of [0, -1, NaN, Infinity, '1', null]) { + expect(() => build({ [key]: value })).to.throw( + 'stormTracking gracePeriod, graceInterval, and inFlightTTL must be positive numbers' + ) + } + } + }) + + it('Precondition: stormTracking fanOut must be a positive integer', () => { + for (const fanOut of [0, -1, 1.5, NaN, Infinity, '1', null]) { + expect(() => build({ fanOut })).to.throw( + 'stormTracking fanOut must be a positive integer' + ) + } + }) - it('Precondition: The grace period must be non-negative and less than the cache limit TTL', () => { - for (const gracePeriod of [-1, 60, 61, NaN, Infinity]) { + it('Precondition: stormTracking settings must be consistent, as in the MPL', () => { + const message = + 'stormTracking graceInterval must not exceed inFlightTTL, and inFlightTTL must not exceed gracePeriod' + expect(() => build({ graceInterval: 11 })).to.throw(message) + expect(() => build({ gracePeriod: 5 })).to.throw(message) expect( - () => - new KmsHierarchicalKeyRingNode({ - branchKeyId, - keyStore, - cacheLimitTtl: 60, - gracePeriod, - }) - ).to.throw( - 'The grace period must be non-negative and less than the cache limit TTL' - ) - } + build({ gracePeriod: 5, graceInterval: 5, inFlightTTL: 5 }) + ).to.not.equal(undefined) + }) + + it('Precondition: stormTracking gracePeriod must be less than the cache limit TTL', () => { + const message = + 'stormTracking gracePeriod must be less than the cache limit TTL' + expect(() => build({}, 10)).to.throw(message) + expect(() => build({}, 0)).to.throw(message) + expect(build({}, 11)).to.not.equal(undefined) + }) }) + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#cache-limit-ttl + //= type=test + //# The maximum amount of time in seconds that an entry within the cache may be used before it MUST be evicted. + //# The client MUST set a time-to-live (TTL) for [branch key materials](../structures.md#branch-key-materials) in the underlying cache. + //# This value MUST be greater than zero. it('Precondition: Cache limit TTL must be non-negative and less than or equal to (Number.MAX_SAFE_INTEGER / 1000) seconds', () => { expect( new KmsHierarchicalKeyRingNode({ From 4737df1ca95b6b9afc02b420d2c08f1e2cbf7510 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 10:51:51 -0700 Subject: [PATCH 18/38] docs: tighten shared request and stormTracking comments Make the stormTracking option docs JSDoc so they show up in editors, and fix a stale comment on the cached-entry lookup. --- ...hing_cryptographic_materials_decorators.ts | 82 +++++++++---------- modules/cache-material/src/shared_requests.ts | 21 ++--- ...c_materials_decorators.concurrency.test.ts | 2 +- .../src/branch_key_storm_tracker.ts | 76 ++++++++--------- .../kms-keyring-node/src/kms_hkeyring_node.ts | 55 +++++++------ .../src/kms_hkeyring_node_helpers.ts | 15 ++-- .../kms_hkeyring_node.concurrency.test.ts | 2 +- 7 files changed, 119 insertions(+), 134 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 018810297..a6289786f 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -136,7 +136,7 @@ export function getEncryptionMaterials({ (b) => canJoin(this, settings, b, plaintextLength) ) if (!batch) { - /* Check synchronously, so no other caller can start a request for this key in between. */ + // No await before startBatch, or concurrent callers all find no batch and each start one. if (!fanOutReached(this._cache, settings, waitUntil)) break await sleep(SHARED_REQUESTS.sleepMilli) continue @@ -157,11 +157,10 @@ export function getEncryptionMaterials({ ) } - // With storm tracking, try again once the failed request is graceInterval old, like the MPL. + // stormTracking mode: as in the MPL, try again once the failed request is graceInterval old. await sleep(batch.startedAt + settings.graceInterval - Date.now()) } - // Callers that miss while this request runs can wait for it. const batch = startBatch>( this._cache, cacheKey, @@ -303,7 +302,7 @@ export function decryptMaterials({ (b) => isRecent(settings, b) ) if (!batch) { - /* Check synchronously, so no other caller can start a request for this key in between. */ + // No await before startBatch, or concurrent callers all find no batch and each start one. if (!fanOutReached(this._cache, settings, waitUntil)) break await sleep(SHARED_REQUESTS.sleepMilli) continue @@ -311,14 +310,13 @@ export function decryptMaterials({ const outcome = await joinBatch(batch, 0, waitUntil) if ('material' in outcome) return outcome.material - // The shared request failed. Without storm tracking, make this caller's own request. + // The shared request failed: in default mode, make this caller's own request. if (!settings.stormTracking) return cloneResponse(await fetchAndCache()) - // With storm tracking, try again once the failed request is graceInterval old, like the MPL. + // stormTracking mode: as in the MPL, try again once the failed request is graceInterval old. await sleep(batch.startedAt + settings.graceInterval - Date.now()) } - // Callers that miss while this request runs can wait for it. const batch = startBatch>( this._cache, cacheKey, @@ -344,9 +342,7 @@ export function decryptMaterials({ const IN_FLIGHT_TTL_EXCEEDED = 'Caching materials manager inFlightTTL exceeded' -/* The caching materials manager's sharing settings. - * Without stormTracking: no fanOut limit and no timeout for waiting callers. - */ +// Default mode has no fanOut limit and no timeout for waiting callers. function sharingSettings( cmm: CachingMaterialsManager ): Readonly { @@ -382,9 +378,7 @@ interface Member { */ type Outcome = { material: M } | { uncached: true } | { error: unknown } -/* Look batches up by cache and key every time instead of holding on to a list, - * because closeBatch deletes a key's list once it is empty. - */ +// Look up the list on every call: closeBatch deletes a key's list once it is empty. function findBatch( cache: CryptographicMaterialsCache, cacheKey: string, @@ -422,8 +416,8 @@ function canJoin( ) } -/* With storm tracking, whether fanOut requests are already in flight on this cache. - * A caller that has waited past inFlightTTL for one to finish fails. +/* stormTracking mode: whether fanOut requests are already in flight on this cache. + * Throws once the caller has waited past inFlightTTL. */ function fanOutReached( cache: CryptographicMaterialsCache, @@ -435,8 +429,8 @@ function fanOutReached( return true } -/* Requests in flight on this cache, for any cache key. - * Like the MPL, a request older than inFlightTTL no longer counts. +/* Requests in flight on this cache, across all cache keys. + * As in the MPL, a request older than inFlightTTL stops counting. */ function inFlightCount( cache: CryptographicMaterialsCache, @@ -464,9 +458,8 @@ function startBatch( byKey = new Map() batchesByCache.set(cache, byKey) } - /* Drop batches nobody can join anymore. - * A request that never settles never calls closeBatch, so this is what forgets it. - */ + // Drop batches nobody can join anymore. + // This is the only place a request that never settles gets forgotten. const batches = (byKey.get(cacheKey) || []).filter((b) => isRecent(settings, b) ) @@ -482,7 +475,7 @@ function startBatch( return batch } -/* Remove the batch, and the cache key's list once it is empty, so the map does not grow forever. */ +// Also deletes the cache key's list once it is empty, so the map doesn't grow with every key ever used. function closeBatch( cache: CryptographicMaterialsCache, cacheKey: string, @@ -497,7 +490,7 @@ function closeBatch( } /* Wait for the shared request's answer, counting this caller's message against its data key. - * With storm tracking, fails once waitUntil passes. + * In stormTracking mode, fails once waitUntil passes. */ async function joinBatch( batch: Batch, @@ -512,9 +505,7 @@ async function joinBatch( batch.members.push(member) if (waitUntil === Infinity) return timer = setTimeout(() => { - /* A caller that times out never gets the data key, - * so the request no longer clones it for this caller or counts its message. - */ + // A caller that times out never gets the data key, so stop cloning it for this caller or counting its message. const index = batch.members.indexOf(member) if (index !== -1) batch.members.splice(index, 1) batch.messages -= 1 @@ -575,37 +566,42 @@ export interface CachingMaterialsManagerInput< maxBytesEncrypted?: number maxMessagesEncrypted?: number maxAge: number - /* Opt in to behaving like the MPL's StormTracking cache. - * See CachingMaterialsManagerStormTracking. - */ + /** Opt in to the MPL's StormTracking behavior; see {@link CachingMaterialsManagerStormTracking}. */ stormTracking?: CachingMaterialsManagerStormTracking }> {} -/* Opt-in settings that make the caching materials manager behave like - * the MPL's StormTracking cache. Times are in milliseconds, like maxAge. +/** + * Settings that make the caching materials manager share requests like the MPL's StormTracking cache. + * Times are in milliseconds, like maxAge. + * + * Without stormTracking, concurrent misses for the same cache entry still share one backing request, + * but sharing adds no new failures or waits: + * - if the shared request fails, each waiting caller makes its own request + * - waiting callers don't time out + * - no limit on requests in flight * - * Without stormTracking, concurrent cache misses still share one request, - * but a caller never fails or waits in a way it would not without sharing: - * there is no limit on requests in flight, no timeout for waiting callers, - * and if the shared request fails, each waiting caller makes its own request. - * This differs from the MPL so that existing customers see no change in behavior. - * A future major version may align these defaults with the MPL. + * These defaults differ from the MPL so existing customers see no change. + * A future major version may align them. * * With stormTracking, unset settings take the MPL's defaults. */ export interface CachingMaterialsManagerStormTracking { - /* After a request has run this long, new callers stop waiting on it and make their own. - * If a request fails, waiting callers try again once it is this old. - * Default 1000 (1 second). + /** + * After a request has run this long, new callers stop waiting on it and make their own. + * After a failed request, waiting callers try again once it is this old. + * Default 1000. */ graceInterval?: number - /* At most this many requests to the backing materials manager are in flight at once, - * across every entry in the cache. Other callers wait. + /** + * At most this many backing requests are in flight at once, across every entry in the cache. + * Other callers wait. * Default 20. */ fanOut?: number - /* A caller that has waited this long fails. - * Default 10000 (10 seconds). Must be at least graceInterval. + /** + * A caller that has waited this long fails. + * Must be at least graceInterval. + * Default 10000. */ inFlightTTL?: number } diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 19004cbea..0a13d44f3 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -5,24 +5,21 @@ import { needs } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from './cryptographic_materials_cache' import type { CachingMaterialsManagerStormTracking } from './caching_cryptographic_materials_decorators' -/* If many encrypt or decrypt calls use the cache at the same time, - * cache misses for the same entry share one request to the backing materials manager. - * One caller asks the backing materials manager, and the rest wait for its answer. - * - * Internal: not exported from the package index. Tests change these values. +/* Shares backing requests between concurrent cache misses for the same entry. + * CachingMaterialsManagerStormTracking documents both modes. + * Internal: not exported from the package index. */ + +// Tests shorten these. export const SHARED_REQUESTS = { - /* Without storm tracking: after a request has run this long, it may be stuck, - * so new callers stop waiting on it and make their own. - */ + // Default mode: a request older than this may be stuck, so new callers make their own. graceInterval: 1000, // milliseconds == 1 second - // With storm tracking: how often a caller waiting for fanOut to free up checks again. + // stormTracking mode: how often a caller waiting for fanOut checks again. sleepMilli: 20, } -/* How a caching materials manager shares requests. */ export interface SharingSettings { - // false: the defaults described on CachingMaterialsManagerStormTracking. + // false: default mode. stormTracking: boolean graceInterval: number fanOut: number @@ -83,7 +80,7 @@ function isPositive(value: unknown): value is number { /* In-flight batches (one request plus its waiting callers) by cache, then cache key. * Keyed by cache so caching materials managers that share a cache also share requests. - * A cache key is removed once it has no batches, so this does not grow with every key ever used. + * closeBatch removes a cache key once it has no batches, so the map doesn't grow with every key ever used. */ export const batchesByCache = new WeakMap< CryptographicMaterialsCache, diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 19b38db11..335983110 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -318,7 +318,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { }) it('by default, waiting callers do not time out', async () => { - // A request slower than the storm tracking inFlightTTL still serves every waiting caller. + // Slower than FAST_STORM.inFlightTTL; every waiting caller still gets the material. const backing = slowBackingMaterialsManager({ ms: 150 }) const cmm = cachingCMM(backing) diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index 66ef77256..3db4ffe52 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -7,24 +7,16 @@ import { } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' -/* Concurrent callers that miss the cache for the same branch key share one keystore fetch. - * - * By default, sharing never makes a caller fail or wait in a way it would not without sharing: - * one caller fetches, the others wait for its answer, and if that fetch fails, - * each waiting caller makes its own fetch. There is no limit on fetches in flight - * and no timeout for waiting callers. - * - * The keyring's stormTracking option instead ports the MPL's StormTracker.dfy, - * which the other ESDKs use by default for the hierarchical keyring. - * - * Internal: not exported from the package index. Tests change these values. +/* Shares keystore fetches between concurrent cache misses for the same branch key. + * KmsHierarchicalKeyRingStormTracking documents both modes. + * Internal: not exported from the package index. */ + +// Tests shorten these. export const SHARED_FETCHES = { - /* By default: after a fetch has run this long it may be stuck, - * so new callers stop waiting on it and make their own. - */ + // Default mode: a fetch older than this may be stuck, so new callers make their own. graceInterval: 1000, // milliseconds - // With stormTracking: how often a waiting caller checks the cache again. + // stormTracking mode: how often a waiting caller checks the cache again. sleepMilli: 20, } @@ -44,9 +36,7 @@ export const STORM_TRACKING_DEFAULTS = Object.freeze({ inFlightTTL: 10, }) -/* --------------------------------------------------------------------------- - * Default: share one fetch, and fall back to each caller's own fetch on failure. - */ +// Default mode. interface SharedFetch { startedAt: number @@ -56,8 +46,8 @@ type SharedOutcome = { material: NodeBranchKeyMaterial } | { failed: true } /* In-flight fetches by cache, then cache entry id. * Keyed by cache so keyrings that share a cache also share fetches. - * Each fetch is its own object, so a slow fetch that settles late - * only removes itself, never a newer fetch for the same entry. + * Each fetch is its own object, so a slow fetch that settles late removes only itself, + * never a newer fetch for the same entry. */ const sharedFetches = new WeakMap< CryptographicMaterialsCache, @@ -74,9 +64,9 @@ function isRecent(fetch: SharedFetch) { return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval } -/* Fetch the branch key materials, sharing a fetch another caller already started. +/* Fetch the branch key materials, or wait for another caller's fetch of the same entry. * `fetch` asks the keystore; `put` caches the result. - * Returns materials this caller owns: a copy no cache will ever zero. + * Returns a copy only this caller holds. */ export async function sharedFetch( cmc: CryptographicMaterialsCache, @@ -86,17 +76,17 @@ export async function sharedFetch( ): Promise { const byId = sharedFetches.get(cmc) || new Map() sharedFetches.set(cmc, byId) - // Drop fetches nobody can join anymore. A fetch that never settles is forgotten here. + // Drop fetches nobody can join anymore. + // This is the only place a fetch that never settles gets forgotten. const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) const current = fetches[fetches.length - 1] if (current) { - // Another caller is fetching this branch key. Wait for its answer. const outcome = await new Promise((resolve) => current.members.push(resolve) ) if ('material' in outcome) return outcome.material - // That fetch failed. Make this caller's own fetch, as it would without sharing. + // The shared fetch failed: make this caller's own fetch, as it would without sharing. const material = await fetch() put(material) return deepCopyBranchKeyMaterial(material) @@ -122,9 +112,8 @@ export async function sharedFetch( throw error } close() - /* Give each waiting caller its own copy before caching the material, - * because the cache zeroes the material's buffer when it evicts it. - */ + // Copy before put: once cached, an eviction can zero the material's buffer. + for (const settle of mine.members) { settle({ material: deepCopyBranchKeyMaterial(material) }) } @@ -132,28 +121,24 @@ export async function sharedFetch( return deepCopyBranchKeyMaterial(material) } -/* --------------------------------------------------------------------------- - * With stormTracking: the MPL's StormTracker. - */ +// stormTracking mode: a port of the MPL's StormTracker.dfy. export type CacheState = 'use' | 'fetch' | 'wait' // Identifies one fetch, so a late fetch can't be mistaken for a newer one. export type FetchToken = symbol /* Tells each caller to use the cached entry, fetch it from the keystore, - * or wait and check the cache again because another caller is fetching it. + * or wait and check the cache again. */ export class StormTracker { - // Cache entries being fetched: when each fetch started, and a token for that fetch. private readonly inFlight = new Map< string, { startedAt: number; token: FetchToken } >() private lastPrune = 0 - /* For a cached entry that hasn't expired. - * Use it, unless it's within gracePeriod of expiring and nobody is refreshing it yet. - */ + // For an unexpired entry: use it, unless it's within gracePeriod of expiring and nobody is refreshing it. + checkEntry( id: string, expiresAt: number, @@ -169,10 +154,9 @@ export class StormTracker { return this.start(id, now) } - /* For an entry that's missing or expired. - * Fetch it, unless another caller started fetching it within the last graceInterval. - * Like the MPL, a failed fetch stays in flight, so waiting callers fetch again - * once it is graceInterval old. + /* For a missing or expired entry: fetch it, unless another caller started fetching it within graceInterval. + * A failed fetch stays in flight, as in the MPL, + * so the next fetch waits until the failed one is graceInterval old. */ checkNewEntry( id: string, @@ -188,8 +172,8 @@ export class StormTracker { } /* Call when a fetch succeeds. - * Only the newest fetch for an entry clears it, so a slow fetch that - * finishes late can't make callers think a newer fetch is done. + * Only the newest fetch for an entry clears it, + * so a slow fetch that finishes late can't end a newer one's wait. */ fetched(id: string, token: FetchToken) { if (this.inFlight.get(id)?.token === token) this.inFlight.delete(id) @@ -211,7 +195,7 @@ export class StormTracker { } /* Forget fetches older than inFlightTTL, so stuck or failed fetches stop counting toward fanOut. - * Like the MPL, only bother when fanOut is reached, and at most once a second. + * As in the MPL, this runs only when fanOut is reached, at most once a second. */ private pruneInFlight(now: number, settings: StormTrackingSettings) { if (this.inFlight.size < settings.fanOut) return @@ -244,7 +228,11 @@ export async function sleep(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)) } -/* A copy of the materials with its own buffer, which no cache will ever zero. */ +/* A copy with its own buffer. + * The cache zeroes a material's buffer when it evicts it, + * and callers derive keys from the branch key after getBranchKeyMaterials returns, + * so handing out the cached buffer lets an eviction zero a key mid-use (#1691). + */ export function deepCopyBranchKeyMaterial( material: NodeBranchKeyMaterial ): NodeBranchKeyMaterial { diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index 1306455c3..fa3a30ec4 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -79,42 +79,51 @@ export interface KmsHierarchicalKeyRingNodeInput { //# - MAY provide a [Partition ID](#partition-id) partitionId?: string utf8Sorting?: boolean - /* Opt in to behaving like the MPL's StormTracking cache. - * See KmsHierarchicalKeyRingStormTracking. - */ + /** Opt in to the MPL's StormTracking behavior; see {@link KmsHierarchicalKeyRingStormTracking}. */ stormTracking?: KmsHierarchicalKeyRingStormTracking } -/* Opt-in settings that make the keyring's branch key cache behave like - * the MPL's StormTracking cache, which the other ESDKs use by default. +/** + * Settings that make the keyring's branch key cache behave like the MPL's StormTracking cache, + * which the other ESDKs use by default. * Times are in seconds, like cacheLimitTtl. * - * Without stormTracking, concurrent cache misses for the same branch key - * still share one keystore fetch, but a caller never fails or waits in a way - * it would not without sharing: there is no limit on fetches in flight, - * no timeout for waiting callers, cached branch keys are never refreshed early, - * and if the shared fetch fails, each waiting caller makes its own fetch. - * This differs from the MPL so that existing customers see no change in behavior. - * A future major version may align these defaults with the MPL. + * Without stormTracking, concurrent misses for the same branch key still share one keystore fetch, + * but sharing adds no new failures or waits: + * - if the shared fetch fails, each waiting caller makes its own fetch + * - waiting callers don't time out + * - no limit on fetches in flight + * - no early refresh of cached branch keys + * + * These defaults differ from the MPL so existing customers see no change. + * A future major version may align them. * * With stormTracking, unset settings take the MPL's defaults, - * and the settings must satisfy the MPL's rules: - * graceInterval <= inFlightTTL <= gracePeriod < cacheLimitTtl. - * One difference from the MPL: if an early refresh fails, - * the caller uses the still-valid cached branch key instead of failing. + * and the settings must satisfy graceInterval <= inFlightTTL <= gracePeriod < cacheLimitTtl, as in the MPL. + * Unlike the MPL, a failed early refresh uses the still-valid cached branch key instead of failing the call. */ export interface KmsHierarchicalKeyRingStormTracking { - /* Before a cached branch key expires, for this long one caller at a time refreshes it - * from the keystore while other callers keep using the cached key. Default 10. + /** + * How long before a cached branch key expires one caller refreshes it, + * while other callers keep using the cached key. + * Default 10. */ gracePeriod?: number - /* After a fetch has run this long, the next caller fetches again. Default 1. */ + /** + * After a fetch has run this long, the next caller fetches again. + * Default 1. + */ graceInterval?: number - /* At most this many branch keys are fetched at once, across every keyring that - * shares the cache. Callers for other branch keys wait. Default 20. + /** + * At most this many branch keys are fetched at once, across every keyring that shares the cache. + * Callers for other branch keys wait. + * Default 20. */ fanOut?: number - /* A caller that has waited this long fails. Default 10. */ + /** + * A caller that has waited this long fails. + * Default 10. + */ inFlightTTL?: number } @@ -123,7 +132,7 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { branchKeyIdSupplier?: Readonly keyStore: Readonly cacheLimitTtl: number - // Milliseconds. Set only when the stormTracking input is given. + // Milliseconds; set only when the stormTracking input is given. _stormTracking?: Readonly _onEncrypt(material: NodeEncryptionMaterial): Promise _onDecrypt( diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index aed0d3a34..0a610956b 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -202,10 +202,8 @@ export async function getBranchKeyMaterials( ): Promise { const { keyStore, cacheLimitTtl } = hKeyring - /* A valid cached entry, as a copy the cache can never touch. The CMC zeroes a material's - * buffer in place on eviction; callers read the branch key AFTER this await, - * so a concurrent eviction (overwrite, TTL, or tail) could otherwise zero the - * buffer mid-derivation. */ + // The cached entry, or undefined if it's missing or past this keyring's TTL. + // Callers must copy its material before returning it; see deepCopyBranchKeyMaterial. const lookUp = () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) @@ -273,7 +271,6 @@ export async function getBranchKeyMaterials( //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. return deepCopyBranchKeyMaterial(cacheEntry.response) } - // Concurrent callers for the same branch key share one keystore fetch. return sharedFetch(cmc, cacheEntryId, fetch, put) } @@ -299,7 +296,6 @@ export async function getBranchKeyMaterials( token = decision.token continue } - // Someone else is fetching it. Check the cache again shortly. needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') await sleep(SHARED_FETCHES.sleepMilli) } @@ -308,10 +304,9 @@ export async function getBranchKeyMaterials( try { branchKeyMaterials = await fetch() } catch (error) { - /* If this was an early refresh of an entry in its grace period, - * the cached entry is still valid, so use it instead of failing. - * This differs from the MPL, which fails the call. - */ + // A failed early refresh leaves a valid cached entry, so use it. + // The MPL fails the call instead. + const cacheEntry = lookUp() if (cacheEntry) return deepCopyBranchKeyMaterial(cacheEntry.response) throw error diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 8bf0e958c..dfd51799c 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -315,7 +315,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it('waiting callers do not time out', async () => { - // Slower than the storm tracking inFlightTTL used below; every caller still succeeds. + // Slower than FAST_STORM.inFlightTTL; every caller still succeeds. const keyStore = slowKeyStore(250) await encryptConcurrently(keyringFor(keyStore), 10) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) From de7a7926312c73ba177e1fbc2c2fdd20c61e7db6 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 11:10:21 -0700 Subject: [PATCH 19/38] fix: restore default timings in 3000-caller tests, export stormTracking type The 3000-caller hierarchical keyring tests can block the event loop past the shortened test graceInterval on slower CI hosts, which starts a second fetch. Run them with the default timings. Also skip fanOut counting in default mode, and export CachingMaterialsManagerStormTracking from the caching CMM packages. --- .../src/caching_cryptographic_materials_decorators.ts | 1 + modules/caching-materials-manager-browser/src/index.ts | 5 ++++- modules/caching-materials-manager-node/src/index.ts | 5 ++++- modules/kms-keyring-node/src/branch_key_storm_tracker.ts | 2 -- modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts | 1 - .../test/kms_hkeyring_node.concurrency.test.ts | 4 ++++ 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index a6289786f..d547057dd 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -424,6 +424,7 @@ function fanOutReached( settings: SharingSettings, waitUntil: number ): boolean { + if (!settings.stormTracking) return false if (inFlightCount(cache, settings) < settings.fanOut) return false needs(Date.now() <= waitUntil, IN_FLIGHT_TTL_EXCEEDED) return true diff --git a/modules/caching-materials-manager-browser/src/index.ts b/modules/caching-materials-manager-browser/src/index.ts index 41f11f87d..5656228e8 100644 --- a/modules/caching-materials-manager-browser/src/index.ts +++ b/modules/caching-materials-manager-browser/src/index.ts @@ -2,4 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from './caching_materials_manager_browser' -export { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' +export { + getLocalCryptographicMaterialsCache, + CachingMaterialsManagerStormTracking, +} from '@aws-crypto/cache-material' diff --git a/modules/caching-materials-manager-node/src/index.ts b/modules/caching-materials-manager-node/src/index.ts index cefa1ba66..87c63917f 100644 --- a/modules/caching-materials-manager-node/src/index.ts +++ b/modules/caching-materials-manager-node/src/index.ts @@ -2,4 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from './caching_materials_manager_node' -export { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' +export { + getLocalCryptographicMaterialsCache, + CachingMaterialsManagerStormTracking, +} from '@aws-crypto/cache-material' diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts index 3db4ffe52..a6bebf689 100644 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts @@ -113,7 +113,6 @@ export async function sharedFetch( } close() // Copy before put: once cached, an eviction can zero the material's buffer. - for (const settle of mine.members) { settle({ material: deepCopyBranchKeyMaterial(material) }) } @@ -138,7 +137,6 @@ export class StormTracker { private lastPrune = 0 // For an unexpired entry: use it, unless it's within gracePeriod of expiring and nobody is refreshing it. - checkEntry( id: string, expiresAt: number, diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 0a610956b..80a3ef773 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -306,7 +306,6 @@ export async function getBranchKeyMaterials( } catch (error) { // A failed early refresh leaves a valid cached entry, so use it. // The MPL fails the call instead. - const cacheEntry = lookUp() if (cacheEntry) return deepCopyBranchKeyMaterial(cacheEntry.response) throw error diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index dfd51799c..d05f8b189 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -259,12 +259,16 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { + // 3000 callers can block the event loop past the shortened graceInterval. + Object.assign(SHARED_FETCHES, DEFAULT) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { + // 3000 callers can block the event loop past the shortened graceInterval. + Object.assign(SHARED_FETCHES, DEFAULT) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) From f32dba0f2dd3c5952afd83d50edccacfc4d0e825 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 11:19:28 -0700 Subject: [PATCH 20/38] docs: drop the no-new-waits claim from stormTracking docs A slow first call delays callers that joined it within graceInterval. --- .../src/caching_cryptographic_materials_decorators.ts | 2 +- modules/kms-keyring-node/src/kms_hkeyring_node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index d547057dd..0d939a80f 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -576,7 +576,7 @@ export interface CachingMaterialsManagerInput< * Times are in milliseconds, like maxAge. * * Without stormTracking, concurrent misses for the same cache entry still share one backing request, - * but sharing adds no new failures or waits: + * but sharing adds no new failures: * - if the shared request fails, each waiting caller makes its own request * - waiting callers don't time out * - no limit on requests in flight diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index fa3a30ec4..acb37e105 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -89,7 +89,7 @@ export interface KmsHierarchicalKeyRingNodeInput { * Times are in seconds, like cacheLimitTtl. * * Without stormTracking, concurrent misses for the same branch key still share one keystore fetch, - * but sharing adds no new failures or waits: + * but sharing adds no new failures: * - if the shared fetch fails, each waiting caller makes its own fetch * - waiting callers don't time out * - no limit on fetches in flight From 0de55e5801d3aed05562eaf0cc4308d099dba328 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 13:12:34 -0700 Subject: [PATCH 21/38] refactor: drop the stormTracking option Concurrent cache misses still share one backing request, which fixes #1663 and #1665. The opt-in MPL StormTracking settings (gracePeriod, graceInterval, fanOut, inFlightTTL) are removed, so this change adds no public API. Storm tracking can come back as a cache option later. --- ...hing_cryptographic_materials_decorators.ts | 284 ++++-------------- modules/cache-material/src/shared_requests.ts | 70 +---- ...c_materials_decorators.concurrency.test.ts | 122 +------- ...cryptographic_materials_decorators.test.ts | 76 ----- .../src/index.ts | 5 +- .../src/index.ts | 5 +- .../src/branch_key_storm_tracker.ts | 243 --------------- .../kms-keyring-node/src/kms_hkeyring_node.ts | 108 ------- .../src/kms_hkeyring_node_helpers.ts | 66 +--- .../src/shared_branch_key_fetches.ts | 118 ++++++++ .../kms_hkeyring_node.concurrency.test.ts | 188 +----------- .../kms_hkeyring_node.constructor.test.ts | 73 ----- 12 files changed, 208 insertions(+), 1150 deletions(-) delete mode 100644 modules/kms-keyring-node/src/branch_key_storm_tracker.ts create mode 100644 modules/kms-keyring-node/src/shared_branch_key_fetches.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 0d939a80f..4a36126ce 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -21,12 +21,7 @@ import { Entry, } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -import { - SHARED_REQUESTS, - SharingSettings, - batchesByCache, - resolveStormTracking, -} from './shared_requests' +import { SHARED_REQUESTS, batchesByCache } from './shared_requests' export function decorateProperties( obj: CachingMaterialsManager, @@ -39,7 +34,6 @@ export function decorateProperties( maxBytesEncrypted, maxMessagesEncrypted, partition, - stormTracking, } = input /* Precondition: A caching material manager needs a cache. */ @@ -82,7 +76,6 @@ export function decorateProperties( maxMessagesEncrypted || Maximum.MESSAGES_PER_CACHED_KEY_LIMIT ) readOnlyProperty(obj, '_partition', partition) - readOnlyProperty(obj, '_stormTracking', resolveStormTracking(stormTracking)) } export function getEncryptionMaterials({ @@ -117,55 +110,40 @@ export function getEncryptionMaterials({ * so it must not depend on this message's length. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - const settings = sharingSettings(this) - const waitUntil = Date.now() + settings.inFlightTTL - - for (;;) { - /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ - const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - } - - // If another caller's request has room for this message, wait for it instead of making a new one. - const batch = findBatch>( - this._cache, - cacheKey, - (b) => canJoin(this, settings, b, plaintextLength) - ) - if (!batch) { - // No await before startBatch, or concurrent callers all find no batch and each start one. - if (!fanOutReached(this._cache, settings, waitUntil)) break - await sleep(SHARED_REQUESTS.sleepMilli) - continue - } - const outcome = await joinBatch(batch, plaintextLength, waitUntil) - if ('material' in outcome) return outcome.material - /* Make this caller's own request, as it would without sharing, if - * the shared data key can't be cached (the suite isn't cache safe), or - * the shared request failed and storm tracking is off. + /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ + const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } + + // If another caller's request has room for this message, wait for it instead of making a new one. + const joinable = findBatch>( + this._cache, + cacheKey, + (b) => canJoin(this, b, plaintextLength) + ) + if (joinable) { + const outcome = await joinBatch(joinable, plaintextLength) + if ('material' in outcome) return outcome.material + /* The shared data key can't be cached, or the shared request failed. + * Make this caller's own request, as it would without sharing. */ - if ('uncached' in outcome || !settings.stormTracking) { - return cacheEncryptionMaterial( - this, - cacheKey, - await fetch(), - plaintextLength - ) - } - - // stormTracking mode: as in the MPL, try again once the failed request is graceInterval old. - await sleep(batch.startedAt + settings.graceInterval - Date.now()) + return cacheEncryptionMaterial( + this, + cacheKey, + await fetch(), + plaintextLength + ) } + // No await between findBatch and startBatch, or concurrent callers all find no batch and each start one. const batch = startBatch>( this._cache, cacheKey, this, - settings, plaintextLength ) let material: EncryptionMaterial @@ -173,14 +151,14 @@ export function getEncryptionMaterials({ material = await fetch() } catch (error) { closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, { error }) + settleMembers(batch, { failed: true }) throw error } closeBatch(this._cache, cacheKey, batch) // Can't cache it, so the waiting callers can't share it. if (!material.suite.cacheSafe) { - settleMembers(batch, { uncached: true }) + settleMembers(batch, { failed: true }) return material } @@ -192,7 +170,7 @@ export function getEncryptionMaterials({ bytesEncrypted: batch.bytes, } if (this._cacheEntryHasExceededLimits(used)) { - settleMembers(batch, { uncached: true }) + settleMembers(batch, { failed: true }) return material } @@ -204,7 +182,7 @@ export function getEncryptionMaterials({ this._maxAge ) } catch (error) { - settleMembers(batch, { error }) + settleMembers(batch, { failed: true }) throw error } // Count the waiting callers' messages against the cache entry too. @@ -283,45 +261,33 @@ export function decryptMaterials({ this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) return material } - const settings = sharingSettings(this) - const waitUntil = Date.now() + settings.inFlightTTL - - for (;;) { - /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ - const entry = this._cache.getDecryptionMaterial(cacheKey) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - } - - // If another caller has a request in flight, wait for it instead of making a new one. - const batch = findBatch>( - this._cache, - cacheKey, - (b) => isRecent(settings, b) - ) - if (!batch) { - // No await before startBatch, or concurrent callers all find no batch and each start one. - if (!fanOutReached(this._cache, settings, waitUntil)) break - await sleep(SHARED_REQUESTS.sleepMilli) - continue - } - const outcome = await joinBatch(batch, 0, waitUntil) - if ('material' in outcome) return outcome.material - // The shared request failed: in default mode, make this caller's own request. - if (!settings.stormTracking) return cloneResponse(await fetchAndCache()) + /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + const entry = this._cache.getDecryptionMaterial(cacheKey) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } - // stormTracking mode: as in the MPL, try again once the failed request is graceInterval old. - await sleep(batch.startedAt + settings.graceInterval - Date.now()) + // If another caller has a request in flight, wait for it instead of making a new one. + const joinable = findBatch>( + this._cache, + cacheKey, + isRecent + ) + if (joinable) { + const outcome = await joinBatch(joinable, 0) + if ('material' in outcome) return outcome.material + // The shared request failed: make this caller's own request, as it would without sharing. + return cloneResponse(await fetchAndCache()) } + // No await between findBatch and startBatch, or concurrent callers all find no batch and each start one. const batch = startBatch>( this._cache, cacheKey, this, - settings, 0 ) let material: DecryptionMaterial @@ -329,7 +295,7 @@ export function decryptMaterials({ material = await fetchAndCache() } catch (error) { closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, { error }) + settleMembers(batch, { failed: true }) throw error } closeBatch(this._cache, cacheKey, batch) @@ -340,22 +306,6 @@ export function decryptMaterials({ } } -const IN_FLIGHT_TTL_EXCEEDED = 'Caching materials manager inFlightTTL exceeded' - -// Default mode has no fanOut limit and no timeout for waiting callers. -function sharingSettings( - cmm: CachingMaterialsManager -): Readonly { - return ( - cmm._stormTracking || { - stormTracking: false, - graceInterval: SHARED_REQUESTS.graceInterval, - fanOut: Infinity, - inFlightTTL: Infinity, - } - ) -} - /* One request to the backing materials manager, plus the callers waiting for its answer. * `messages` and `bytes` count what its data key will encrypt for all of them, * so canJoin can keep the data key within its limits before it even exists. @@ -365,18 +315,13 @@ interface Batch { messages: number bytes: number requester: CachingMaterialsManager - members: Member[] -} - -interface Member { - plaintextLength: number - settle: (outcome: Outcome) => void + members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } /* What a waiting caller gets when the shared request finishes: - * the material, `uncached` if the material cannot be shared, or the request's error. + * the material, or `failed` if the request failed or its material can't be shared. */ -type Outcome = { material: M } | { uncached: true } | { error: unknown } +type Outcome = { material: M } | { failed: true } // Look up the list on every call: closeBatch deletes a key's list once it is empty. function findBatch( @@ -389,8 +334,8 @@ function findBatch( } /* A request older than graceInterval may be stuck, so new callers stop sharing it. */ -function isRecent(settings: SharingSettings, batch: Batch) { - return Date.now() < batch.startedAt + settings.graceInterval +function isRecent(batch: Batch) { + return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } /* Whether this caller's message still fits in the batch's data key. @@ -399,11 +344,10 @@ function isRecent(settings: SharingSettings, batch: Batch) { */ function canJoin( cmm: CachingMaterialsManager, - settings: SharingSettings, batch: Batch, plaintextLength: number ) { - if (!isRecent(settings, batch)) return false + if (!isRecent(batch)) return false const next = { response: undefined as any, now: Date.now(), @@ -416,54 +360,17 @@ function canJoin( ) } -/* stormTracking mode: whether fanOut requests are already in flight on this cache. - * Throws once the caller has waited past inFlightTTL. - */ -function fanOutReached( - cache: CryptographicMaterialsCache, - settings: SharingSettings, - waitUntil: number -): boolean { - if (!settings.stormTracking) return false - if (inFlightCount(cache, settings) < settings.fanOut) return false - needs(Date.now() <= waitUntil, IN_FLIGHT_TTL_EXCEEDED) - return true -} - -/* Requests in flight on this cache, across all cache keys. - * As in the MPL, a request older than inFlightTTL stops counting. - */ -function inFlightCount( - cache: CryptographicMaterialsCache, - settings: SharingSettings -) { - const now = Date.now() - let count = 0 - for (const batches of batchesByCache.get(cache)?.values() || []) { - for (const batch of batches as Batch[]) { - if (now < batch.startedAt + settings.inFlightTTL) count += 1 - } - } - return count -} - function startBatch( cache: CryptographicMaterialsCache, cacheKey: string, requester: CachingMaterialsManager, - settings: SharingSettings, plaintextLength: number ): Batch { - let byKey = batchesByCache.get(cache) - if (!byKey) { - byKey = new Map() - batchesByCache.set(cache, byKey) - } + const byKey = batchesByCache.get(cache) || new Map[]>() + batchesByCache.set(cache, byKey) // Drop batches nobody can join anymore. // This is the only place a request that never settles gets forgotten. - const batches = (byKey.get(cacheKey) || []).filter((b) => - isRecent(settings, b) - ) + const batches = (byKey.get(cacheKey) || []).filter(isRecent) byKey.set(cacheKey, batches) const batch: Batch = { startedAt: Date.now(), @@ -490,43 +397,22 @@ function closeBatch( if (!batches.length) byKey.delete(cacheKey) } -/* Wait for the shared request's answer, counting this caller's message against its data key. - * In stormTracking mode, fails once waitUntil passes. - */ +/* Wait for the shared request's answer, counting this caller's message against its data key. */ async function joinBatch( batch: Batch, - plaintextLength: number, - waitUntil: number + plaintextLength: number ): Promise> { batch.messages += 1 batch.bytes += plaintextLength - let timer: ReturnType | undefined - const outcome = await new Promise | undefined>((resolve) => { - const member = { plaintextLength, settle: resolve } - batch.members.push(member) - if (waitUntil === Infinity) return - timer = setTimeout(() => { - // A caller that times out never gets the data key, so stop cloning it for this caller or counting its message. - const index = batch.members.indexOf(member) - if (index !== -1) batch.members.splice(index, 1) - batch.messages -= 1 - batch.bytes -= plaintextLength - resolve(undefined) - }, Math.max(0, waitUntil - Date.now())) - }) - if (timer) clearTimeout(timer) - needs(outcome, IN_FLIGHT_TTL_EXCEEDED) - return outcome + return new Promise>((settle) => + batch.members.push({ plaintextLength, settle }) + ) } function settleMembers(batch: Batch, outcome: Outcome) { for (const member of batch.members) member.settle(outcome) } -async function sleep(ms: number) { - if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms)) -} - export function cacheEntryHasExceededLimits< S extends SupportedAlgorithmSuites >(): CacheEntryHasExceededLimits { @@ -567,46 +453,8 @@ export interface CachingMaterialsManagerInput< maxBytesEncrypted?: number maxMessagesEncrypted?: number maxAge: number - /** Opt in to the MPL's StormTracking behavior; see {@link CachingMaterialsManagerStormTracking}. */ - stormTracking?: CachingMaterialsManagerStormTracking }> {} -/** - * Settings that make the caching materials manager share requests like the MPL's StormTracking cache. - * Times are in milliseconds, like maxAge. - * - * Without stormTracking, concurrent misses for the same cache entry still share one backing request, - * but sharing adds no new failures: - * - if the shared request fails, each waiting caller makes its own request - * - waiting callers don't time out - * - no limit on requests in flight - * - * These defaults differ from the MPL so existing customers see no change. - * A future major version may align them. - * - * With stormTracking, unset settings take the MPL's defaults. - */ -export interface CachingMaterialsManagerStormTracking { - /** - * After a request has run this long, new callers stop waiting on it and make their own. - * After a failed request, waiting callers try again once it is this old. - * Default 1000. - */ - graceInterval?: number - /** - * At most this many backing requests are in flight at once, across every entry in the cache. - * Other callers wait. - * Default 20. - */ - fanOut?: number - /** - * A caller that has waited this long fails. - * Must be at least graceInterval. - * Default 10000. - */ - inFlightTTL?: number -} - export interface CachingMaterialsManagerDecorateInput< S extends SupportedAlgorithmSuites > extends CachingMaterialsManagerInput { @@ -622,8 +470,6 @@ export interface CachingMaterialsManager readonly _maxBytesEncrypted: number readonly _maxMessagesEncrypted: number readonly _maxAge: number - // Set by decorateProperties when the stormTracking input is given. - readonly _stormTracking?: Readonly _cacheEntryHasExceededLimits: CacheEntryHasExceededLimits } diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 0a13d44f3..c02936abb 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -1,81 +1,17 @@ // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { needs } from '@aws-crypto/material-management' import { CryptographicMaterialsCache } from './cryptographic_materials_cache' -import type { CachingMaterialsManagerStormTracking } from './caching_cryptographic_materials_decorators' -/* Shares backing requests between concurrent cache misses for the same entry. - * CachingMaterialsManagerStormTracking documents both modes. +/* Concurrent cache misses for the same entry share one request to the backing materials manager. + * One caller makes the request, and the others wait for its answer. * Internal: not exported from the package index. */ // Tests shorten these. export const SHARED_REQUESTS = { - // Default mode: a request older than this may be stuck, so new callers make their own. + // A request older than this may be stuck, so new callers make their own. graceInterval: 1000, // milliseconds == 1 second - // stormTracking mode: how often a caller waiting for fanOut checks again. - sleepMilli: 20, -} - -export interface SharingSettings { - // false: default mode. - stormTracking: boolean - graceInterval: number - fanOut: number - inFlightTTL: number -} - -export const STORM_TRACKING_DEFAULTS = Object.freeze({ - graceInterval: 1000, - fanOut: 20, - inFlightTTL: 10 * 1000, -}) - -export function resolveStormTracking( - stormTracking?: CachingMaterialsManagerStormTracking -): Readonly | undefined { - if (stormTracking === undefined) return undefined - /* Precondition: stormTracking must be an object. */ - needs( - stormTracking && typeof stormTracking === 'object', - 'stormTracking must be an object.' - ) - const { - graceInterval = STORM_TRACKING_DEFAULTS.graceInterval, - fanOut = STORM_TRACKING_DEFAULTS.fanOut, - inFlightTTL = STORM_TRACKING_DEFAULTS.inFlightTTL, - } = stormTracking - /* Precondition: stormTracking.graceInterval must be a positive number. */ - needs( - isPositive(graceInterval), - 'stormTracking.graceInterval must be a positive number.' - ) - /* Precondition: stormTracking.inFlightTTL must be a positive number. */ - needs( - isPositive(inFlightTTL), - 'stormTracking.inFlightTTL must be a positive number.' - ) - /* Precondition: stormTracking.fanOut must be a positive integer. */ - needs( - Number.isSafeInteger(fanOut) && fanOut > 0, - 'stormTracking.fanOut must be a positive integer.' - ) - /* Precondition: stormTracking.graceInterval must not exceed stormTracking.inFlightTTL. */ - needs( - graceInterval <= inFlightTTL, - 'stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.' - ) - return Object.freeze({ - stormTracking: true, - graceInterval, - fanOut, - inFlightTTL, - }) -} - -function isPositive(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) && value > 0 } /* In-flight batches (one request plus its waiting callers) by cache, then cache key. diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 335983110..f75d346cd 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -9,7 +9,6 @@ import { decorateProperties, getEncryptionMaterials, decryptMaterials, - CachingMaterialsManagerStormTracking, } from '../src/caching_cryptographic_materials_decorators' import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' import * as cacheMaterial from '../src/index' @@ -52,12 +51,8 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( ) // Shortened timings, so the tests run in milliseconds. -const FAST = { graceInterval: 20, sleepMilli: 2 } +const FAST = { graceInterval: 20 } const DEFAULT = { ...SHARED_REQUESTS } -const FAST_STORM: CachingMaterialsManagerStormTracking = { - graceInterval: 20, - inFlightTTL: 100, -} // A backing materials manager that takes `ms` milliseconds per request and returns a new data key each time. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. @@ -110,9 +105,6 @@ function cachingCMM( maxMessagesEncrypted = 1000, maxBytesEncrypted = 1000, cache = getLocalCryptographicMaterialsCache(100) as any, - stormTracking = undefined as - | CachingMaterialsManagerStormTracking - | undefined, } = {} ) { const cmm = { @@ -128,7 +120,6 @@ function cachingCMM( maxAge: 1000 * 60, maxBytesEncrypted, maxMessagesEncrypted, - stormTracking, }) return cmm } @@ -278,7 +269,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(1) }) - it('by default, waiting callers make their own request when the shared one fails', async () => { + it('waiting callers make their own request when the shared one fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) @@ -289,7 +280,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(10) }) - it('by default, a failing backing materials manager fails each caller with its own error', async () => { + it('a failing backing materials manager fails each caller with its own error', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail'] }) const cmm = cachingCMM(backing) @@ -303,7 +294,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(10) }) - it('by default, decrypts make their own request when the shared one fails', async () => { + it('decrypts make their own request when the shared one fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) @@ -317,8 +308,8 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.decrypt).to.equal(5) }) - it('by default, waiting callers do not time out', async () => { - // Slower than FAST_STORM.inFlightTTL; every waiting caller still gets the material. + it('waiting callers do not time out', async () => { + // Waiting callers have no timeout, however long the shared request takes. const backing = slowBackingMaterialsManager({ ms: 150 }) const cmm = cachingCMM(backing) @@ -463,104 +454,3 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { } }) }) - -describe('caching materials manager: concurrent cache misses with stormTracking', () => { - beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) - afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) - - it('waiting callers try again after a failed request, and share the retry', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) - const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) - - const results = await Promise.allSettled(startEncrypts(cmm, times(10))) - - expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(backing.calls.encrypt).to.equal(2) - }) - - it('decrypts try again after a failed request, and share the retry', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) - const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) - - const results = await Promise.allSettled( - Array.from({ length: 5 }, async () => - cmm.decryptMaterials(decryptRequest) - ) - ) - - expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(backing.calls.decrypt).to.equal(2) - }) - - it('fails waiting callers after inFlightTTL when the request hangs', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['hang'] }) - const cmm = cachingCMM(backing, { stormTracking: FAST_STORM }) - - const [, ...waiters] = startEncrypts(cmm, times(5)) - const results = await Promise.allSettled(waiters) - - for (const result of results) { - expect((result as PromiseRejectedResult).reason.message).to.equal( - 'Caching materials manager inFlightTTL exceeded' - ) - } - expect(backing.calls.encrypt).to.equal(1) - }) - - it('a caller that times out is not given or charged for the data key', async () => { - // The request outlasts inFlightTTL, so the 4 waiting callers time out before it returns. - const backing = slowBackingMaterialsManager({ ms: 150 }) - const cmm = cachingCMM(backing, { - maxMessagesEncrypted: 5, - stormTracking: FAST_STORM, - }) - - const [first, ...waiters] = startEncrypts(cmm, times(5)) - const results = await Promise.allSettled(waiters) - for (const result of results) expect(result.status).to.equal('rejected') - const leader = await first - - // Only the first message used the data key, so 4 more can reuse it. - const later = [] - for (let i = 0; i < 4; i++) - later.push(await cmm.getEncryptionMaterials(encryptRequest)) - expect(backing.calls.encrypt).to.equal(1) - expect(usageByDataKey([leader, ...later], times(5))).to.have.lengthOf(1) - }) - - it('makes at most fanOut backing requests at once', async () => { - const backing = slowBackingMaterialsManager({ ms: 10 }) - const cmm = cachingCMM(backing, { - stormTracking: { ...FAST_STORM, fanOut: 2 }, - }) - - await Promise.all( - ['a', 'b', 'c', 'd'].map(async (a) => - cmm.getEncryptionMaterials({ - ...encryptRequest, - encryptionContext: { a }, - }) - ) - ) - - expect(backing.calls.encrypt).to.equal(4) - expect(backing.calls.peak).to.equal(2) - }) - - it('a request older than inFlightTTL stops counting toward fanOut', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) - const cmm = cachingCMM(backing, { - stormTracking: { ...FAST_STORM, fanOut: 1 }, - }) - - startEncrypts(cmm, times(1), { encryptionContext: { a: 'a' } }) - await new Promise((resolve) => setTimeout(resolve, 1)) - const started = Date.now() - await encryptConcurrently(cmm, times(1), { encryptionContext: { a: 'b' } }) - - expect(Date.now() - started).to.be.at.least( - (FAST_STORM.inFlightTTL as number) - 10 - ) - expect(backing.calls.encrypt).to.equal(2) - }) -}) diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts index a21816f6f..c439dd148 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.test.ts @@ -109,82 +109,6 @@ describe('decorateProperties', () => { } as any expect(() => decorateProperties(test, input)).to.throw() }) - - describe('stormTracking', () => { - const decorate = (stormTracking: any) => { - const test = {} as any - decorateProperties(test, { - cache: 'cache' as any, - backingMaterialsManager: 'backingMaterialsManager' as any, - maxAge: 10, - partition: 'something', - stormTracking, - } as any) - return test._stormTracking - } - - it('is off unless given', () => { - expect(decorate(undefined)).to.equal(undefined) - }) - - it('takes the MPL defaults for unset settings', () => { - expect(decorate({})).to.deep.equal({ - stormTracking: true, - graceInterval: 1000, - fanOut: 20, - inFlightTTL: 10000, - }) - expect( - decorate({ graceInterval: 5, fanOut: 3, inFlightTTL: 50 }) - ).to.deep.equal({ - stormTracking: true, - graceInterval: 5, - fanOut: 3, - inFlightTTL: 50, - }) - }) - - it('Precondition: stormTracking must be an object.', () => { - for (const value of [null, true, 1, 'x']) { - expect(() => decorate(value)).to.throw( - 'stormTracking must be an object.' - ) - } - }) - - it('Precondition: stormTracking.graceInterval must be a positive number.', () => { - for (const graceInterval of [0, -1, NaN, Infinity, '1', null]) { - expect(() => decorate({ graceInterval })).to.throw( - 'stormTracking.graceInterval must be a positive number.' - ) - } - }) - - it('Precondition: stormTracking.inFlightTTL must be a positive number.', () => { - for (const inFlightTTL of [0, -1, NaN, Infinity, '1', null]) { - expect(() => decorate({ inFlightTTL })).to.throw( - 'stormTracking.inFlightTTL must be a positive number.' - ) - } - }) - - it('Precondition: stormTracking.fanOut must be a positive integer.', () => { - for (const fanOut of [0, -1, 1.5, NaN, Infinity, '1', null]) { - expect(() => decorate({ fanOut })).to.throw( - 'stormTracking.fanOut must be a positive integer.' - ) - } - }) - - it('Precondition: stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.', () => { - expect(() => decorate({ graceInterval: 11, inFlightTTL: 10 })).to.throw( - 'stormTracking.graceInterval must not exceed stormTracking.inFlightTTL.' - ) - expect( - decorate({ graceInterval: 10, inFlightTTL: 10 }).graceInterval - ).to.equal(10) - }) - }) }) describe('cacheEntryHasExceededLimits', () => { diff --git a/modules/caching-materials-manager-browser/src/index.ts b/modules/caching-materials-manager-browser/src/index.ts index 5656228e8..41f11f87d 100644 --- a/modules/caching-materials-manager-browser/src/index.ts +++ b/modules/caching-materials-manager-browser/src/index.ts @@ -2,7 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './caching_materials_manager_browser' -export { - getLocalCryptographicMaterialsCache, - CachingMaterialsManagerStormTracking, -} from '@aws-crypto/cache-material' +export { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' diff --git a/modules/caching-materials-manager-node/src/index.ts b/modules/caching-materials-manager-node/src/index.ts index 87c63917f..cefa1ba66 100644 --- a/modules/caching-materials-manager-node/src/index.ts +++ b/modules/caching-materials-manager-node/src/index.ts @@ -2,7 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './caching_materials_manager_node' -export { - getLocalCryptographicMaterialsCache, - CachingMaterialsManagerStormTracking, -} from '@aws-crypto/cache-material' +export { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' diff --git a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts b/modules/kms-keyring-node/src/branch_key_storm_tracker.ts deleted file mode 100644 index a6bebf689..000000000 --- a/modules/kms-keyring-node/src/branch_key_storm_tracker.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - NodeAlgorithmSuite, - NodeBranchKeyMaterial, -} from '@aws-crypto/material-management' -import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' - -/* Shares keystore fetches between concurrent cache misses for the same branch key. - * KmsHierarchicalKeyRingStormTracking documents both modes. - * Internal: not exported from the package index. - */ - -// Tests shorten these. -export const SHARED_FETCHES = { - // Default mode: a fetch older than this may be stuck, so new callers make their own. - graceInterval: 1000, // milliseconds - // stormTracking mode: how often a waiting caller checks the cache again. - sleepMilli: 20, -} - -/* The stormTracking settings, in milliseconds. */ -export interface StormTrackingSettings { - gracePeriod: number - graceInterval: number - fanOut: number - inFlightTTL: number -} - -/* The MPL's DefaultStorm() settings, in seconds. */ -export const STORM_TRACKING_DEFAULTS = Object.freeze({ - gracePeriod: 10, - graceInterval: 1, - fanOut: 20, - inFlightTTL: 10, -}) - -// Default mode. - -interface SharedFetch { - startedAt: number - members: ((outcome: SharedOutcome) => void)[] -} -type SharedOutcome = { material: NodeBranchKeyMaterial } | { failed: true } - -/* In-flight fetches by cache, then cache entry id. - * Keyed by cache so keyrings that share a cache also share fetches. - * Each fetch is its own object, so a slow fetch that settles late removes only itself, - * never a newer fetch for the same entry. - */ -const sharedFetches = new WeakMap< - CryptographicMaterialsCache, - Map ->() - -export function sharedFetchesFor( - cmc: CryptographicMaterialsCache -) { - return sharedFetches.get(cmc) -} - -function isRecent(fetch: SharedFetch) { - return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval -} - -/* Fetch the branch key materials, or wait for another caller's fetch of the same entry. - * `fetch` asks the keystore; `put` caches the result. - * Returns a copy only this caller holds. - */ -export async function sharedFetch( - cmc: CryptographicMaterialsCache, - cacheEntryId: string, - fetch: () => Promise, - put: (material: NodeBranchKeyMaterial) => void -): Promise { - const byId = sharedFetches.get(cmc) || new Map() - sharedFetches.set(cmc, byId) - // Drop fetches nobody can join anymore. - // This is the only place a fetch that never settles gets forgotten. - const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) - const current = fetches[fetches.length - 1] - - if (current) { - const outcome = await new Promise((resolve) => - current.members.push(resolve) - ) - if ('material' in outcome) return outcome.material - // The shared fetch failed: make this caller's own fetch, as it would without sharing. - const material = await fetch() - put(material) - return deepCopyBranchKeyMaterial(material) - } - - const mine: SharedFetch = { startedAt: Date.now(), members: [] } - fetches.push(mine) - byId.set(cacheEntryId, fetches) - const close = () => { - const list = byId.get(cacheEntryId) - if (!list) return - const index = list.indexOf(mine) - if (index !== -1) list.splice(index, 1) - if (!list.length) byId.delete(cacheEntryId) - } - - let material: NodeBranchKeyMaterial - try { - material = await fetch() - } catch (error) { - close() - for (const settle of mine.members) settle({ failed: true }) - throw error - } - close() - // Copy before put: once cached, an eviction can zero the material's buffer. - for (const settle of mine.members) { - settle({ material: deepCopyBranchKeyMaterial(material) }) - } - put(material) - return deepCopyBranchKeyMaterial(material) -} - -// stormTracking mode: a port of the MPL's StormTracker.dfy. - -export type CacheState = 'use' | 'fetch' | 'wait' -// Identifies one fetch, so a late fetch can't be mistaken for a newer one. -export type FetchToken = symbol - -/* Tells each caller to use the cached entry, fetch it from the keystore, - * or wait and check the cache again. - */ -export class StormTracker { - private readonly inFlight = new Map< - string, - { startedAt: number; token: FetchToken } - >() - private lastPrune = 0 - - // For an unexpired entry: use it, unless it's within gracePeriod of expiring and nobody is refreshing it. - checkEntry( - id: string, - expiresAt: number, - now: number, - settings: StormTrackingSettings - ): { state: CacheState; token?: FetchToken } { - if (this.fanOutReached(now, settings)) return { state: 'use' } - if (now < expiresAt - settings.gracePeriod) return { state: 'use' } - const current = this.inFlight.get(id) - if (current && now < current.startedAt + settings.graceInterval) { - return { state: 'use' } - } - return this.start(id, now) - } - - /* For a missing or expired entry: fetch it, unless another caller started fetching it within graceInterval. - * A failed fetch stays in flight, as in the MPL, - * so the next fetch waits until the failed one is graceInterval old. - */ - checkNewEntry( - id: string, - now: number, - settings: StormTrackingSettings - ): { state: CacheState; token?: FetchToken } { - if (this.fanOutReached(now, settings)) return { state: 'wait' } - const current = this.inFlight.get(id) - if (current && now < current.startedAt + settings.graceInterval) { - return { state: 'wait' } - } - return this.start(id, now) - } - - /* Call when a fetch succeeds. - * Only the newest fetch for an entry clears it, - * so a slow fetch that finishes late can't end a newer one's wait. - */ - fetched(id: string, token: FetchToken) { - if (this.inFlight.get(id)?.token === token) this.inFlight.delete(id) - } - - inFlightCount() { - return this.inFlight.size - } - - private start(id: string, now: number) { - const token = Symbol('fetch') - this.inFlight.set(id, { startedAt: now, token }) - return { state: 'fetch' as const, token } - } - - private fanOutReached(now: number, settings: StormTrackingSettings) { - this.pruneInFlight(now, settings) - return this.inFlight.size >= settings.fanOut - } - - /* Forget fetches older than inFlightTTL, so stuck or failed fetches stop counting toward fanOut. - * As in the MPL, this runs only when fanOut is reached, at most once a second. - */ - private pruneInFlight(now: number, settings: StormTrackingSettings) { - if (this.inFlight.size < settings.fanOut) return - if (now - 1000 < this.lastPrune) return - this.lastPrune = now - for (const [id, { startedAt }] of this.inFlight) { - if (now >= startedAt + settings.inFlightTTL) this.inFlight.delete(id) - } - } -} - -/* One tracker per cache, so keyrings that share a cache also share fetches. */ -const trackers = new WeakMap< - CryptographicMaterialsCache, - StormTracker ->() - -export function stormTrackerFor( - cmc: CryptographicMaterialsCache -): StormTracker { - let tracker = trackers.get(cmc) - if (!tracker) { - tracker = new StormTracker() - trackers.set(cmc, tracker) - } - return tracker -} - -export async function sleep(ms: number) { - await new Promise((resolve) => setTimeout(resolve, ms)) -} - -/* A copy with its own buffer. - * The cache zeroes a material's buffer when it evicts it, - * and callers derive keys from the branch key after getBranchKeyMaterials returns, - * so handing out the cached buffer lets an eviction zero a key mid-use (#1691). - */ -export function deepCopyBranchKeyMaterial( - material: NodeBranchKeyMaterial -): NodeBranchKeyMaterial { - return new NodeBranchKeyMaterial( - Buffer.from(material.branchKey()), - material.branchKeyIdentifier, - material.branchKeyVersion.toString('utf-8'), - { ...material.encryptionContext } - ) -} diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node.ts b/modules/kms-keyring-node/src/kms_hkeyring_node.ts index acb37e105..f0a5ad8c2 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node.ts @@ -50,10 +50,6 @@ import { isBranchKeyIdSupplier, } from '@aws-crypto/kms-keyring' import { randomBytes } from 'crypto' -import { - StormTrackingSettings, - STORM_TRACKING_DEFAULTS, -} from './branch_key_storm_tracker' export interface KmsHierarchicalKeyRingNodeInput { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#initialization @@ -79,52 +75,6 @@ export interface KmsHierarchicalKeyRingNodeInput { //# - MAY provide a [Partition ID](#partition-id) partitionId?: string utf8Sorting?: boolean - /** Opt in to the MPL's StormTracking behavior; see {@link KmsHierarchicalKeyRingStormTracking}. */ - stormTracking?: KmsHierarchicalKeyRingStormTracking -} - -/** - * Settings that make the keyring's branch key cache behave like the MPL's StormTracking cache, - * which the other ESDKs use by default. - * Times are in seconds, like cacheLimitTtl. - * - * Without stormTracking, concurrent misses for the same branch key still share one keystore fetch, - * but sharing adds no new failures: - * - if the shared fetch fails, each waiting caller makes its own fetch - * - waiting callers don't time out - * - no limit on fetches in flight - * - no early refresh of cached branch keys - * - * These defaults differ from the MPL so existing customers see no change. - * A future major version may align them. - * - * With stormTracking, unset settings take the MPL's defaults, - * and the settings must satisfy graceInterval <= inFlightTTL <= gracePeriod < cacheLimitTtl, as in the MPL. - * Unlike the MPL, a failed early refresh uses the still-valid cached branch key instead of failing the call. - */ -export interface KmsHierarchicalKeyRingStormTracking { - /** - * How long before a cached branch key expires one caller refreshes it, - * while other callers keep using the cached key. - * Default 10. - */ - gracePeriod?: number - /** - * After a fetch has run this long, the next caller fetches again. - * Default 1. - */ - graceInterval?: number - /** - * At most this many branch keys are fetched at once, across every keyring that shares the cache. - * Callers for other branch keys wait. - * Default 20. - */ - fanOut?: number - /** - * A caller that has waited this long fails. - * Default 10. - */ - inFlightTTL?: number } export interface IKmsHierarchicalKeyRingNode extends KeyringNode { @@ -132,8 +82,6 @@ export interface IKmsHierarchicalKeyRingNode extends KeyringNode { branchKeyIdSupplier?: Readonly keyStore: Readonly cacheLimitTtl: number - // Milliseconds; set only when the stormTracking input is given. - _stormTracking?: Readonly _onEncrypt(material: NodeEncryptionMaterial): Promise _onDecrypt( material: NodeDecryptionMaterial, @@ -154,7 +102,6 @@ export class KmsHierarchicalKeyRingNode public declare keyStore: Readonly public declare _logicalKeyStoreName: Buffer public declare cacheLimitTtl: number - public declare _stormTracking?: Readonly public declare maxCacheSize?: number public declare _cmc: CryptographicMaterialsCache declare readonly _partition: Buffer @@ -169,7 +116,6 @@ export class KmsHierarchicalKeyRingNode maxCacheSize, partitionId, utf8Sorting, - stormTracking, }: KmsHierarchicalKeyRingNodeInput) { super() @@ -270,12 +216,6 @@ export class KmsHierarchicalKeyRingNode // convert seconds to milliseconds readOnlyProperty(this, 'cacheLimitTtl', cacheLimitTtl * 1000) - readOnlyProperty( - this, - '_stormTracking', - resolveStormTracking(stormTracking, cacheLimitTtl) - ) - readOnlyProperty(this, 'branchKeyId', branchKeyId) readOnlyProperty( @@ -556,51 +496,3 @@ immutableClass(KmsHierarchicalKeyRingNode) //# Users MUST make sure that all of Partition ID, Logical Key Store Name of the Key Store for the Hierarchical Keyring //# and Branch Key ID are set to be the same for two Hierarchical Keyrings if and only they want the keyrings to share //# cache entries. - -function resolveStormTracking( - stormTracking: KmsHierarchicalKeyRingStormTracking | undefined, - cacheLimitTtl: number -): Readonly | undefined { - if (stormTracking === undefined) return undefined - /* Precondition: stormTracking must be an object */ - needs( - stormTracking && typeof stormTracking === 'object', - 'stormTracking must be an object' - ) - const { - gracePeriod = STORM_TRACKING_DEFAULTS.gracePeriod, - graceInterval = STORM_TRACKING_DEFAULTS.graceInterval, - fanOut = STORM_TRACKING_DEFAULTS.fanOut, - inFlightTTL = STORM_TRACKING_DEFAULTS.inFlightTTL, - } = stormTracking - /* Precondition: stormTracking times must be positive numbers */ - needs( - [gracePeriod, graceInterval, inFlightTTL].every( - (value) => - typeof value === 'number' && Number.isFinite(value) && value > 0 - ), - 'stormTracking gracePeriod, graceInterval, and inFlightTTL must be positive numbers' - ) - /* Precondition: stormTracking fanOut must be a positive integer */ - needs( - Number.isSafeInteger(fanOut) && fanOut > 0, - 'stormTracking fanOut must be a positive integer' - ) - /* Precondition: stormTracking settings must be consistent, as in the MPL */ - needs( - graceInterval <= inFlightTTL && inFlightTTL <= gracePeriod, - 'stormTracking graceInterval must not exceed inFlightTTL, and inFlightTTL must not exceed gracePeriod' - ) - /* Precondition: stormTracking gracePeriod must be less than the cache limit TTL */ - needs( - gracePeriod < cacheLimitTtl, - 'stormTracking gracePeriod must be less than the cache limit TTL' - ) - // convert seconds to milliseconds - return Object.freeze({ - gracePeriod: gracePeriod * 1000, - graceInterval: graceInterval * 1000, - fanOut, - inFlightTTL: inFlightTTL * 1000, - }) -} diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 80a3ef773..2c71a8133 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -36,12 +36,8 @@ import { BranchKeyIdSupplier } from '@aws-crypto/kms-keyring' import { serializeFactory, uuidv4Factory } from '@aws-crypto/serialize' import { deepCopyBranchKeyMaterial, - FetchToken, sharedFetch, - sleep, - SHARED_FETCHES, - stormTrackerFor, -} from './branch_key_storm_tracker' +} from './shared_branch_key_fetches' export const stringToUtf8Bytes = (input: string): Buffer => Buffer.from(input, 'utf-8') @@ -203,7 +199,6 @@ export async function getBranchKeyMaterials( const { keyStore, cacheLimitTtl } = hKeyring // The cached entry, or undefined if it's missing or past this keyring's TTL. - // Callers must copy its material before returning it; see deepCopyBranchKeyMaterial. const lookUp = () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) @@ -260,59 +255,16 @@ export async function getBranchKeyMaterials( const put = (material: NodeBranchKeyMaterial) => cmc.putBranchKeyMaterial(cacheEntryId, material, cacheLimitTtl) - const storm = hKeyring._stormTracking - if (!storm) { - const cacheEntry = lookUp() - if (cacheEntry) { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. - return deepCopyBranchKeyMaterial(cacheEntry.response) - } - return sharedFetch(cmc, cacheEntryId, fetch, put) - } + const cacheEntry = lookUp() + if (cacheEntry) { + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. - // With stormTracking, behave like the MPL's StormTracking cache. - const tracker = stormTrackerFor(cmc) - const waitUntil = Date.now() + storm.inFlightTTL - let token: FetchToken | undefined - while (!token) { - const now = Date.now() - const cacheEntry = lookUp() - const decision = cacheEntry - ? tracker.checkEntry( - cacheEntryId, - cacheEntry.now + cacheLimitTtl, - now, - storm - ) - : tracker.checkNewEntry(cacheEntryId, now, storm) - if (cacheEntry && decision.state === 'use') { - return deepCopyBranchKeyMaterial(cacheEntry.response) - } - if (decision.state === 'fetch') { - token = decision.token - continue - } - needs(Date.now() <= waitUntil, 'Storm cache inFlightTTL exceeded') - await sleep(SHARED_FETCHES.sleepMilli) - } - - let branchKeyMaterials: NodeBranchKeyMaterial - try { - branchKeyMaterials = await fetch() - } catch (error) { - // A failed early refresh leaves a valid cached entry, so use it. - // The MPL fails the call instead. - const cacheEntry = lookUp() - if (cacheEntry) return deepCopyBranchKeyMaterial(cacheEntry.response) - throw error + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + return deepCopyBranchKeyMaterial(cacheEntry.response) } - tracker.fetched(cacheEntryId, token) - put(branchKeyMaterials) - return deepCopyBranchKeyMaterial(branchKeyMaterials) + return sharedFetch(cmc, cacheEntryId, fetch, put) } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts new file mode 100644 index 000000000..2f6f4bbf8 --- /dev/null +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -0,0 +1,118 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + NodeAlgorithmSuite, + NodeBranchKeyMaterial, +} from '@aws-crypto/material-management' +import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' + +/* Concurrent cache misses for the same branch key share one keystore fetch. + * One caller fetches, and the others wait for its answer. + * If that fetch fails, each waiting caller makes its own fetch, as it would without sharing. + * Internal: not exported from the package index. + */ + +// Tests shorten these. +export const SHARED_FETCHES = { + // A fetch older than this may be stuck, so new callers make their own. + graceInterval: 1000, // milliseconds +} + +interface SharedFetch { + startedAt: number + members: ((outcome: SharedOutcome) => void)[] +} +type SharedOutcome = { material: NodeBranchKeyMaterial } | { failed: true } + +/* In-flight fetches by cache, then cache entry id. + * Keyed by cache so keyrings that share a cache also share fetches. + * Each fetch is its own object, so a slow fetch that settles late removes only itself, + * never a newer fetch for the same entry. + */ +const sharedFetches = new WeakMap< + CryptographicMaterialsCache, + Map +>() + +export function sharedFetchesFor( + cmc: CryptographicMaterialsCache +) { + return sharedFetches.get(cmc) +} + +function isRecent(fetch: SharedFetch) { + return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval +} + +/* Fetch the branch key materials, or wait for another caller's fetch of the same entry. + * `fetch` asks the keystore; `put` caches the result. + * Returns a copy only this caller holds. + */ +export async function sharedFetch( + cmc: CryptographicMaterialsCache, + cacheEntryId: string, + fetch: () => Promise, + put: (material: NodeBranchKeyMaterial) => void +): Promise { + const byId = sharedFetches.get(cmc) || new Map() + sharedFetches.set(cmc, byId) + // Drop fetches nobody can join anymore. + // This is the only place a fetch that never settles gets forgotten. + const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) + const current = fetches[fetches.length - 1] + + if (current) { + const outcome = await new Promise((resolve) => + current.members.push(resolve) + ) + if ('material' in outcome) return outcome.material + // The shared fetch failed: make this caller's own fetch, as it would without sharing. + const material = await fetch() + put(material) + return deepCopyBranchKeyMaterial(material) + } + + const mine: SharedFetch = { startedAt: Date.now(), members: [] } + fetches.push(mine) + byId.set(cacheEntryId, fetches) + const close = () => { + const list = byId.get(cacheEntryId) + if (!list) return + const index = list.indexOf(mine) + if (index !== -1) list.splice(index, 1) + if (!list.length) byId.delete(cacheEntryId) + } + + let material: NodeBranchKeyMaterial + try { + material = await fetch() + } catch (error) { + close() + for (const settle of mine.members) settle({ failed: true }) + throw error + } + close() + // Copy before put: once cached, an eviction can zero the material's buffer. + for (const settle of mine.members) { + settle({ material: deepCopyBranchKeyMaterial(material) }) + } + put(material) + return deepCopyBranchKeyMaterial(material) +} + +/* A copy with its own buffer. + * The cache zeroes a material's buffer when it evicts it, + * and callers derive keys from the branch key after getBranchKeyMaterials returns, + * so handing out the cached buffer lets an eviction zero a key mid-use (#1691). + */ +export function deepCopyBranchKeyMaterial( + material: NodeBranchKeyMaterial +): NodeBranchKeyMaterial { + return new NodeBranchKeyMaterial( + Buffer.from(material.branchKey()), + material.branchKeyIdentifier, + material.branchKeyVersion.toString('utf-8'), + { ...material.encryptionContext } + ) +} diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index d05f8b189..e6d714dee 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -17,10 +17,7 @@ import { TEST_ESDK_ALG_SUITE, TTL, } from './fixtures' -import { - KmsHierarchicalKeyRingNode, - KmsHierarchicalKeyRingStormTracking, -} from '../src/kms_hkeyring_node' +import { KmsHierarchicalKeyRingNode } from '../src/kms_hkeyring_node' import { BRANCH_KEY_ID_SUPPLIER, deepCopyBranchKeyMaterial, @@ -34,12 +31,9 @@ import { import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' import { - FetchToken, SHARED_FETCHES, - StormTracker, - StormTrackingSettings, sharedFetchesFor, -} from '../src/branch_key_storm_tracker' +} from '../src/shared_branch_key_fetches' import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -162,14 +156,8 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 // Shortened timings, so the tests run in milliseconds. -const FAST = { graceInterval: 40, sleepMilli: 2 } +const FAST = { graceInterval: 40 } const DEFAULT = { ...SHARED_FETCHES } -// Seconds, like the keyring option. -const FAST_STORM: KmsHierarchicalKeyRingStormTracking = { - gracePeriod: 0.2, - graceInterval: 0.04, - inFlightTTL: 0.2, -} // A keystore stub where each request takes `ms` milliseconds. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. @@ -207,11 +195,9 @@ function keyringFor( { cache, partitionId, - stormTracking, }: { cache?: KmsHierarchicalKeyRingNode['_cmc'] partitionId?: string - stormTracking?: KmsHierarchicalKeyRingStormTracking } = {} ): KmsHierarchicalKeyRingNode { return new KmsHierarchicalKeyRingNode({ @@ -220,17 +206,15 @@ function keyringFor( cacheLimitTtl: TTL, cache, partitionId, - stormTracking, }) } // A minimal keyring for calling getBranchKeyMaterials directly. -function hKeyringFor(keyStore: any, stormTracking?: StormTrackingSettings) { +function hKeyringFor(keyStore: any) { return { keyStore, cacheLimitTtl: TTL * 1000, cacheEntryHasExceededLimits: () => false, - _stormTracking: stormTracking, } as any } @@ -319,7 +303,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it('waiting callers do not time out', async () => { - // Slower than FAST_STORM.inFlightTTL; every caller still succeeds. + // Waiting callers have no timeout, however long the shared fetch takes. const keyStore = slowKeyStore(250) await encryptConcurrently(keyringFor(keyStore), 10) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) @@ -414,168 +398,6 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) }) -describe('KmsHierarchicalKeyRingNode: stormTracking', () => { - beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) - afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) - - it('concurrent misses share one keystore call', async () => { - const keyStore = slowKeyStore() - await encryptConcurrently( - keyringFor(keyStore, { stormTracking: FAST_STORM }), - 100 - ) - expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - }) - - it('after a failed fetch, one waiting caller fetches again once it is graceInterval old', async () => { - const keyStore = slowKeyStore(5, ['fail', 'ok']) - const results = await encryptSettled( - keyringFor(keyStore, { stormTracking: FAST_STORM }), - 10 - ) - - expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(keyStore.getActiveBranchKey.callCount).to.equal(2) - }) - - it('starts another fetch when the first hangs past graceInterval', async () => { - const keyStore = slowKeyStore(5, ['hang', 'ok']) - const hkr = keyringFor(keyStore, { stormTracking: FAST_STORM }) - - // The first caller's fetch never returns; the other 9 must not wait on it. - const [, ...waiters] = startEncrypts(hkr, 10) - const results = await Promise.allSettled(waiters) - - expect(keyStore.getActiveBranchKey.callCount).to.equal(2) - for (const result of results) expect(result.status).to.equal('fulfilled') - }) - - it('fails waiting callers after inFlightTTL when every fetch hangs', async () => { - const keyStore = slowKeyStore(5, ['hang']) - const hkr = keyringFor(keyStore, { stormTracking: FAST_STORM }) - // Each caller told to fetch hangs forever; every other caller fails after inFlightTTL. - const failures: string[] = [] - for (const p of startEncrypts(hkr, 10)) { - p.catch((e) => failures.push(e.message)) - } - const inFlightTTL = (FAST_STORM.inFlightTTL as number) * 1000 - const graceInterval = (FAST_STORM.graceInterval as number) * 1000 - await wait(2 * inFlightTTL) - - const fetchers = keyStore.getActiveBranchKey.callCount - expect(fetchers).to.be.at.most(inFlightTTL / graceInterval + 1) - expect(failures).to.have.lengthOf(10 - fetchers) - for (const message of failures) { - expect(message).to.equal('Storm cache inFlightTTL exceeded') - } - }) - - it('fetches at most fanOut keys at once', async () => { - const keyStore = slowKeyStore(20) - const cache = getLocalCryptographicMaterialsCache(100) - const hKeyring = hKeyringFor(keyStore, { - gracePeriod: 200, - graceInterval: 40, - inFlightTTL: 200, - fanOut: 2, - }) - - await Promise.all( - ['a', 'b', 'c', 'd'].map(async (id) => - getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, id, 'version') - ) - ) - - expect(keyStore.getBranchKeyVersion.callCount).to.equal(4) - expect(keyStore.peak()).to.equal(2) - }) - - it('refreshes an entry in its grace period with one fetch while others use it', async () => { - const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) - try { - const keyStore = slowKeyStore(20) - const gracePeriod = 10 // seconds - const hkr = keyringFor(keyStore, { stormTracking: { gracePeriod } }) - await encryptConcurrently(hkr, 1) - clock.tick(TTL * 1000 - (gracePeriod * 1000) / 2) - - let finished = 0 - const all = Promise.all( - startEncrypts(hkr, 10).map(async (p) => { - await p - finished += 1 - }) - ) - // The refresh takes 20 ms; the other 9 callers use the cached entry meanwhile. - await wait(5) - expect(finished).to.equal(9) - - await all - expect(keyStore.getActiveBranchKey.callCount).to.equal(2) - } finally { - clock.restore() - } - }) - - it('uses the cached entry when an early refresh fails', async () => { - const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) - try { - const keyStore = slowKeyStore(5, ['ok', 'fail']) - const gracePeriod = 10 // seconds - const hkr = keyringFor(keyStore, { stormTracking: { gracePeriod } }) - await encryptConcurrently(hkr, 1) - clock.tick(TTL * 1000 - (gracePeriod * 1000) / 2) - - await encryptConcurrently(hkr, 1) - expect(keyStore.getActiveBranchKey.callCount).to.equal(2) - } finally { - clock.restore() - } - }) -}) - -describe('StormTracker', () => { - const settings = { - gracePeriod: 10000, - graceInterval: 1000, - fanOut: 20, - inFlightTTL: 10000, - } - - it('only the newest fetch for an entry marks it fetched', () => { - const tracker = new StormTracker() - const stale = tracker.checkNewEntry('id', 0, settings) - const newer = tracker.checkNewEntry('id', 1000, settings) - expect(stale.state).to.equal('fetch') - expect(newer.state).to.equal('fetch') - - // The stale fetch finishing late leaves the newer one in flight. - tracker.fetched('id', stale.token as FetchToken) - expect(tracker.checkNewEntry('id', 1001, settings).state).to.equal('wait') - - tracker.fetched('id', newer.token as FetchToken) - expect(tracker.checkNewEntry('id', 1002, settings).state).to.equal('fetch') - }) - - it('a failed fetch stays in flight until graceInterval, like the MPL', () => { - const tracker = new StormTracker() - tracker.checkNewEntry('id', 0, settings) - expect(tracker.checkNewEntry('id', 999, settings).state).to.equal('wait') - expect(tracker.checkNewEntry('id', 1000, settings).state).to.equal('fetch') - }) - - it('forgets fetches older than inFlightTTL once fanOut is reached', () => { - const tracker = new StormTracker() - const small = { ...settings, fanOut: 1 } - tracker.checkNewEntry('a', 2000, small) - expect(tracker.checkNewEntry('b', 2000, small).state).to.equal('wait') - expect( - tracker.checkNewEntry('b', 2000 + small.inFlightTTL, small).state - ).to.equal('fetch') - expect(tracker.inFlightCount()).to.equal(1) - }) -}) - describe('getBranchKeyMaterials: eviction while a request settles', () => { const material = (fill: number) => new NodeBranchKeyMaterial(Buffer.alloc(32, fill), BRANCH_KEY_ID_A, v4(), {}) diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts index e9f08dd9e..cf95070b1 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.constructor.test.ts @@ -155,79 +155,6 @@ describe('KmsHierarchicalKeyRingNode: constructor', () => { }) }) - describe('stormTracking', () => { - const build = (stormTracking: any, ttl = 60) => - new KmsHierarchicalKeyRingNode({ - branchKeyId, - keyStore, - cacheLimitTtl: ttl, - stormTracking, - })._stormTracking - - it('is off unless given', () => { - expect(build(undefined)).to.equal(undefined) - }) - - it('takes the MPL defaults for unset settings, stored in milliseconds', () => { - expect(build({})).to.deep.equal({ - gracePeriod: 10000, - graceInterval: 1000, - fanOut: 20, - inFlightTTL: 10000, - }) - expect( - build({ gracePeriod: 30, graceInterval: 2, fanOut: 5, inFlightTTL: 20 }) - ).to.deep.equal({ - gracePeriod: 30000, - graceInterval: 2000, - fanOut: 5, - inFlightTTL: 20000, - }) - }) - - it('Precondition: stormTracking must be an object', () => { - for (const value of [null, true, 1, 'x']) { - expect(() => build(value)).to.throw('stormTracking must be an object') - } - }) - - it('Precondition: stormTracking times must be positive numbers', () => { - for (const key of ['gracePeriod', 'graceInterval', 'inFlightTTL']) { - for (const value of [0, -1, NaN, Infinity, '1', null]) { - expect(() => build({ [key]: value })).to.throw( - 'stormTracking gracePeriod, graceInterval, and inFlightTTL must be positive numbers' - ) - } - } - }) - - it('Precondition: stormTracking fanOut must be a positive integer', () => { - for (const fanOut of [0, -1, 1.5, NaN, Infinity, '1', null]) { - expect(() => build({ fanOut })).to.throw( - 'stormTracking fanOut must be a positive integer' - ) - } - }) - - it('Precondition: stormTracking settings must be consistent, as in the MPL', () => { - const message = - 'stormTracking graceInterval must not exceed inFlightTTL, and inFlightTTL must not exceed gracePeriod' - expect(() => build({ graceInterval: 11 })).to.throw(message) - expect(() => build({ gracePeriod: 5 })).to.throw(message) - expect( - build({ gracePeriod: 5, graceInterval: 5, inFlightTTL: 5 }) - ).to.not.equal(undefined) - }) - - it('Precondition: stormTracking gracePeriod must be less than the cache limit TTL', () => { - const message = - 'stormTracking gracePeriod must be less than the cache limit TTL' - expect(() => build({}, 10)).to.throw(message) - expect(() => build({}, 0)).to.throw(message) - expect(build({}, 11)).to.not.equal(undefined) - }) - }) - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#cache-limit-ttl //= type=test //# The maximum amount of time in seconds that an entry within the cache may be used before it MUST be evicted. From fb6ea5b26813da53b45de73f481aa566ad07a887 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 13:37:30 -0700 Subject: [PATCH 22/38] fix: always release waiting callers when a shared request ends If the backend returned a malformed response, the caller making the shared request threw before releasing the callers waiting on it, and they hung forever. Release them in a finally block so they make their own request, as they would without sharing. --- ...hing_cryptographic_materials_decorators.ts | 74 ++++++++----------- ...c_materials_decorators.concurrency.test.ts | 26 +++++++ .../src/shared_branch_key_fetches.ts | 21 +++--- .../kms_hkeyring_node.concurrency.test.ts | 26 +++++++ 4 files changed, 92 insertions(+), 55 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 4a36126ce..3c4b76dad 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -146,53 +146,40 @@ export function getEncryptionMaterials({ this, plaintextLength ) - let material: EncryptionMaterial try { - material = await fetch() - } catch (error) { - closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, { failed: true }) - throw error - } - closeBatch(this._cache, cacheKey, batch) + const material = await fetch() - // Can't cache it, so the waiting callers can't share it. - if (!material.suite.cacheSafe) { - settleMembers(batch, { failed: true }) - return material - } + // Can't cache it, so the waiting callers can't share it. + if (!material.suite.cacheSafe) return material - // This message alone is over the limits, so nobody joined, and it can't be cached. - const used = { - response: material, - now: Date.now(), - messagesEncrypted: batch.messages, - bytesEncrypted: batch.bytes, - } - if (this._cacheEntryHasExceededLimits(used)) { - settleMembers(batch, { failed: true }) - return material - } + // This message alone is over the limits, so nobody joined, and it can't be cached. + const used = { + response: material, + now: Date.now(), + messagesEncrypted: batch.messages, + bytesEncrypted: batch.bytes, + } + if (this._cacheEntryHasExceededLimits(used)) return material - try { this._cache.putEncryptionMaterial( cacheKey, material, plaintextLength, this._maxAge ) - } catch (error) { + // Count the waiting callers' messages against the cache entry too. + for (const member of batch.members) { + this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + } + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) + } finally { + // However this request ends, waiting callers not given its material make their own. + closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) - throw error - } - // Count the waiting callers' messages against the cache entry too. - for (const member of batch.members) { - this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) - } - return cloneResponse(material) } } @@ -290,19 +277,17 @@ export function decryptMaterials({ this, 0 ) - let material: DecryptionMaterial try { - material = await fetchAndCache() - } catch (error) { + const material = await fetchAndCache() + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) + } finally { + // However this request ends, waiting callers not given its material make their own. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) - throw error } - closeBatch(this._cache, cacheKey, batch) - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) - } - return cloneResponse(material) } } @@ -320,6 +305,7 @@ interface Batch { /* What a waiting caller gets when the shared request finishes: * the material, or `failed` if the request failed or its material can't be shared. + * A caller's settle is a promise resolve, so only the first outcome counts. */ type Outcome = { material: M } | { failed: true } diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index f75d346cd..64333f360 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -356,6 +356,32 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { } }) + it('releases every waiting caller when the response is malformed', async () => { + // No suite at all: reading the response throws before the request can hand it out. + const malformed = { + async getEncryptionMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return {} + }, + async decryptMaterials() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return {} + }, + } + const cmm = cachingCMM(malformed) + + const encrypts = await Promise.allSettled(startEncrypts(cmm, times(3))) + const decrypts = await Promise.allSettled( + Array.from({ length: 3 }, async () => + cmm.decryptMaterials(decryptRequest) + ) + ) + + for (const result of [...encrypts, ...decrypts]) { + expect(result.status).to.equal('rejected') + } + }) + it('forgets a cache key once its requests finish', async () => { const backing = slowBackingMaterialsManager() const cache = getLocalCryptographicMaterialsCache(100) diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 2f6f4bbf8..d6983822b 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -84,21 +84,20 @@ export async function sharedFetch( if (!list.length) byId.delete(cacheEntryId) } - let material: NodeBranchKeyMaterial try { - material = await fetch() - } catch (error) { + const material = await fetch() + // Copy before put: once cached, an eviction can zero the material's buffer. + for (const settle of mine.members) { + settle({ material: deepCopyBranchKeyMaterial(material) }) + } + put(material) + return deepCopyBranchKeyMaterial(material) + } finally { + // However this fetch ends, waiting callers not given its material make their own. + // Each settle is a promise resolve, so only the first outcome counts. close() for (const settle of mine.members) settle({ failed: true }) - throw error - } - close() - // Copy before put: once cached, an eviction can zero the material's buffer. - for (const settle of mine.members) { - settle({ material: deepCopyBranchKeyMaterial(material) }) } - put(material) - return deepCopyBranchKeyMaterial(material) } /* A copy with its own buffer. diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index e6d714dee..1d123fc63 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -302,6 +302,32 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect(keyStore.getActiveBranchKey.callCount).to.equal(10) }) + it('releases every waiting caller when the keystore response is malformed', async () => { + // Copying this response throws before the fetch can hand it out. + const keyStore = { + getBranchKeyVersion: async () => { + await wait(5) + return {} as NodeBranchKeyMaterial + }, + } + const cache = getLocalCryptographicMaterialsCache(100) + + const results = await Promise.allSettled( + Array.from({ length: 3 }, async () => + getBranchKeyMaterials( + hKeyringFor(keyStore), + cache, + BRANCH_KEY_ID_A, + 'entry', + 'version' + ) + ) + ) + + for (const result of results) expect(result.status).to.equal('rejected') + expect(sharedFetchesFor(cache)?.size).to.equal(0) + }) + it('waiting callers do not time out', async () => { // Waiting callers have no timeout, however long the shared fetch takes. const keyStore = slowKeyStore(250) From f1e95f5172f2430022a636b520714bf6dde44be6 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:09:29 -0700 Subject: [PATCH 23/38] docs: state the no-added-failures rule once in shared request comments --- .../caching_cryptographic_materials_decorators.ts | 12 +++++------- modules/cache-material/src/shared_requests.ts | 5 +++-- .../src/shared_branch_key_fetches.ts | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 3c4b76dad..7fa0108f8 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -128,9 +128,7 @@ export function getEncryptionMaterials({ if (joinable) { const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material - /* The shared data key can't be cached, or the shared request failed. - * Make this caller's own request, as it would without sharing. - */ + // The shared request failed, or its data key can't be shared. return cacheEncryptionMaterial( this, cacheKey, @@ -149,7 +147,7 @@ export function getEncryptionMaterials({ try { const material = await fetch() - // Can't cache it, so the waiting callers can't share it. + // Not cache safe, so not shareable; the finally below releases the waiting callers. if (!material.suite.cacheSafe) return material // This message alone is over the limits, so nobody joined, and it can't be cached. @@ -177,6 +175,7 @@ export function getEncryptionMaterials({ return cloneResponse(material) } finally { // However this request ends, waiting callers not given its material make their own. + // Each settle is a promise resolve, so callers already given the material ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -266,7 +265,7 @@ export function decryptMaterials({ if (joinable) { const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material - // The shared request failed: make this caller's own request, as it would without sharing. + // The shared request failed. return cloneResponse(await fetchAndCache()) } @@ -285,6 +284,7 @@ export function decryptMaterials({ return cloneResponse(material) } finally { // However this request ends, waiting callers not given its material make their own. + // Each settle is a promise resolve, so callers already given the material ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -305,7 +305,6 @@ interface Batch { /* What a waiting caller gets when the shared request finishes: * the material, or `failed` if the request failed or its material can't be shared. - * A caller's settle is a promise resolve, so only the first outcome counts. */ type Outcome = { material: M } | { failed: true } @@ -369,7 +368,6 @@ function startBatch( return batch } -// Also deletes the cache key's list once it is empty, so the map doesn't grow with every key ever used. function closeBatch( cache: CryptographicMaterialsCache, cacheKey: string, diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index c02936abb..c2aa8ff9c 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -5,10 +5,11 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' /* Concurrent cache misses for the same entry share one request to the backing materials manager. * One caller makes the request, and the others wait for its answer. - * Internal: not exported from the package index. + * Sharing must not add failures: + * if the shared request fails or its material can't be shared, each waiting caller makes its own. */ -// Tests shorten these. +// An object, not a constant, so tests can shorten it. export const SHARED_REQUESTS = { // A request older than this may be stuck, so new callers make their own. graceInterval: 1000, // milliseconds == 1 second diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index d6983822b..55af0734d 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -9,11 +9,11 @@ import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' /* Concurrent cache misses for the same branch key share one keystore fetch. * One caller fetches, and the others wait for its answer. - * If that fetch fails, each waiting caller makes its own fetch, as it would without sharing. - * Internal: not exported from the package index. + * Sharing must not add failures: + * if the shared fetch fails, each waiting caller makes its own. */ -// Tests shorten these. +// An object, not a constant, so tests can shorten it. export const SHARED_FETCHES = { // A fetch older than this may be stuck, so new callers make their own. graceInterval: 1000, // milliseconds @@ -67,7 +67,7 @@ export async function sharedFetch( current.members.push(resolve) ) if ('material' in outcome) return outcome.material - // The shared fetch failed: make this caller's own fetch, as it would without sharing. + // The shared fetch failed. const material = await fetch() put(material) return deepCopyBranchKeyMaterial(material) From 712eb9a1342182b9ba12959649c1f475bbdf6384 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:28:28 -0700 Subject: [PATCH 24/38] docs: drop the findBatch comment --- ...hing_cryptographic_materials_decorators.ts | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 7fa0108f8..7401032be 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -119,7 +119,7 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - // If another caller's request has room for this message, wait for it instead of making a new one. + // If another caller's request has room for this message, use that one instead of making a new one. const joinable = findBatch>( this._cache, cacheKey, @@ -129,6 +129,7 @@ export function getEncryptionMaterials({ const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material // The shared request failed, or its data key can't be shared. + // (do what?) return cacheEncryptionMaterial( this, cacheKey, @@ -137,7 +138,7 @@ export function getEncryptionMaterials({ ) } - // No await between findBatch and startBatch, or concurrent callers all find no batch and each start one. + // Start a call to cache/upstream data source for this request (or, possibly, a batch of requests) const batch = startBatch>( this._cache, cacheKey, @@ -147,16 +148,16 @@ export function getEncryptionMaterials({ try { const material = await fetch() - // Not cache safe, so not shareable; the finally below releases the waiting callers. + // Not cache safe, so not shareable if (!material.suite.cacheSafe) return material - // This message alone is over the limits, so nobody joined, and it can't be cached. const used = { response: material, now: Date.now(), messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } + // This is over the bytes/messages limit for cached material. Don't cache it if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( @@ -165,7 +166,7 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - // Count the waiting callers' messages against the cache entry too. + // If any other requests are in this batch, pass them the encryption material for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -174,8 +175,7 @@ export function getEncryptionMaterials({ } return cloneResponse(material) } finally { - // However this request ends, waiting callers not given its material make their own. - // Each settle is a promise resolve, so callers already given the material ignore this. + // If there are any failures, signal failure to any waiting requests in the batch closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -191,17 +191,16 @@ function cacheEncryptionMaterial( /* Check for early return (Postcondition): If I can not cache the EncryptionMaterial, just return it. */ if (!material.suite.cacheSafe) return material - /* It is possible for an entry to exceed limits immediately. - * The simplest case is to need to encrypt more than then maxBytesEncrypted. - * In this case, I return the response to encrypt the data, - * but do not put a know invalid item into the cache. - */ const testEntry = { response: material, now: Date.now(), messagesEncrypted: 1, bytesEncrypted: plaintextLength, } + /* It is possible for an entry to exceed bytes/messages encrypted limits immediately. + * In this case, return the response to encrypt the data, + * but do not put a known invalid item into the cache. + */ if (!cmm._cacheEntryHasExceededLimits(testEntry)) { cmm._cache.putEncryptionMaterial( cacheKey, @@ -213,7 +212,7 @@ function cacheEncryptionMaterial( } else { /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. * If it is cloned, and the clone is returned, - * then there exist a copy of the unencrypted data key. + * then there exists a copy of the unencrypted data key. * It is true that this data would be caught by GC, it is better to just not rely on that. */ return material @@ -269,7 +268,6 @@ export function decryptMaterials({ return cloneResponse(await fetchAndCache()) } - // No await between findBatch and startBatch, or concurrent callers all find no batch and each start one. const batch = startBatch>( this._cache, cacheKey, @@ -283,32 +281,28 @@ export function decryptMaterials({ } return cloneResponse(material) } finally { - // However this request ends, waiting callers not given its material make their own. - // Each settle is a promise resolve, so callers already given the material ignore this. + // If there are any failures, signal failure to any waiting requests in the batch closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } } } -/* One request to the backing materials manager, plus the callers waiting for its answer. - * `messages` and `bytes` count what its data key will encrypt for all of them, - * so canJoin can keep the data key within its limits before it even exists. - */ +// Tracks one request to the backing materials manager plus the callers waiting for its answer. interface Batch { startedAt: number + // `messages` and `bytes` count towards messages/bytes encrypted limits for all requests in the batch. messages: number bytes: number requester: CachingMaterialsManager members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } -/* What a waiting caller gets when the shared request finishes: - * the material, or `failed` if the request failed or its material can't be shared. +/* Result from a shared/batched request. + * Either the actual material, or `failed` if the request failed or its material can't be shared. */ type Outcome = { material: M } | { failed: true } -// Look up the list on every call: closeBatch deletes a key's list once it is empty. function findBatch( cache: CryptographicMaterialsCache, cacheKey: string, From 2c9d87856d6eecf96b0db23fce44517bc9639fdb Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:32:44 -0700 Subject: [PATCH 25/38] docs: rewrite shared request comments one by one --- ...hing_cryptographic_materials_decorators.ts | 27 +++++++++---------- ...c_materials_decorators.concurrency.test.ts | 1 - .../src/shared_branch_key_fetches.ts | 6 ++--- .../kms_hkeyring_node.concurrency.test.ts | 1 - 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 7401032be..938f74de5 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -119,7 +119,7 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - // If another caller's request has room for this message, use that one instead of making a new one. + // Join another caller's request if its data key has room for this message. const joinable = findBatch>( this._cache, cacheKey, @@ -128,8 +128,7 @@ export function getEncryptionMaterials({ if (joinable) { const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material - // The shared request failed, or its data key can't be shared. - // (do what?) + // The shared request failed or its data key can't be shared, so make this caller's own. return cacheEncryptionMaterial( this, cacheKey, @@ -138,7 +137,6 @@ export function getEncryptionMaterials({ ) } - // Start a call to cache/upstream data source for this request (or, possibly, a batch of requests) const batch = startBatch>( this._cache, cacheKey, @@ -157,7 +155,7 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // This is over the bytes/messages limit for cached material. Don't cache it + // Only this message can be over the limits: canJoin keeps joiners within them. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( @@ -166,7 +164,7 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - // If any other requests are in this batch, pass them the encryption material + // Count each waiting caller's message against the cache entry. for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -175,7 +173,8 @@ export function getEncryptionMaterials({ } return cloneResponse(material) } finally { - // If there are any failures, signal failure to any waiting requests in the batch + // However the request ends, waiting callers not given its material make their own. + // A caller already given the material ignores this second settle. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -255,7 +254,7 @@ export function decryptMaterials({ this._cache.del(cacheKey) } - // If another caller has a request in flight, wait for it instead of making a new one. + // Join another caller's request if one is in flight. const joinable = findBatch>( this._cache, cacheKey, @@ -264,7 +263,7 @@ export function decryptMaterials({ if (joinable) { const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material - // The shared request failed. + // The shared request failed, so make this caller's own. return cloneResponse(await fetchAndCache()) } @@ -281,7 +280,8 @@ export function decryptMaterials({ } return cloneResponse(material) } finally { - // If there are any failures, signal failure to any waiting requests in the batch + // However the request ends, waiting callers not given its material make their own. + // A caller already given the material ignores this second settle. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -291,16 +291,14 @@ export function decryptMaterials({ // Tracks one request to the backing materials manager plus the callers waiting for its answer. interface Batch { startedAt: number - // `messages` and `bytes` count towards messages/bytes encrypted limits for all requests in the batch. + // What the batch's data key will encrypt, counted as callers join so canJoin can enforce the limits before the key exists. messages: number bytes: number requester: CachingMaterialsManager members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } -/* Result from a shared/batched request. - * Either the actual material, or `failed` if the request failed or its material can't be shared. - */ +// What a waiting caller gets: the material, or `failed` if the request failed or its material can't be shared. type Outcome = { material: M } | { failed: true } function findBatch( @@ -339,6 +337,7 @@ function canJoin( ) } +// Don't await between a findBatch miss and this call, or concurrent callers each start their own batch. function startBatch( cache: CryptographicMaterialsCache, cacheKey: string, diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 64333f360..9c2610b12 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -309,7 +309,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { }) it('waiting callers do not time out', async () => { - // Waiting callers have no timeout, however long the shared request takes. const backing = slowBackingMaterialsManager({ ms: 150 }) const cmm = cachingCMM(backing) diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 55af0734d..b01ff2c59 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -67,7 +67,7 @@ export async function sharedFetch( current.members.push(resolve) ) if ('material' in outcome) return outcome.material - // The shared fetch failed. + // The shared fetch failed, so make this caller's own. const material = await fetch() put(material) return deepCopyBranchKeyMaterial(material) @@ -93,8 +93,8 @@ export async function sharedFetch( put(material) return deepCopyBranchKeyMaterial(material) } finally { - // However this fetch ends, waiting callers not given its material make their own. - // Each settle is a promise resolve, so only the first outcome counts. + // However the fetch ends, waiting callers not given its material make their own. + // A caller already given the material ignores this second settle. close() for (const settle of mine.members) settle({ failed: true }) } diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 1d123fc63..bd076b0a7 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -329,7 +329,6 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it('waiting callers do not time out', async () => { - // Waiting callers have no timeout, however long the shared fetch takes. const keyStore = slowKeyStore(250) await encryptConcurrently(keyringFor(keyStore), 10) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) From ee71bcf64f402d1d3f1dcfe169eb5792a09a8f01 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:36:53 -0700 Subject: [PATCH 26/38] docs: say what the over-limits check is for --- .../src/caching_cryptographic_materials_decorators.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 938f74de5..7c3fdac81 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -128,7 +128,7 @@ export function getEncryptionMaterials({ if (joinable) { const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material - // The shared request failed or its data key can't be shared, so make this caller's own. + // The shared request failed or its data key can't be shared, so make a new request just for us. return cacheEncryptionMaterial( this, cacheKey, @@ -155,7 +155,7 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // Only this message can be over the limits: canJoin keeps joiners within them. + // This message alone is over the limits (e.g. larger than maxBytesEncrypted), so don't cache the data key. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( From 2ec4d0ff91e0a47fa2b8d45fdb710506d40bc4f7 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:39:59 -0700 Subject: [PATCH 27/38] docs: rewrite shared request comments for a first-time reader --- ...hing_cryptographic_materials_decorators.ts | 35 ++++++++++--------- modules/cache-material/src/shared_requests.ts | 4 +-- ...c_materials_decorators.concurrency.test.ts | 2 +- .../src/shared_branch_key_fetches.ts | 14 ++++---- .../kms_hkeyring_node.concurrency.test.ts | 6 ++-- 5 files changed, 32 insertions(+), 29 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 7c3fdac81..d4a35f6f3 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -119,7 +119,7 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - // Join another caller's request if its data key has room for this message. + // If another caller's request can also cover this message, wait for its result. const joinable = findBatch>( this._cache, cacheKey, @@ -128,7 +128,7 @@ export function getEncryptionMaterials({ if (joinable) { const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material - // The shared request failed or its data key can't be shared, so make a new request just for us. + // The shared request failed or its data key can't be reused, so make a new request for just this message. return cacheEncryptionMaterial( this, cacheKey, @@ -146,7 +146,7 @@ export function getEncryptionMaterials({ try { const material = await fetch() - // Not cache safe, so not shareable + // Not cache safe, so the waiting callers can't reuse it. if (!material.suite.cacheSafe) return material const used = { @@ -155,7 +155,7 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // This message alone is over the limits (e.g. larger than maxBytesEncrypted), so don't cache the data key. + // This message alone is over maxBytesEncrypted or maxMessagesEncrypted, so don't cache the data key. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( @@ -173,8 +173,8 @@ export function getEncryptionMaterials({ } return cloneResponse(material) } finally { - // However the request ends, waiting callers not given its material make their own. - // A caller already given the material ignores this second settle. + // Release waiting callers that didn't get the material, so they make their own requests. + // Callers that did get it ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -254,7 +254,7 @@ export function decryptMaterials({ this._cache.del(cacheKey) } - // Join another caller's request if one is in flight. + // If another caller's request is in flight, wait for its result. const joinable = findBatch>( this._cache, cacheKey, @@ -263,7 +263,7 @@ export function decryptMaterials({ if (joinable) { const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material - // The shared request failed, so make this caller's own. + // The shared request failed, so make a new request for just this caller. return cloneResponse(await fetchAndCache()) } @@ -280,8 +280,8 @@ export function decryptMaterials({ } return cloneResponse(material) } finally { - // However the request ends, waiting callers not given its material make their own. - // A caller already given the material ignores this second settle. + // Release waiting callers that didn't get the material, so they make their own requests. + // Callers that did get it ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, { failed: true }) } @@ -291,7 +291,8 @@ export function decryptMaterials({ // Tracks one request to the backing materials manager plus the callers waiting for its answer. interface Batch { startedAt: number - // What the batch's data key will encrypt, counted as callers join so canJoin can enforce the limits before the key exists. + // Messages and bytes the data key will encrypt, counting every caller in the batch. + // canJoin checks these against the limits before the data key exists. messages: number bytes: number requester: CachingMaterialsManager @@ -310,12 +311,12 @@ function findBatch( return batches?.find(predicate) } -/* A request older than graceInterval may be stuck, so new callers stop sharing it. */ +/* A request older than graceInterval may be stuck, so new callers don't join it. */ function isRecent(batch: Batch) { return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } -/* Whether this caller's message still fits in the batch's data key. +/* Whether adding this caller's message keeps the batch within the data key's limits. * The batch may belong to another caching materials manager on the same cache, * so check both managers' limits. */ @@ -337,7 +338,9 @@ function canJoin( ) } -// Don't await between a findBatch miss and this call, or concurrent callers each start their own batch. +/* Call this right after findBatch finds nothing, with no await in between. + * Otherwise concurrent callers each start their own batch. + */ function startBatch( cache: CryptographicMaterialsCache, cacheKey: string, @@ -346,8 +349,8 @@ function startBatch( ): Batch { const byKey = batchesByCache.get(cache) || new Map[]>() batchesByCache.set(cache, byKey) - // Drop batches nobody can join anymore. - // This is the only place a request that never settles gets forgotten. + // Drop this key's batches that are too old to join. + // A request that never settles is only ever removed here. const batches = (byKey.get(cacheKey) || []).filter(isRecent) byKey.set(cacheKey, batches) const batch: Batch = { diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index c2aa8ff9c..ade03bce9 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -6,12 +6,12 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' /* Concurrent cache misses for the same entry share one request to the backing materials manager. * One caller makes the request, and the others wait for its answer. * Sharing must not add failures: - * if the shared request fails or its material can't be shared, each waiting caller makes its own. + * if the shared request fails or its material can't be reused, each waiting caller makes its own request. */ // An object, not a constant, so tests can shorten it. export const SHARED_REQUESTS = { - // A request older than this may be stuck, so new callers make their own. + // A request older than this may be stuck, so new callers don't join it. graceInterval: 1000, // milliseconds == 1 second } diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 9c2610b12..099d11888 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -356,7 +356,7 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { }) it('releases every waiting caller when the response is malformed', async () => { - // No suite at all: reading the response throws before the request can hand it out. + // Without a suite, reading the response throws before the waiting callers get it. const malformed = { async getEncryptionMaterials() { await new Promise((resolve) => setTimeout(resolve, 5)) diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index b01ff2c59..76c04b48b 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -10,12 +10,12 @@ import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' /* Concurrent cache misses for the same branch key share one keystore fetch. * One caller fetches, and the others wait for its answer. * Sharing must not add failures: - * if the shared fetch fails, each waiting caller makes its own. + * if the shared fetch fails, each waiting caller makes its own fetch. */ // An object, not a constant, so tests can shorten it. export const SHARED_FETCHES = { - // A fetch older than this may be stuck, so new callers make their own. + // A fetch older than this may be stuck, so new callers don't join it. graceInterval: 1000, // milliseconds } @@ -57,8 +57,8 @@ export async function sharedFetch( ): Promise { const byId = sharedFetches.get(cmc) || new Map() sharedFetches.set(cmc, byId) - // Drop fetches nobody can join anymore. - // This is the only place a fetch that never settles gets forgotten. + // Drop this entry's fetches that are too old to join. + // A fetch that never settles is only ever removed here. const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) const current = fetches[fetches.length - 1] @@ -67,7 +67,7 @@ export async function sharedFetch( current.members.push(resolve) ) if ('material' in outcome) return outcome.material - // The shared fetch failed, so make this caller's own. + // The shared fetch failed, so fetch for just this caller. const material = await fetch() put(material) return deepCopyBranchKeyMaterial(material) @@ -93,8 +93,8 @@ export async function sharedFetch( put(material) return deepCopyBranchKeyMaterial(material) } finally { - // However the fetch ends, waiting callers not given its material make their own. - // A caller already given the material ignores this second settle. + // Release waiting callers that didn't get the material, so they make their own fetches. + // Callers that did get it ignore this. close() for (const settle of mine.members) settle({ failed: true }) } diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index bd076b0a7..814d5d27a 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -243,7 +243,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // 3000 callers can block the event loop past the shortened graceInterval. + // 3000 callers can block the event loop past the shortened graceInterval, which starts a second fetch. Object.assign(SHARED_FETCHES, DEFAULT) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) @@ -251,7 +251,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // 3000 callers can block the event loop past the shortened graceInterval. + // 3000 callers can block the event loop past the shortened graceInterval, which starts a second fetch. Object.assign(SHARED_FETCHES, DEFAULT) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) @@ -303,7 +303,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it('releases every waiting caller when the keystore response is malformed', async () => { - // Copying this response throws before the fetch can hand it out. + // Copying this malformed response throws before the waiting callers get it. const keyStore = { getBranchKeyVersion: async () => { await wait(5) From fc4dad0a8857a7f877f22c15327217f244083cd9 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 14:59:40 -0700 Subject: [PATCH 28/38] test: keep only tests that fail when sharing breaks Drop tests that duplicate other tests or guard storm tracking behavior that no longer exists. Add coverage for counting waiting callers against the cache entry and for the starting manager's limits, which no test caught when broken. --- ...c_materials_decorators.concurrency.test.ts | 124 ++++-------------- .../kms_hkeyring_node.concurrency.test.ts | 107 ++------------- 2 files changed, 34 insertions(+), 197 deletions(-) diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 099d11888..d901a445c 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -11,7 +11,6 @@ import { decryptMaterials, } from '../src/caching_cryptographic_materials_decorators' import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' -import * as cacheMaterial from '../src/index' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' import { createHash, randomBytes } from 'crypto' @@ -54,12 +53,11 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( const FAST = { graceInterval: 20 } const DEFAULT = { ...SHARED_REQUESTS } -// A backing materials manager that takes `ms` milliseconds per request and returns a new data key each time. +// A backing materials manager that takes 5 ms per request and returns a new data key each time. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. function slowBackingMaterialsManager({ outcomes = ['ok'] as ('ok' | 'fail' | 'hang')[], materialSuite = suite, - ms = 5, } = {}) { const calls = { encrypt: 0, decrypt: 0, active: 0, peak: 0 } let n = 0 @@ -68,7 +66,7 @@ function slowBackingMaterialsManager({ if (outcome === 'hang') await new Promise(() => undefined) calls.active += 1 calls.peak = Math.max(calls.peak, calls.active) - await new Promise((resolve) => setTimeout(resolve, ms)) + await new Promise((resolve) => setTimeout(resolve, 5)) calls.active -= 1 if (outcome === 'fail') throw new Error('backing materials manager unavailable') @@ -163,7 +161,7 @@ function usageByDataKey(materials: any[], lengths: number[]) { return [...usage.values()] } -describe('caching materials manager: concurrent cache misses (#1665)', () => { +describe('caching materials manager: concurrent cache misses', () => { beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) @@ -178,6 +176,25 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect( usageByDataKey(materials, times(10)).map((u) => u.messages) ).to.deep.equal([5, 5]) + + // The cached data key already encrypted 5 messages, so the next one needs a new data key. + await cmm.getEncryptionMaterials(encryptRequest) + expect(backing.calls.encrypt).to.equal(3) + }) + + it('callers stop joining a request once it would exceed the limits of the manager that made it', async () => { + const backing = slowBackingMaterialsManager() + const cache = getLocalCryptographicMaterialsCache(100) + const strict = cachingCMM(backing, { cache, maxMessagesEncrypted: 2 }) + const loose = cachingCMM(backing, { cache, maxMessagesEncrypted: 10 }) + + // strict's request can serve 2 messages, so loose's 5 share a second request. + await Promise.all([ + ...startEncrypts(strict, times(2)), + ...startEncrypts(loose, times(5)), + ]) + + expect(backing.calls.encrypt).to.equal(2) }) it('concurrent encrypts share data keys without exceeding maxBytesEncrypted', async () => { @@ -192,19 +209,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { ).to.deep.equal([9, 9, 9, 3]) }) - it('a large burst runs its backing requests in parallel', async () => { - const backing = slowBackingMaterialsManager() - const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) - - const materials = await encryptConcurrently(cmm, times(100)) - - expect(backing.calls.encrypt).to.equal(20) - expect(backing.calls.peak).to.equal(20) - for (const { messages } of usageByDataKey(materials, times(100))) { - expect(messages).to.be.at.most(5) - } - }) - it('a warm entry serves callers until its limit, then one request serves the rest', async () => { const backing = slowBackingMaterialsManager() const cmm = cachingCMM(backing, { maxMessagesEncrypted: 5 }) @@ -221,22 +225,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { } }) - it('keeps every data key within its limits for mixed plaintext sizes', async () => { - const backing = slowBackingMaterialsManager() - const cmm = cachingCMM(backing, { - maxBytesEncrypted: 10, - maxMessagesEncrypted: 3, - }) - const lengths = [6, 3, 1, 11, 4, 4, 2, 11, 9, 1, 1, 5] - - const materials = await encryptConcurrently(cmm, lengths) - - for (const { messages, bytes } of usageByDataKey(materials, lengths)) { - expect(messages).to.be.at.most(3) - if (messages > 1) expect(bytes).to.be.at.most(10) - } - }) - it('concurrent decrypts share one backing request', async () => { const backing = slowBackingMaterialsManager() const cmm = cachingCMM(backing) @@ -280,20 +268,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(10) }) - it('a failing backing materials manager fails each caller with its own error', async () => { - const backing = slowBackingMaterialsManager({ outcomes: ['fail'] }) - const cmm = cachingCMM(backing) - - const results = await Promise.allSettled(startEncrypts(cmm, times(10))) - - for (const result of results) { - expect((result as PromiseRejectedResult).reason.message).to.equal( - 'backing materials manager unavailable' - ) - } - expect(backing.calls.encrypt).to.equal(10) - }) - it('decrypts make their own request when the shared one fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) @@ -308,16 +282,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.decrypt).to.equal(5) }) - it('waiting callers do not time out', async () => { - const backing = slowBackingMaterialsManager({ ms: 150 }) - const cmm = cachingCMM(backing) - - const materials = await encryptConcurrently(cmm, times(5)) - - expect(materials).to.have.lengthOf(5) - expect(backing.calls.encrypt).to.equal(1) - }) - it('starts another request for new callers once one hangs past graceInterval', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) const cmm = cachingCMM(backing) @@ -330,31 +294,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(backing.calls.encrypt).to.equal(2) }) - it('fails waiting callers when the response cannot be cached', async () => { - const malformed = { - async getEncryptionMaterials() { - await new Promise((resolve) => setTimeout(resolve, 5)) - return { suite } - }, - async decryptMaterials() { - await new Promise((resolve) => setTimeout(resolve, 5)) - return { suite } - }, - } - const cmm = cachingCMM(malformed) - - const encrypts = await Promise.allSettled(startEncrypts(cmm, times(3))) - const decrypts = await Promise.allSettled( - Array.from({ length: 3 }, async () => - cmm.decryptMaterials(decryptRequest) - ) - ) - - for (const result of [...encrypts, ...decrypts]) { - expect(result.status).to.equal('rejected') - } - }) - it('releases every waiting caller when the response is malformed', async () => { // Without a suite, reading the response throws before the waiting callers get it. const malformed = { @@ -417,11 +356,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { expect(batchesByCache.get(cache)?.size).to.equal(0) }) - it('does not export the shared request internals from the package', () => { - expect(cacheMaterial).to.not.have.property('SHARED_REQUESTS') - expect(cacheMaterial).to.not.have.property('batchesByCache') - }) - describe('requests run in parallel when the response cannot serve another caller', () => { const uncached = { 'plaintextLength exceeds maxBytesEncrypted': { @@ -431,20 +365,6 @@ describe('caching materials manager: concurrent cache misses (#1665)', () => { request: {}, peak: 10, }, - 'two plaintexts exceed maxBytesEncrypted': { - backing: {}, - cmm: { maxBytesEncrypted: 10 }, - length: 6, - request: {}, - peak: 10, - }, - 'maxMessagesEncrypted is 1': { - backing: {}, - cmm: { maxMessagesEncrypted: 1 }, - length: 1, - request: {}, - peak: 10, - }, 'the backing suite is not cache safe': { backing: { materialSuite: uncacheableSuite }, cmm: {}, diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 814d5d27a..64ebe7ccf 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -159,12 +159,14 @@ const CONCURRENT_OPERATIONS = 3000 const FAST = { graceInterval: 40 } const DEFAULT = { ...SHARED_FETCHES } -// A keystore stub where each request takes `ms` milliseconds. +const wait = async (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +// A keystore stub where each request takes 5 ms. // `outcomes` decides each call in order: 'ok', 'fail', or 'hang'; later calls use the last one. function slowKeyStore( - ms = 5, outcomes: ('ok' | 'fail' | 'hang')[] = ['ok'] -): Sinon.SinonStubbedInstance & { peak: () => number } { +): Sinon.SinonStubbedInstance { const material = new NodeBranchKeyMaterial( Buffer.alloc(32, 1), BRANCH_KEY_ID_A, @@ -173,20 +175,15 @@ function slowKeyStore( ) const keyStore = stubKeyStore() as any let calls = 0 - let active = 0 - let peak = 0 const fetch = async () => { const outcome = outcomes[Math.min(calls++, outcomes.length - 1)] if (outcome === 'hang') return new Promise(() => undefined) - peak = Math.max(peak, ++active) - await new Promise((resolve) => setTimeout(resolve, ms)) - active -= 1 + await wait(5) if (outcome === 'fail') throw new Error('keystore unavailable') return deepCopyBranchKeyMaterial(material) } keyStore.getActiveBranchKey.callsFake(fetch) keyStore.getBranchKeyVersion.callsFake(fetch) - keyStore.peak = () => peak return keyStore } @@ -235,9 +232,6 @@ async function encryptSettled(hkr: KmsHierarchicalKeyRingNode, count: number) { return Promise.allSettled(startEncrypts(hkr, count)) } -const wait = async (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)) - describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) @@ -282,7 +276,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it('waiting callers make their own fetch when the shared one fails', async () => { - const keyStore = slowKeyStore(5, ['fail', 'ok']) + const keyStore = slowKeyStore(['fail', 'ok']) const results = await encryptSettled(keyringFor(keyStore), 10) // Only the caller whose fetch failed sees the error, as without sharing. @@ -290,18 +284,6 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect(keyStore.getActiveBranchKey.callCount).to.equal(10) }) - it('a failing keystore fails each caller with its own error', async () => { - const keyStore = slowKeyStore(5, ['fail']) - const results = await encryptSettled(keyringFor(keyStore), 10) - - for (const result of results) { - expect((result as PromiseRejectedResult).reason.message).to.equal( - 'keystore unavailable' - ) - } - expect(keyStore.getActiveBranchKey.callCount).to.equal(10) - }) - it('releases every waiting caller when the keystore response is malformed', async () => { // Copying this malformed response throws before the waiting callers get it. const keyStore = { @@ -328,14 +310,8 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect(sharedFetchesFor(cache)?.size).to.equal(0) }) - it('waiting callers do not time out', async () => { - const keyStore = slowKeyStore(250) - await encryptConcurrently(keyringFor(keyStore), 10) - expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - }) - it('starts another fetch for new callers once one hangs past graceInterval', async () => { - const keyStore = slowKeyStore(5, ['hang', 'ok']) + const keyStore = slowKeyStore(['hang', 'ok']) const hkr = keyringFor(keyStore) startEncrypts(hkr, 1) @@ -377,6 +353,8 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect((staleResult as PromiseRejectedResult).reason.message).to.equal( 'stale' ) + // A caller arriving now still joins the newer fetch. + newer.push(get()) deferreds[1].resolve( new NodeBranchKeyMaterial(Buffer.alloc(32, 7), BRANCH_KEY_ID_A, v4(), {}) ) @@ -386,55 +364,18 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect(deferreds).to.have.lengthOf(2) }) - it('has no limit on branch keys fetched at once', async () => { - const keyStore = slowKeyStore(20) - const cache = getLocalCryptographicMaterialsCache(100) - const hKeyring = hKeyringFor(keyStore) - - await Promise.all( - Array.from({ length: 30 }, async (_, i) => - getBranchKeyMaterials(hKeyring, cache, BRANCH_KEY_ID_A, `id-${i}`, 'v') - ) - ) - - expect(keyStore.peak()).to.equal(30) - }) - it('forgets a cache entry once its fetches settle', async () => { const cache = getLocalCryptographicMaterialsCache(100) await encryptConcurrently(keyringFor(slowKeyStore(), { cache }), 5) - await encryptSettled(keyringFor(slowKeyStore(5, ['fail']), { cache }), 5) + await encryptSettled(keyringFor(slowKeyStore(['fail']), { cache }), 5) expect(sharedFetchesFor(cache)?.size).to.equal(0) }) - - it('does not refresh an entry before it expires', async () => { - const clock = Sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) - try { - const keyStore = slowKeyStore(20) - const hkr = keyringFor(keyStore) - await encryptConcurrently(hkr, 1) - clock.tick(TTL * 1000 - 1) - - await encryptConcurrently(hkr, 10) - expect(keyStore.getActiveBranchKey.callCount).to.equal(1) - } finally { - clock.restore() - } - }) }) describe('getBranchKeyMaterials: eviction while a request settles', () => { const material = (fill: number) => new NodeBranchKeyMaterial(Buffer.alloc(32, fill), BRANCH_KEY_ID_A, v4(), {}) - function hKeyringWith(keyStore: any) { - return { - keyStore, - cacheLimitTtl: TTL, - cacheEntryHasExceededLimits: () => false, - } as any - } - it('returns an intact branch key when another put evicts the entry before callers resume', async () => { const cache = getLocalCryptographicMaterialsCache(1) // Another branch key is cached one microtask after this one, @@ -455,7 +396,7 @@ describe('getBranchKeyMaterials: eviction while a request settles', () => { const results = await Promise.all( Array.from({ length: 3 }, async () => getBranchKeyMaterials( - hKeyringWith(keyStore), + hKeyringFor(keyStore), racingCache, BRANCH_KEY_ID_A, 'entry', @@ -468,28 +409,4 @@ describe('getBranchKeyMaterials: eviction while a request settles', () => { expect(result.branchKey()).to.deep.equal(Buffer.alloc(32, 1)) } }) - - it('returns intact branch keys for two keys racing through a one-entry cache', async () => { - const cache = getLocalCryptographicMaterialsCache(1) - const keyStore = { - getBranchKeyVersion: async (id: string) => - material(id === BRANCH_KEY_ID_A ? 1 : 2), - } - - const results = await Promise.all( - Array.from({ length: 10 }, async (_, i) => - getBranchKeyMaterials( - hKeyringWith(keyStore), - cache, - i % 2 ? BRANCH_KEY_ID_B : BRANCH_KEY_ID_A, - i % 2 ? 'entry-b' : 'entry-a', - 'version' - ) - ) - ) - - results.forEach((result, i) => - expect(result.branchKey()).to.deep.equal(Buffer.alloc(32, i % 2 ? 2 : 1)) - ) - }) }) From 22417fbfacac8884d3c7850b94a8d3cb0ec70ed5 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 15:03:12 -0700 Subject: [PATCH 29/38] docs: trim shared_requests comments --- modules/cache-material/src/shared_requests.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index ade03bce9..89a2451d3 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -5,19 +5,14 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' /* Concurrent cache misses for the same entry share one request to the backing materials manager. * One caller makes the request, and the others wait for its answer. - * Sharing must not add failures: - * if the shared request fails or its material can't be reused, each waiting caller makes its own request. */ -// An object, not a constant, so tests can shorten it. export const SHARED_REQUESTS = { - // A request older than this may be stuck, so new callers don't join it. + // A request older than this may be stuck. New callers won't join it. graceInterval: 1000, // milliseconds == 1 second } -/* In-flight batches (one request plus its waiting callers) by cache, then cache key. - * Keyed by cache so caching materials managers that share a cache also share requests. - * closeBatch removes a cache key once it has no batches, so the map doesn't grow with every key ever used. +/* Index in-flight batches by cache so caching materials managers that share a cache also share requests. */ export const batchesByCache = new WeakMap< CryptographicMaterialsCache, From bfac44b3800cb23e0b49782f188aa8cae9e89a15 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 15:31:16 -0700 Subject: [PATCH 30/38] fix: share one retry when a shared request fails Waiting callers each made their own request when the shared one failed, so a throttled backend got one call per waiter. Waiters now coalesce into one retry; if it also fails, they get its error. Also give the 3000-operation keyring tests a long grace interval so a slow runner can't start a second fetch and flake the assertion. --- ...hing_cryptographic_materials_decorators.ts | 219 ++++++++++-------- ...c_materials_decorators.concurrency.test.ts | 20 +- .../src/shared_branch_key_fetches.ts | 29 ++- .../kms_hkeyring_node.concurrency.test.ts | 24 +- 4 files changed, 174 insertions(+), 118 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index d4a35f6f3..85e08017f 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -111,73 +111,89 @@ export function getEncryptionMaterials({ */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ - const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - } + /* `retry` is false once this caller has waited on a request that failed. */ + const getShared = async ( + retry: boolean + ): Promise> => { + /* Check for early return (Postcondition): If I have a valid EncryptionMaterial, return it. */ + const entry = this._cache.getEncryptionMaterial(cacheKey, plaintextLength) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } - // If another caller's request can also cover this message, wait for its result. - const joinable = findBatch>( - this._cache, - cacheKey, - (b) => canJoin(this, b, plaintextLength) - ) - if (joinable) { - const outcome = await joinBatch(joinable, plaintextLength) - if ('material' in outcome) return outcome.material - // The shared request failed or its data key can't be reused, so make a new request for just this message. - return cacheEncryptionMaterial( - this, + // If another caller's request can also cover this message, wait for its result. + const joinable = findBatch>( + this._cache, cacheKey, - await fetch(), - plaintextLength + (b) => canJoin(this, b, plaintextLength) ) - } - - const batch = startBatch>( - this._cache, - cacheKey, - this, - plaintextLength - ) - try { - const material = await fetch() - - // Not cache safe, so the waiting callers can't reuse it. - if (!material.suite.cacheSafe) return material - - const used = { - response: material, - now: Date.now(), - messagesEncrypted: batch.messages, - bytesEncrypted: batch.bytes, + if (joinable) { + const outcome = await joinBatch(joinable, plaintextLength) + if ('material' in outcome) return outcome.material + if ('error' in outcome) { + if (!retry) throw outcome.error + // The shared request failed. The failed batch is already closed, + // so the first waiting caller to resume starts one new request and the rest join it. + return getShared(false) + } + // The data key can't be reused, so make a new request for just this message. + return cacheEncryptionMaterial( + this, + cacheKey, + await fetch(), + plaintextLength + ) } - // This message alone is over maxBytesEncrypted or maxMessagesEncrypted, so don't cache the data key. - if (this._cacheEntryHasExceededLimits(used)) return material - this._cache.putEncryptionMaterial( + const batch = startBatch>( + this._cache, cacheKey, - material, - plaintextLength, - this._maxAge + this, + plaintextLength ) - // Count each waiting caller's message against the cache entry. - for (const member of batch.members) { - this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + let release: Outcome> = { unshareable: true } + try { + const material = await fetch() + + // Not cache safe, so the waiting callers can't reuse it. + if (!material.suite.cacheSafe) return material + + const used = { + response: material, + now: Date.now(), + messagesEncrypted: batch.messages, + bytesEncrypted: batch.bytes, + } + // This message alone is over maxBytesEncrypted or maxMessagesEncrypted, so don't cache the data key. + if (this._cacheEntryHasExceededLimits(used)) return material + + this._cache.putEncryptionMaterial( + cacheKey, + material, + plaintextLength, + this._maxAge + ) + // Count each waiting caller's message against the cache entry. + for (const member of batch.members) { + this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + } + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) + } catch (error) { + release = { error } + throw error + } finally { + // Close before releasing the waiting callers, so their retry starts a new batch. + // Callers that already got the material ignore this. + closeBatch(this._cache, cacheKey, batch) + settleMembers(batch, release) } - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) - } - return cloneResponse(material) - } finally { - // Release waiting callers that didn't get the material, so they make their own requests. - // Callers that did get it ignore this. - closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, { failed: true }) } + return getShared(true) } } @@ -246,45 +262,57 @@ export function decryptMaterials({ return material } - /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ - const entry = this._cache.getDecryptionMaterial(cacheKey) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - } + /* `retry` is false once this caller has waited on a request that failed. */ + const getShared = async ( + retry: boolean + ): Promise> => { + /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + const entry = this._cache.getDecryptionMaterial(cacheKey) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } - // If another caller's request is in flight, wait for its result. - const joinable = findBatch>( - this._cache, - cacheKey, - isRecent - ) - if (joinable) { - const outcome = await joinBatch(joinable, 0) - if ('material' in outcome) return outcome.material - // The shared request failed, so make a new request for just this caller. - return cloneResponse(await fetchAndCache()) - } + // If another caller's request is in flight, wait for its result. + const joinable = findBatch>( + this._cache, + cacheKey, + isRecent + ) + if (joinable) { + const outcome = await joinBatch(joinable, 0) + if ('material' in outcome) return outcome.material + if ('error' in outcome && !retry) throw outcome.error + // The shared request failed. The failed batch is already closed, + // so the first waiting caller to resume starts one new request and the rest join it. + return getShared(false) + } - const batch = startBatch>( - this._cache, - cacheKey, - this, - 0 - ) - try { - const material = await fetchAndCache() - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) + const batch = startBatch>( + this._cache, + cacheKey, + this, + 0 + ) + let release: Outcome> = { unshareable: true } + try { + const material = await fetchAndCache() + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) + } + return cloneResponse(material) + } catch (error) { + release = { error } + throw error + } finally { + // Close before releasing the waiting callers, so their retry starts a new batch. + // Callers that already got the material ignore this. + closeBatch(this._cache, cacheKey, batch) + settleMembers(batch, release) } - return cloneResponse(material) - } finally { - // Release waiting callers that didn't get the material, so they make their own requests. - // Callers that did get it ignore this. - closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, { failed: true }) } + return getShared(true) } } @@ -299,8 +327,9 @@ interface Batch { members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } -// What a waiting caller gets: the material, or `failed` if the request failed or its material can't be shared. -type Outcome = { material: M } | { failed: true } +// What a waiting caller gets: the material, the request's error, +// or `unshareable` if the material can't be reused for another message. +type Outcome = { material: M } | { error: unknown } | { unshareable: true } function findBatch( cache: CryptographicMaterialsCache, diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index d901a445c..40ad8dfb3 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -257,18 +257,28 @@ describe('caching materials manager: concurrent cache misses', () => { expect(backing.calls.encrypt).to.equal(1) }) - it('waiting callers make their own request when the shared one fails', async () => { + it('waiting callers share one retry when the shared request fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) const results = await Promise.allSettled(startEncrypts(cmm, times(10))) - // Only the caller whose request failed sees the error, as without sharing. + // Only the caller whose request failed sees the error. expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(backing.calls.encrypt).to.equal(10) + expect(backing.calls.encrypt).to.equal(2) + }) + + it('waiting callers get the error when the shared retry also fails', async () => { + const backing = slowBackingMaterialsManager({ outcomes: ['fail'] }) + const cmm = cachingCMM(backing) + + const results = await Promise.allSettled(startEncrypts(cmm, times(10))) + + for (const result of results) expect(result.status).to.equal('rejected') + expect(backing.calls.encrypt).to.equal(2) }) - it('decrypts make their own request when the shared one fails', async () => { + it('waiting decrypts share one retry when the shared request fails', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['fail', 'ok'] }) const cmm = cachingCMM(backing) @@ -279,7 +289,7 @@ describe('caching materials manager: concurrent cache misses', () => { ) expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(backing.calls.decrypt).to.equal(5) + expect(backing.calls.decrypt).to.equal(2) }) it('starts another request for new callers once one hangs past graceInterval', async () => { diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 76c04b48b..186cb6ebf 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -9,8 +9,9 @@ import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' /* Concurrent cache misses for the same branch key share one keystore fetch. * One caller fetches, and the others wait for its answer. - * Sharing must not add failures: - * if the shared fetch fails, each waiting caller makes its own fetch. + * If the shared fetch fails, the waiting callers share one retry, + * so a failing keystore gets one more call, not one per waiting caller. + * A caller that already retried gets the error. */ // An object, not a constant, so tests can shorten it. @@ -23,7 +24,7 @@ interface SharedFetch { startedAt: number members: ((outcome: SharedOutcome) => void)[] } -type SharedOutcome = { material: NodeBranchKeyMaterial } | { failed: true } +type SharedOutcome = { material: NodeBranchKeyMaterial } | { error: unknown } /* In-flight fetches by cache, then cache entry id. * Keyed by cache so keyrings that share a cache also share fetches. @@ -47,13 +48,15 @@ function isRecent(fetch: SharedFetch) { /* Fetch the branch key materials, or wait for another caller's fetch of the same entry. * `fetch` asks the keystore; `put` caches the result. + * `retry` is false once this caller has waited on a fetch that failed. * Returns a copy only this caller holds. */ export async function sharedFetch( cmc: CryptographicMaterialsCache, cacheEntryId: string, fetch: () => Promise, - put: (material: NodeBranchKeyMaterial) => void + put: (material: NodeBranchKeyMaterial) => void, + retry = true ): Promise { const byId = sharedFetches.get(cmc) || new Map() sharedFetches.set(cmc, byId) @@ -67,10 +70,10 @@ export async function sharedFetch( current.members.push(resolve) ) if ('material' in outcome) return outcome.material - // The shared fetch failed, so fetch for just this caller. - const material = await fetch() - put(material) - return deepCopyBranchKeyMaterial(material) + if (!retry) throw outcome.error + // The shared fetch failed. The failed fetch is already closed, + // so the first waiting caller to resume starts one new fetch and the rest join it. + return sharedFetch(cmc, cacheEntryId, fetch, put, false) } const mine: SharedFetch = { startedAt: Date.now(), members: [] } @@ -84,6 +87,7 @@ export async function sharedFetch( if (!list.length) byId.delete(cacheEntryId) } + let failure: SharedOutcome = { error: undefined } try { const material = await fetch() // Copy before put: once cached, an eviction can zero the material's buffer. @@ -92,11 +96,14 @@ export async function sharedFetch( } put(material) return deepCopyBranchKeyMaterial(material) + } catch (error) { + failure = { error } + throw error } finally { - // Release waiting callers that didn't get the material, so they make their own fetches. - // Callers that did get it ignore this. + // Close before releasing the waiting callers, so their retry starts a new fetch. + // Callers that already got the material ignore this. close() - for (const settle of mine.members) settle({ failed: true }) + for (const settle of mine.members) settle(failure) } } diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 64ebe7ccf..06d52cbab 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -237,16 +237,18 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // 3000 callers can block the event loop past the shortened graceInterval, which starts a second fetch. - Object.assign(SHARED_FETCHES, DEFAULT) + // Starting 3000 callers takes an unpredictable time on a slow runner. + // A grace interval far longer than the test keeps that from starting a second fetch. + Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // 3000 callers can block the event loop past the shortened graceInterval, which starts a second fetch. - Object.assign(SHARED_FETCHES, DEFAULT) + // Starting 3000 callers takes an unpredictable time on a slow runner. + // A grace interval far longer than the test keeps that from starting a second fetch. + Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) @@ -275,13 +277,21 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) - it('waiting callers make their own fetch when the shared one fails', async () => { + it('waiting callers share one retry when the shared fetch fails', async () => { const keyStore = slowKeyStore(['fail', 'ok']) const results = await encryptSettled(keyringFor(keyStore), 10) - // Only the caller whose fetch failed sees the error, as without sharing. + // Only the caller whose fetch failed sees the error. expect(results.filter((r) => r.status === 'rejected')).to.have.lengthOf(1) - expect(keyStore.getActiveBranchKey.callCount).to.equal(10) + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) + }) + + it('waiting callers get the error when the shared retry also fails', async () => { + const keyStore = slowKeyStore(['fail']) + const results = await encryptSettled(keyringFor(keyStore), 10) + + for (const result of results) expect(result.status).to.equal('rejected') + expect(keyStore.getActiveBranchKey.callCount).to.equal(2) }) it('releases every waiting caller when the keystore response is malformed', async () => { From 8758dbb1c2364956c00b878d1efab540d9f6cc91 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 15:45:40 -0700 Subject: [PATCH 31/38] docs: rewrite shared request comments Cut comments that restate the code, state each invariant once, and fix mid-line periods and hidden actors. --- ...hing_cryptographic_materials_decorators.ts | 65 +++++++++---------- modules/cache-material/src/shared_requests.ts | 11 ++-- ...c_materials_decorators.concurrency.test.ts | 2 +- .../src/kms_hkeyring_node_helpers.ts | 1 - .../src/shared_branch_key_fetches.ts | 35 +++++----- .../kms_hkeyring_node.concurrency.test.ts | 8 +-- 6 files changed, 56 insertions(+), 66 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 85e08017f..84b0951db 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -105,13 +105,12 @@ export function getEncryptionMaterials({ }) const fetch = async () => this._backingMaterialsManager - /* Leave plaintextLength out of the request to the backing materials manager. - * The data key it returns is cached and reused for other messages, - * so it must not depend on this message's length. + /* The cache reuses this data key for other messages, + * so the backing request must not carry this message's plaintextLength. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - /* `retry` is false once this caller has waited on a request that failed. */ + // A caller that waited on a failed request gets one more attempt, with `retry` false. const getShared = async ( retry: boolean ): Promise> => { @@ -123,7 +122,7 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - // If another caller's request can also cover this message, wait for its result. + // Wait on an in-flight request whose data key still has room for this message. const joinable = findBatch>( this._cache, cacheKey, @@ -134,11 +133,11 @@ export function getEncryptionMaterials({ if ('material' in outcome) return outcome.material if ('error' in outcome) { if (!retry) throw outcome.error - // The shared request failed. The failed batch is already closed, - // so the first waiting caller to resume starts one new request and the rest join it. + // The failed batch closed before its waiting callers resumed, + // so the first one to resume starts the retry and the rest join it. return getShared(false) } - // The data key can't be reused, so make a new request for just this message. + // The data key can't be shared, so this message needs its own request. return cacheEncryptionMaterial( this, cacheKey, @@ -157,7 +156,7 @@ export function getEncryptionMaterials({ try { const material = await fetch() - // Not cache safe, so the waiting callers can't reuse it. + // Not cache safe, so the waiting callers can't share it. if (!material.suite.cacheSafe) return material const used = { @@ -166,7 +165,8 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // This message alone is over maxBytesEncrypted or maxMessagesEncrypted, so don't cache the data key. + // canJoin keeps waiting callers within the limits, + // so this only trips when this caller's message alone exceeds them. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( @@ -175,7 +175,7 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - // Count each waiting caller's message against the cache entry. + // Each getEncryptionMaterial call counts one waiting caller's message against the entry. for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -187,8 +187,9 @@ export function getEncryptionMaterials({ release = { error } throw error } finally { - // Close before releasing the waiting callers, so their retry starts a new batch. - // Callers that already got the material ignore this. + // Close before settling, so a waiting caller's retry starts a new batch + // instead of rejoining this one. + // Callers that already got the material ignore this second settle. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -212,9 +213,8 @@ function cacheEncryptionMaterial( messagesEncrypted: 1, bytesEncrypted: plaintextLength, } - /* It is possible for an entry to exceed bytes/messages encrypted limits immediately. - * In this case, return the response to encrypt the data, - * but do not put a known invalid item into the cache. + /* One message can exceed the bytes or messages limit by itself. + * Return the material for it, but don't cache a data key that is already over its limits. */ if (!cmm._cacheEntryHasExceededLimits(testEntry)) { cmm._cache.putEncryptionMaterial( @@ -226,9 +226,7 @@ function cacheEncryptionMaterial( return cloneResponse(material) } else { /* Postcondition: If the material has exceeded limits it MUST NOT be cloned. - * If it is cloned, and the clone is returned, - * then there exists a copy of the unencrypted data key. - * It is true that this data would be caught by GC, it is better to just not rely on that. + * A clone would leave a second copy of the unencrypted data key for GC to clean up. */ return material } @@ -262,7 +260,7 @@ export function decryptMaterials({ return material } - /* `retry` is false once this caller has waited on a request that failed. */ + // A caller that waited on a failed request gets one more attempt, with `retry` false. const getShared = async ( retry: boolean ): Promise> => { @@ -274,7 +272,6 @@ export function decryptMaterials({ this._cache.del(cacheKey) } - // If another caller's request is in flight, wait for its result. const joinable = findBatch>( this._cache, cacheKey, @@ -284,8 +281,8 @@ export function decryptMaterials({ const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material if ('error' in outcome && !retry) throw outcome.error - // The shared request failed. The failed batch is already closed, - // so the first waiting caller to resume starts one new request and the rest join it. + // The failed batch closed before its waiting callers resumed, + // so the first one to resume starts the retry and the rest join it. return getShared(false) } @@ -306,8 +303,9 @@ export function decryptMaterials({ release = { error } throw error } finally { - // Close before releasing the waiting callers, so their retry starts a new batch. - // Callers that already got the material ignore this. + // Close before settling, so a waiting caller's retry starts a new batch + // instead of rejoining this one. + // Callers that already got the material ignore this second settle. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -316,7 +314,7 @@ export function decryptMaterials({ } } -// Tracks one request to the backing materials manager plus the callers waiting for its answer. +// One request to the backing materials manager, and the callers waiting on it. interface Batch { startedAt: number // Messages and bytes the data key will encrypt, counting every caller in the batch. @@ -328,7 +326,7 @@ interface Batch { } // What a waiting caller gets: the material, the request's error, -// or `unshareable` if the material can't be reused for another message. +// or `unshareable` if the material can't be shared with another message. type Outcome = { material: M } | { error: unknown } | { unshareable: true } function findBatch( @@ -340,14 +338,12 @@ function findBatch( return batches?.find(predicate) } -/* A request older than graceInterval may be stuck, so new callers don't join it. */ function isRecent(batch: Batch) { return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } -/* Whether adding this caller's message keeps the batch within the data key's limits. - * The batch may belong to another caching materials manager on the same cache, - * so check both managers' limits. +/* The batch may belong to another caching materials manager on the same cache, + * so the joined batch must fit both managers' limits. */ function canJoin( cmm: CachingMaterialsManager, @@ -378,8 +374,7 @@ function startBatch( ): Batch { const byKey = batchesByCache.get(cache) || new Map[]>() batchesByCache.set(cache, byKey) - // Drop this key's batches that are too old to join. - // A request that never settles is only ever removed here. + // A request that never settles is only removed here, once a newer one starts for the same key. const batches = (byKey.get(cacheKey) || []).filter(isRecent) byKey.set(cacheKey, batches) const batch: Batch = { @@ -406,7 +401,9 @@ function closeBatch( if (!batches.length) byKey.delete(cacheKey) } -/* Wait for the shared request's answer, counting this caller's message against its data key. */ +/* Counts this caller's message against the batch now, before the data key exists, + * so canJoin sees it when the next caller arrives. + */ async function joinBatch( batch: Batch, plaintextLength: number diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 89a2451d3..0eda36c45 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -4,16 +4,17 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' /* Concurrent cache misses for the same entry share one request to the backing materials manager. - * One caller makes the request, and the others wait for its answer. + * If that request fails, its waiting callers share one retry, + * so a failing backing materials manager gets one more call, not one per waiting caller. */ +// An object, not a constant, so tests can shorten it. export const SHARED_REQUESTS = { - // A request older than this may be stuck. New callers won't join it. - graceInterval: 1000, // milliseconds == 1 second + // A request older than this may be stuck, so new callers don't join it. + graceInterval: 1000, // milliseconds } -/* Index in-flight batches by cache so caching materials managers that share a cache also share requests. - */ +// Keyed by cache, so caching materials managers that share a cache also share requests. export const batchesByCache = new WeakMap< CryptographicMaterialsCache, Map diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 40ad8dfb3..79d8314dc 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -49,7 +49,7 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( .digest() ) -// Shortened timings, so the tests run in milliseconds. +// A short grace interval, so the hang tests run in milliseconds. const FAST = { graceInterval: 20 } const DEFAULT = { ...SHARED_REQUESTS } diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index 2c71a8133..def5f9086 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -204,7 +204,6 @@ export async function getBranchKeyMaterials( //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) //# from the underlying [cryptographic materials cache](../local-cryptographic-materials-cache.md). const cacheEntry = cmc.getBranchKeyMaterial(cacheEntryId) - // if the cache entry is false, branch key materials were not found if (!cacheEntry || hKeyring.cacheEntryHasExceededLimits(cacheEntry)) { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# If this is NOT true, then we MUST treat the cache entry as expired. diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 186cb6ebf..2d3a75dd5 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -8,10 +8,8 @@ import { import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' /* Concurrent cache misses for the same branch key share one keystore fetch. - * One caller fetches, and the others wait for its answer. - * If the shared fetch fails, the waiting callers share one retry, + * If that fetch fails, its waiting callers share one retry, * so a failing keystore gets one more call, not one per waiting caller. - * A caller that already retried gets the error. */ // An object, not a constant, so tests can shorten it. @@ -26,10 +24,9 @@ interface SharedFetch { } type SharedOutcome = { material: NodeBranchKeyMaterial } | { error: unknown } -/* In-flight fetches by cache, then cache entry id. - * Keyed by cache so keyrings that share a cache also share fetches. - * Each fetch is its own object, so a slow fetch that settles late removes only itself, - * never a newer fetch for the same entry. +/* Keyed by cache, so keyrings that share a cache also share fetches. + * Each entry id holds a list, so a stale fetch that settles late removes only itself, + * not a newer fetch for the same entry. */ const sharedFetches = new WeakMap< CryptographicMaterialsCache, @@ -46,10 +43,9 @@ function isRecent(fetch: SharedFetch) { return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval } -/* Fetch the branch key materials, or wait for another caller's fetch of the same entry. - * `fetch` asks the keystore; `put` caches the result. - * `retry` is false once this caller has waited on a fetch that failed. +/* Fetch the branch key materials, or wait on another caller's fetch of the same entry. * Returns a copy only this caller holds. + * A caller that waited on a failed fetch gets one more attempt, with `retry` false. */ export async function sharedFetch( cmc: CryptographicMaterialsCache, @@ -60,8 +56,7 @@ export async function sharedFetch( ): Promise { const byId = sharedFetches.get(cmc) || new Map() sharedFetches.set(cmc, byId) - // Drop this entry's fetches that are too old to join. - // A fetch that never settles is only ever removed here. + // A fetch that never settles is only removed here, once a newer one starts for the same entry. const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) const current = fetches[fetches.length - 1] @@ -71,8 +66,8 @@ export async function sharedFetch( ) if ('material' in outcome) return outcome.material if (!retry) throw outcome.error - // The shared fetch failed. The failed fetch is already closed, - // so the first waiting caller to resume starts one new fetch and the rest join it. + // The failed fetch closed before its waiting callers resumed, + // so the first one to resume starts the retry and the rest join it. return sharedFetch(cmc, cacheEntryId, fetch, put, false) } @@ -100,17 +95,17 @@ export async function sharedFetch( failure = { error } throw error } finally { - // Close before releasing the waiting callers, so their retry starts a new fetch. - // Callers that already got the material ignore this. + // Close before settling, so a waiting caller's retry starts a new fetch + // instead of rejoining this one. + // Callers that already got the material ignore this second settle. close() for (const settle of mine.members) settle(failure) } } -/* A copy with its own buffer. - * The cache zeroes a material's buffer when it evicts it, - * and callers derive keys from the branch key after getBranchKeyMaterials returns, - * so handing out the cached buffer lets an eviction zero a key mid-use (#1691). +/* The cache zeroes a material's buffer when it evicts it. + * Callers derive keys from the branch key after getBranchKeyMaterials returns, + * so they need their own buffer, or an eviction can zero the key mid-use (#1691). */ export function deepCopyBranchKeyMaterial( material: NodeBranchKeyMaterial diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 06d52cbab..8d61b08fd 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -155,7 +155,7 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// Shortened timings, so the tests run in milliseconds. +// A short grace interval, so the hang tests run in milliseconds. const FAST = { graceInterval: 40 } const DEFAULT = { ...SHARED_FETCHES } @@ -237,8 +237,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // Starting 3000 callers takes an unpredictable time on a slow runner. - // A grace interval far longer than the test keeps that from starting a second fetch. + // On a slow runner, starting 3000 callers can outlast a short grace interval and start a second fetch. Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) @@ -246,8 +245,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // Starting 3000 callers takes an unpredictable time on a slow runner. - // A grace interval far longer than the test keeps that from starting a second fetch. + // On a slow runner, starting 3000 callers can outlast a short grace interval and start a second fetch. Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) From 094141202a542e488d686c62100e9ab743809447 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 16:32:37 -0700 Subject: [PATCH 32/38] docs: state the retry rule instead of the flag --- ...hing_cryptographic_materials_decorators.ts | 22 +++++++++---------- .../src/shared_branch_key_fetches.ts | 11 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 84b0951db..bde884d8b 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -110,7 +110,8 @@ export function getEncryptionMaterials({ */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - // A caller that waited on a failed request gets one more attempt, with `retry` false. + // If a shared request fails, its waiting callers retry once, together. + // If the retry fails too, they throw its error. const getShared = async ( retry: boolean ): Promise> => { @@ -133,8 +134,8 @@ export function getEncryptionMaterials({ if ('material' in outcome) return outcome.material if ('error' in outcome) { if (!retry) throw outcome.error - // The failed batch closed before its waiting callers resumed, - // so the first one to resume starts the retry and the rest join it. + // The failed batch is gone by the time its waiting callers resume, + // so the first to resume starts one new request and the rest join it. return getShared(false) } // The data key can't be shared, so this message needs its own request. @@ -187,9 +188,7 @@ export function getEncryptionMaterials({ release = { error } throw error } finally { - // Close before settling, so a waiting caller's retry starts a new batch - // instead of rejoining this one. - // Callers that already got the material ignore this second settle. + // After a success, every waiting caller already has the material and ignores this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -260,7 +259,8 @@ export function decryptMaterials({ return material } - // A caller that waited on a failed request gets one more attempt, with `retry` false. + // If a shared request fails, its waiting callers retry once, together. + // If the retry fails too, they throw its error. const getShared = async ( retry: boolean ): Promise> => { @@ -281,8 +281,8 @@ export function decryptMaterials({ const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material if ('error' in outcome && !retry) throw outcome.error - // The failed batch closed before its waiting callers resumed, - // so the first one to resume starts the retry and the rest join it. + // The failed batch is gone by the time its waiting callers resume, + // so the first to resume starts one new request and the rest join it. return getShared(false) } @@ -303,9 +303,7 @@ export function decryptMaterials({ release = { error } throw error } finally { - // Close before settling, so a waiting caller's retry starts a new batch - // instead of rejoining this one. - // Callers that already got the material ignore this second settle. + // After a success, every waiting caller already has the material and ignores this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 2d3a75dd5..293fd4484 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -45,7 +45,8 @@ function isRecent(fetch: SharedFetch) { /* Fetch the branch key materials, or wait on another caller's fetch of the same entry. * Returns a copy only this caller holds. - * A caller that waited on a failed fetch gets one more attempt, with `retry` false. + * If the shared fetch fails, its waiting callers retry once, together. + * If the retry fails too, they throw its error. */ export async function sharedFetch( cmc: CryptographicMaterialsCache, @@ -66,8 +67,8 @@ export async function sharedFetch( ) if ('material' in outcome) return outcome.material if (!retry) throw outcome.error - // The failed fetch closed before its waiting callers resumed, - // so the first one to resume starts the retry and the rest join it. + // The failed fetch is gone by the time its waiting callers resume, + // so the first to resume starts one new fetch and the rest join it. return sharedFetch(cmc, cacheEntryId, fetch, put, false) } @@ -95,9 +96,7 @@ export async function sharedFetch( failure = { error } throw error } finally { - // Close before settling, so a waiting caller's retry starts a new fetch - // instead of rejoining this one. - // Callers that already got the material ignore this second settle. + // After a success, every waiting caller already has the material and ignores this. close() for (const settle of mine.members) settle(failure) } From c0f1dec14701d050c2812047b249547fb827775d Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 16:34:39 -0700 Subject: [PATCH 33/38] docs: say what releasing waiting callers does on failure --- .../src/caching_cryptographic_materials_decorators.ts | 8 ++++++-- modules/kms-keyring-node/src/shared_branch_key_fetches.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index bde884d8b..51b22184f 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -188,7 +188,10 @@ export function getEncryptionMaterials({ release = { error } throw error } finally { - // After a success, every waiting caller already has the material and ignores this. + // Release the callers still waiting: + // on an error they retry once, together, + // and on unshareable material they each make their own request. + // After a success they already have the material and ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -303,7 +306,8 @@ export function decryptMaterials({ release = { error } throw error } finally { - // After a success, every waiting caller already has the material and ignores this. + // Release the callers still waiting with the error, so they retry once, together. + // After a success they already have the material and ignore this. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 293fd4484..747644b9b 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -96,7 +96,8 @@ export async function sharedFetch( failure = { error } throw error } finally { - // After a success, every waiting caller already has the material and ignores this. + // Release the callers still waiting with the error, so they retry once, together. + // After a success they already have the material and ignore this. close() for (const settle of mine.members) settle(failure) } From 41814dadad55bf37023ae8f467e9fd850f074c95 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 16:35:37 -0700 Subject: [PATCH 34/38] docs: explain when the batch can exceed its limits --- .../src/caching_cryptographic_materials_decorators.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 51b22184f..6b40731b2 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -166,8 +166,9 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // canJoin keeps waiting callers within the limits, - // so this only trips when this caller's message alone exceeds them. + // Over the limits only when this message is larger than maxBytesEncrypted. + // canJoin turns away any caller that would push the batch over, so no one is waiting. + // Return the data key without caching it. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( From 48438258b31b0e09e57dd3fd4a49f62f89a2c042 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 16:37:30 -0700 Subject: [PATCH 35/38] docs: replace jargon in shared request comments --- ...hing_cryptographic_materials_decorators.ts | 72 ++++++++++--------- modules/cache-material/src/shared_requests.ts | 1 + ...c_materials_decorators.concurrency.test.ts | 2 +- .../src/shared_branch_key_fetches.ts | 22 +++--- .../kms_hkeyring_node.concurrency.test.ts | 6 +- 5 files changed, 58 insertions(+), 45 deletions(-) diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 6b40731b2..7e9e531f7 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -106,12 +106,12 @@ export function getEncryptionMaterials({ const fetch = async () => this._backingMaterialsManager /* The cache reuses this data key for other messages, - * so the backing request must not carry this message's plaintextLength. + * so don't pass this message's plaintextLength to the backing materials manager. */ .getEncryptionMaterials({ suite, encryptionContext, commitmentPolicy }) - // If a shared request fails, its waiting callers retry once, together. - // If the retry fails too, they throw its error. + // If the request this caller waited on fails, it tries once more, and a second failure throws. + // `retry` is false on that second try. const getShared = async ( retry: boolean ): Promise> => { @@ -123,7 +123,8 @@ export function getEncryptionMaterials({ this._cache.del(cacheKey) } - // Wait on an in-flight request whose data key still has room for this message. + // Another caller may already be requesting a data key for this cache entry. + // Wait for that one if it can also encrypt this message without going over the limits. const joinable = findBatch>( this._cache, cacheKey, @@ -134,11 +135,12 @@ export function getEncryptionMaterials({ if ('material' in outcome) return outcome.material if ('error' in outcome) { if (!retry) throw outcome.error - // The failed batch is gone by the time its waiting callers resume, - // so the first to resume starts one new request and the rest join it. + // The caller that made the failed request already removed it, + // so the first waiting caller to get here starts a new request and the others wait on that one. return getShared(false) } - // The data key can't be shared, so this message needs its own request. + // The other caller's data key isn't cache safe, so this message can't use it. + // Make a separate request. return cacheEncryptionMaterial( this, cacheKey, @@ -157,7 +159,8 @@ export function getEncryptionMaterials({ try { const material = await fetch() - // Not cache safe, so the waiting callers can't share it. + // Not cache safe: return it without caching. + // The waiting callers can't use it, so each makes its own request. if (!material.suite.cacheSafe) return material const used = { @@ -166,9 +169,9 @@ export function getEncryptionMaterials({ messagesEncrypted: batch.messages, bytesEncrypted: batch.bytes, } - // Over the limits only when this message is larger than maxBytesEncrypted. - // canJoin turns away any caller that would push the batch over, so no one is waiting. - // Return the data key without caching it. + // This is true only if this message alone is larger than maxBytesEncrypted, + // because canJoin turns away any caller that would go over the limits. + // So no one is waiting; return the data key without caching it. if (this._cacheEntryHasExceededLimits(used)) return material this._cache.putEncryptionMaterial( @@ -177,7 +180,8 @@ export function getEncryptionMaterials({ plaintextLength, this._maxAge ) - // Each getEncryptionMaterial call counts one waiting caller's message against the entry. + // getEncryptionMaterial also adds to the entry's message and byte counts. + // Call it once per waiting caller so the cached entry counts their messages too. for (const member of batch.members) { this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } @@ -189,10 +193,10 @@ export function getEncryptionMaterials({ release = { error } throw error } finally { - // Release the callers still waiting: - // on an error they retry once, together, - // and on unshareable material they each make their own request. - // After a success they already have the material and ignore this. + // Tell the callers still waiting how this request ended: + // - on an error, they retry once + // - if they can't use this data key, they each make their own request + // After a success they already have the data key, so this does nothing. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -216,8 +220,8 @@ function cacheEncryptionMaterial( messagesEncrypted: 1, bytesEncrypted: plaintextLength, } - /* One message can exceed the bytes or messages limit by itself. - * Return the material for it, but don't cache a data key that is already over its limits. + /* One message can be larger than maxBytesEncrypted by itself. + * Return the data key for it, but don't cache it. */ if (!cmm._cacheEntryHasExceededLimits(testEntry)) { cmm._cache.putEncryptionMaterial( @@ -263,8 +267,8 @@ export function decryptMaterials({ return material } - // If a shared request fails, its waiting callers retry once, together. - // If the retry fails too, they throw its error. + // If the request this caller waited on fails, it tries once more, and a second failure throws. + // `retry` is false on that second try. const getShared = async ( retry: boolean ): Promise> => { @@ -285,8 +289,8 @@ export function decryptMaterials({ const outcome = await joinBatch(joinable, 0) if ('material' in outcome) return outcome.material if ('error' in outcome && !retry) throw outcome.error - // The failed batch is gone by the time its waiting callers resume, - // so the first to resume starts one new request and the rest join it. + // The caller that made the failed request already removed it, + // so the first waiting caller to get here starts a new request and the others wait on that one. return getShared(false) } @@ -307,8 +311,8 @@ export function decryptMaterials({ release = { error } throw error } finally { - // Release the callers still waiting with the error, so they retry once, together. - // After a success they already have the material and ignore this. + // If the request failed, tell the callers still waiting so they retry once. + // After a success they already have the material, so this does nothing. closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } @@ -320,7 +324,8 @@ export function decryptMaterials({ // One request to the backing materials manager, and the callers waiting on it. interface Batch { startedAt: number - // Messages and bytes the data key will encrypt, counting every caller in the batch. + // Messages and bytes the data key will encrypt, + // counting the caller that made the request and every caller waiting on it. // canJoin checks these against the limits before the data key exists. messages: number bytes: number @@ -328,8 +333,10 @@ interface Batch { members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] } -// What a waiting caller gets: the material, the request's error, -// or `unshareable` if the material can't be shared with another message. +// What a waiting caller gets back: +// - the material +// - the request's error +// - `unshareable`, when the data key can't be used for other messages type Outcome = { material: M } | { error: unknown } | { unshareable: true } function findBatch( @@ -345,8 +352,8 @@ function isRecent(batch: Batch) { return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } -/* The batch may belong to another caching materials manager on the same cache, - * so the joined batch must fit both managers' limits. +/* The request may come from another caching materials manager that shares this cache. + * Join only if this caller's message stays within both managers' limits. */ function canJoin( cmm: CachingMaterialsManager, @@ -367,7 +374,7 @@ function canJoin( } /* Call this right after findBatch finds nothing, with no await in between. - * Otherwise concurrent callers each start their own batch. + * Otherwise callers arriving at the same time each start their own request. */ function startBatch( cache: CryptographicMaterialsCache, @@ -377,7 +384,8 @@ function startBatch( ): Batch { const byKey = batchesByCache.get(cache) || new Map[]>() batchesByCache.set(cache, byKey) - // A request that never settles is only removed here, once a newer one starts for the same key. + // A request that hangs never removes itself. + // This drops it when the next request for this key starts. const batches = (byKey.get(cacheKey) || []).filter(isRecent) byKey.set(cacheKey, batches) const batch: Batch = { @@ -404,7 +412,7 @@ function closeBatch( if (!batches.length) byKey.delete(cacheKey) } -/* Counts this caller's message against the batch now, before the data key exists, +/* Counts this caller's message against the request now, before the data key exists, * so canJoin sees it when the next caller arrives. */ async function joinBatch( diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 0eda36c45..864462fa8 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -14,6 +14,7 @@ export const SHARED_REQUESTS = { graceInterval: 1000, // milliseconds } +// Requests in progress, by cache and then by cache key. // Keyed by cache, so caching materials managers that share a cache also share requests. export const batchesByCache = new WeakMap< CryptographicMaterialsCache, diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index 79d8314dc..fbfdcb6b8 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -188,7 +188,7 @@ describe('caching materials manager: concurrent cache misses', () => { const strict = cachingCMM(backing, { cache, maxMessagesEncrypted: 2 }) const loose = cachingCMM(backing, { cache, maxMessagesEncrypted: 10 }) - // strict's request can serve 2 messages, so loose's 5 share a second request. + // strict's data key can encrypt only 2 messages, so loose's 5 callers share a second request. await Promise.all([ ...startEncrypts(strict, times(2)), ...startEncrypts(loose, times(5)), diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 747644b9b..27754abeb 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -24,8 +24,9 @@ interface SharedFetch { } type SharedOutcome = { material: NodeBranchKeyMaterial } | { error: unknown } -/* Keyed by cache, so keyrings that share a cache also share fetches. - * Each entry id holds a list, so a stale fetch that settles late removes only itself, +/* Fetches in progress, by cache and then by cache entry id. + * Keyed by cache, so keyrings that share a cache also share fetches. + * Each entry id holds a list, so a stuck fetch that finishes late removes only itself, * not a newer fetch for the same entry. */ const sharedFetches = new WeakMap< @@ -44,9 +45,9 @@ function isRecent(fetch: SharedFetch) { } /* Fetch the branch key materials, or wait on another caller's fetch of the same entry. - * Returns a copy only this caller holds. - * If the shared fetch fails, its waiting callers retry once, together. - * If the retry fails too, they throw its error. + * Returns a copy with its own buffer (see deepCopyBranchKeyMaterial). + * If the fetch this caller waited on fails, it tries once more, and a second failure throws. + * `retry` is false on that second try. */ export async function sharedFetch( cmc: CryptographicMaterialsCache, @@ -57,7 +58,8 @@ export async function sharedFetch( ): Promise { const byId = sharedFetches.get(cmc) || new Map() sharedFetches.set(cmc, byId) - // A fetch that never settles is only removed here, once a newer one starts for the same entry. + // A fetch that hangs never removes itself. + // This drops it when the next fetch for this entry starts. const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) const current = fetches[fetches.length - 1] @@ -67,8 +69,8 @@ export async function sharedFetch( ) if ('material' in outcome) return outcome.material if (!retry) throw outcome.error - // The failed fetch is gone by the time its waiting callers resume, - // so the first to resume starts one new fetch and the rest join it. + // The caller that made the failed fetch already removed it, + // so the first waiting caller to get here starts a new fetch and the others wait on that one. return sharedFetch(cmc, cacheEntryId, fetch, put, false) } @@ -96,8 +98,8 @@ export async function sharedFetch( failure = { error } throw error } finally { - // Release the callers still waiting with the error, so they retry once, together. - // After a success they already have the material and ignore this. + // If the fetch failed, tell the callers still waiting so they retry once. + // After a success they already have the material, so this does nothing. close() for (const settle of mine.members) settle(failure) } diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 8d61b08fd..53242191c 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -237,7 +237,8 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can outlast a short grace interval and start a second fetch. + // On a slow runner, starting 3000 callers can take longer than a short grace interval, + // which starts a second fetch. Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) @@ -245,7 +246,8 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can outlast a short grace interval and start a second fetch. + // On a slow runner, starting 3000 callers can take longer than a short grace interval, + // which starts a second fetch. Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) From 8eb86f1bfcb58cb4fbcbbc6785c953b8db216612 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Mon, 12 Oct 2026 16:40:09 -0700 Subject: [PATCH 36/38] docs: drop comment on the shared timing object --- modules/cache-material/src/shared_requests.ts | 1 - modules/kms-keyring-node/src/shared_branch_key_fetches.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 864462fa8..553e93576 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -8,7 +8,6 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' * so a failing backing materials manager gets one more call, not one per waiting caller. */ -// An object, not a constant, so tests can shorten it. export const SHARED_REQUESTS = { // A request older than this may be stuck, so new callers don't join it. graceInterval: 1000, // milliseconds diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts index 27754abeb..c01e53c20 100644 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -12,7 +12,6 @@ import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' * so a failing keystore gets one more call, not one per waiting caller. */ -// An object, not a constant, so tests can shorten it. export const SHARED_FETCHES = { // A fetch older than this may be stuck, so new callers don't join it. graceInterval: 1000, // milliseconds From 61f44421984e23c7e798661413d0df1d95ac8b2c Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Tue, 13 Oct 2026 09:06:06 -0700 Subject: [PATCH 37/38] refactor: share one in-flight request helper Move request sharing into cache-material's shared_requests so the caching CMM and the hierarchical keyring use one implementation. Rename graceInterval to inFlightTTL to match the MPL's StormTracker naming. The keyring now re-checks the cache before a shared retry. --- ...hing_cryptographic_materials_decorators.ts | 211 +++++------------- modules/cache-material/src/index.ts | 1 + modules/cache-material/src/shared_requests.ts | 157 ++++++++++++- ...c_materials_decorators.concurrency.test.ts | 16 +- .../src/kms_hkeyring_node_helpers.ts | 48 ++-- .../src/shared_branch_key_fetches.ts | 120 ---------- .../kms_hkeyring_node.concurrency.test.ts | 36 +-- 7 files changed, 263 insertions(+), 326 deletions(-) delete mode 100644 modules/kms-keyring-node/src/shared_branch_key_fetches.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 7e9e531f7..3db924607 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -21,7 +21,17 @@ import { Entry, } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -import { SHARED_REQUESTS, batchesByCache } from './shared_requests' +import { + SharedRequest, + SharedOutcome, + closeRequest, + findRequest, + isRecent, + settleMembers, + shareRequest, + startRequest, + waitOn, +} from './shared_requests' export function decorateProperties( obj: CachingMaterialsManager, @@ -125,13 +135,17 @@ export function getEncryptionMaterials({ // Another caller may already be requesting a data key for this cache entry. // Wait for that one if it can also encrypt this message without going over the limits. - const joinable = findBatch>( + const joinable = findRequest>( this._cache, cacheKey, - (b) => canJoin(this, b, plaintextLength) + (r) => canJoin(this, r, plaintextLength) ) if (joinable) { - const outcome = await joinBatch(joinable, plaintextLength) + // Count this caller's message against the request now, before the data key exists, + // so canJoin sees it when the next caller arrives. + joinable.messages += 1 + joinable.bytes += plaintextLength + const outcome = await waitOn(joinable, plaintextLength) if ('material' in outcome) return outcome.material if ('error' in outcome) { if (!retry) throw outcome.error @@ -149,13 +163,14 @@ export function getEncryptionMaterials({ ) } - const batch = startBatch>( - this._cache, - cacheKey, - this, - plaintextLength - ) - let release: Outcome> = { unshareable: true } + const batch = startRequest>(this._cache, cacheKey, { + startedAt: Date.now(), + messages: 1, + bytes: plaintextLength, + requester: this, + members: [], + }) + let release: SharedOutcome> = { unshareable: true } try { const material = await fetch() @@ -183,7 +198,7 @@ export function getEncryptionMaterials({ // getEncryptionMaterial also adds to the entry's message and byte counts. // Call it once per waiting caller so the cached entry counts their messages too. for (const member of batch.members) { - this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) + this._cache.getEncryptionMaterial(cacheKey, member.info) } for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) @@ -197,7 +212,7 @@ export function getEncryptionMaterials({ // - on an error, they retry once // - if they can't use this data key, they each make their own request // After a success they already have the data key, so this does nothing. - closeBatch(this._cache, cacheKey, batch) + closeRequest(this._cache, cacheKey, batch) settleMembers(batch, release) } } @@ -259,97 +274,36 @@ export function decryptMaterials({ this._partition, request ) - const fetchAndCache = async () => { - const material = await this._backingMaterialsManager.decryptMaterials( - request - ) - this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) - return material - } - - // If the request this caller waited on fails, it tries once more, and a second failure throws. - // `retry` is false on that second try. - const getShared = async ( - retry: boolean - ): Promise> => { - /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ - const entry = this._cache.getDecryptionMaterial(cacheKey) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - } - - const joinable = findBatch>( - this._cache, - cacheKey, - isRecent - ) - if (joinable) { - const outcome = await joinBatch(joinable, 0) - if ('material' in outcome) return outcome.material - if ('error' in outcome && !retry) throw outcome.error - // The caller that made the failed request already removed it, - // so the first waiting caller to get here starts a new request and the others wait on that one. - return getShared(false) - } - - const batch = startBatch>( - this._cache, - cacheKey, - this, - 0 - ) - let release: Outcome> = { unshareable: true } - try { - const material = await fetchAndCache() - for (const member of batch.members) { - member.settle({ material: cloneResponse(material) }) + return shareRequest(this._cache, cacheKey, { + lookUp: () => { + /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + const entry = this._cache.getDecryptionMaterial(cacheKey) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + return undefined } - return cloneResponse(material) - } catch (error) { - release = { error } - throw error - } finally { - // If the request failed, tell the callers still waiting so they retry once. - // After a success they already have the material, so this does nothing. - closeBatch(this._cache, cacheKey, batch) - settleMembers(batch, release) - } - } - return getShared(true) + }, + fetch: async () => + this._backingMaterialsManager.decryptMaterials(request), + copy: (material) => cloneResponse(material), + put: (material) => + this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge), + }) } } -// One request to the backing materials manager, and the callers waiting on it. -interface Batch { - startedAt: number +// A request for a data key, and the callers waiting on it. +// Each waiting caller's info is its plaintextLength. +interface DataKeyRequest + extends SharedRequest, number> { // Messages and bytes the data key will encrypt, // counting the caller that made the request and every caller waiting on it. // canJoin checks these against the limits before the data key exists. messages: number bytes: number - requester: CachingMaterialsManager - members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] -} - -// What a waiting caller gets back: -// - the material -// - the request's error -// - `unshareable`, when the data key can't be used for other messages -type Outcome = { material: M } | { error: unknown } | { unshareable: true } - -function findBatch( - cache: CryptographicMaterialsCache, - cacheKey: string, - predicate: (batch: Batch) => boolean -): Batch | undefined { - const batches = batchesByCache.get(cache)?.get(cacheKey) - return batches?.find(predicate) -} - -function isRecent(batch: Batch) { - return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval + requester: CachingMaterialsManager } /* The request may come from another caching materials manager that shares this cache. @@ -357,79 +311,22 @@ function isRecent(batch: Batch) { */ function canJoin( cmm: CachingMaterialsManager, - batch: Batch, + request: DataKeyRequest, plaintextLength: number ) { - if (!isRecent(batch)) return false + if (!isRecent(request)) return false const next = { response: undefined as any, now: Date.now(), - messagesEncrypted: batch.messages + 1, - bytesEncrypted: batch.bytes + plaintextLength, + messagesEncrypted: request.messages + 1, + bytesEncrypted: request.bytes + plaintextLength, } return ( !cmm._cacheEntryHasExceededLimits(next) && - !batch.requester._cacheEntryHasExceededLimits(next) + !request.requester._cacheEntryHasExceededLimits(next) ) } -/* Call this right after findBatch finds nothing, with no await in between. - * Otherwise callers arriving at the same time each start their own request. - */ -function startBatch( - cache: CryptographicMaterialsCache, - cacheKey: string, - requester: CachingMaterialsManager, - plaintextLength: number -): Batch { - const byKey = batchesByCache.get(cache) || new Map[]>() - batchesByCache.set(cache, byKey) - // A request that hangs never removes itself. - // This drops it when the next request for this key starts. - const batches = (byKey.get(cacheKey) || []).filter(isRecent) - byKey.set(cacheKey, batches) - const batch: Batch = { - startedAt: Date.now(), - messages: 1, - bytes: plaintextLength, - requester, - members: [], - } - batches.push(batch) - return batch -} - -function closeBatch( - cache: CryptographicMaterialsCache, - cacheKey: string, - batch: Batch -) { - const byKey = batchesByCache.get(cache) - const batches = byKey?.get(cacheKey) - if (!byKey || !batches) return - const index = batches.indexOf(batch) - if (index !== -1) batches.splice(index, 1) - if (!batches.length) byKey.delete(cacheKey) -} - -/* Counts this caller's message against the request now, before the data key exists, - * so canJoin sees it when the next caller arrives. - */ -async function joinBatch( - batch: Batch, - plaintextLength: number -): Promise> { - batch.messages += 1 - batch.bytes += plaintextLength - return new Promise>((settle) => - batch.members.push({ plaintextLength, settle }) - ) -} - -function settleMembers(batch: Batch, outcome: Outcome) { - for (const member of batch.members) member.settle(outcome) -} - export function cacheEntryHasExceededLimits< S extends SupportedAlgorithmSuites >(): CacheEntryHasExceededLimits { diff --git a/modules/cache-material/src/index.ts b/modules/cache-material/src/index.ts index 8125a3e9e..0a9599c7c 100644 --- a/modules/cache-material/src/index.ts +++ b/modules/cache-material/src/index.ts @@ -5,3 +5,4 @@ export * from './cryptographic_materials_cache' export * from './caching_cryptographic_materials_decorators' export * from './build_cryptographic_materials_cache_key_helpers' export * from './get_local_cryptographic_materials_cache' +export * from './shared_requests' diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 553e93576..39314f624 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -3,19 +3,162 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' -/* Concurrent cache misses for the same entry share one request to the backing materials manager. +/* Concurrent cache misses for the same entry share one request to the backing source. * If that request fails, its waiting callers share one retry, - * so a failing backing materials manager gets one more call, not one per waiting caller. + * so a failing backing source gets one more call, not one per waiting caller. + * The caching materials manager and the hierarchical keyring both use this. */ export const SHARED_REQUESTS = { // A request older than this may be stuck, so new callers don't join it. - graceInterval: 1000, // milliseconds + inFlightTTL: 1000, // milliseconds } -// Requests in progress, by cache and then by cache key. -// Keyed by cache, so caching materials managers that share a cache also share requests. -export const batchesByCache = new WeakMap< +// One request to the backing source, and the callers waiting on it. +// `Info` is whatever the request's owner needs to know about each waiting caller. +export interface SharedRequest { + startedAt: number + members: { info: Info; settle: (outcome: SharedOutcome) => void }[] +} + +// What a waiting caller gets back: +// - the material +// - the request's error +// - `unshareable`, when the material can't be used for other callers +export type SharedOutcome = + | { material: M } + | { error: unknown } + | { unshareable: true } + +/* Requests in progress, by cache and then by cache key. + * Keyed by cache, so materials managers and keyrings that share a cache also share requests. + * Each key holds a list, so a stuck request that finishes late removes only itself, + * not a newer request for the same key. + */ +const inFlight = new WeakMap< CryptographicMaterialsCache, - Map + Map[]> >() + +export function inFlightRequestsFor(cache: CryptographicMaterialsCache) { + return inFlight.get(cache) +} + +export function isRecent(request: SharedRequest) { + return Date.now() < request.startedAt + SHARED_REQUESTS.inFlightTTL +} + +export function findRequest>( + cache: CryptographicMaterialsCache, + key: string, + canJoin: (request: R) => boolean = isRecent +): R | undefined { + const requests = inFlight.get(cache)?.get(key) as R[] | undefined + return requests?.find(canJoin) +} + +/* Call this right after findRequest finds nothing, with no await in between. + * Otherwise callers arriving at the same time each start their own request. + */ +export function startRequest>( + cache: CryptographicMaterialsCache, + key: string, + request: R +): R { + const byKey = + inFlight.get(cache) || new Map[]>() + inFlight.set(cache, byKey) + // A request that hangs never removes itself. + // This drops it when the next request for this key starts. + const requests = (byKey.get(key) || []).filter(isRecent) + requests.push(request) + byKey.set(key, requests) + return request +} + +export function closeRequest( + cache: CryptographicMaterialsCache, + key: string, + request: SharedRequest +) { + const byKey = inFlight.get(cache) + const requests = byKey?.get(key) + if (!byKey || !requests) return + const index = requests.indexOf(request) + if (index !== -1) requests.splice(index, 1) + if (!requests.length) byKey.delete(key) +} + +export async function waitOn( + request: SharedRequest, + info: Info +): Promise> { + return new Promise>((settle) => + request.members.push({ info, settle }) + ) +} + +export function settleMembers( + request: SharedRequest, + outcome: SharedOutcome +) { + for (const member of request.members) member.settle(outcome) +} + +export interface ShareRequestInput { + // This caller's copy of the cached material, or undefined on a miss. + lookUp: () => M | undefined + fetch: () => Promise + // Each caller needs its own copy, because the cache zeroes its copy on eviction. + copy: (material: M) => M + put: (material: M) => void +} + +/* Return the cached material, or wait on another caller's request for the same key, + * or make the request and share it. + * If the request this caller waited on fails, it tries once more, and a second failure throws. + * `retry` is false on that second try. + */ +export async function shareRequest( + cache: CryptographicMaterialsCache, + key: string, + input: ShareRequestInput, + retry = true +): Promise { + const cached = input.lookUp() + if (cached) return cached + + const current = findRequest>(cache, key) + if (current) { + const outcome = await waitOn(current, undefined) + if ('material' in outcome) return outcome.material + if ('error' in outcome && !retry) throw outcome.error + // The caller that made the failed request already removed it, + // so the first waiting caller to get here starts a new request and the others wait on that one. + return shareRequest(cache, key, input, false) + } + + const mine = startRequest>(cache, key, { + startedAt: Date.now(), + members: [], + }) + let release: SharedOutcome = { unshareable: true } + try { + const material = await input.fetch() + // Copy before put: once cached, an eviction can zero the material. + const own = input.copy(material) + for (const member of mine.members) { + member.settle({ material: input.copy(material) }) + } + input.put(material) + return own + } catch (error) { + release = { error } + throw error + } finally { + // If the request failed, tell the callers still waiting so they retry once. + // After a success they already have the material, so this does nothing. + closeRequest(cache, key, mine) + settleMembers(mine, release) + } +} diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index fbfdcb6b8..b52e8d32d 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -10,7 +10,7 @@ import { getEncryptionMaterials, decryptMaterials, } from '../src/caching_cryptographic_materials_decorators' -import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' +import { SHARED_REQUESTS, inFlightRequestsFor } from '../src/shared_requests' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' import { createHash, randomBytes } from 'crypto' @@ -49,8 +49,8 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( .digest() ) -// A short grace interval, so the hang tests run in milliseconds. -const FAST = { graceInterval: 20 } +// A short inFlightTTL, so the hang tests run in milliseconds. +const FAST = { inFlightTTL: 20 } const DEFAULT = { ...SHARED_REQUESTS } // A backing materials manager that takes 5 ms per request and returns a new data key each time. @@ -292,12 +292,12 @@ describe('caching materials manager: concurrent cache misses', () => { expect(backing.calls.decrypt).to.equal(2) }) - it('starts another request for new callers once one hangs past graceInterval', async () => { + it('starts another request for new callers once one hangs past inFlightTTL', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) const cmm = cachingCMM(backing) startEncrypts(cmm, times(1)) - await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) + await new Promise((resolve) => setTimeout(resolve, FAST.inFlightTTL + 5)) const materials = await encryptConcurrently(cmm, times(5)) expect(materials).to.have.lengthOf(5) @@ -351,7 +351,7 @@ describe('caching materials manager: concurrent cache misses', () => { ) ) - expect(batchesByCache.get(cache)?.size).to.equal(0) + expect(inFlightRequestsFor(cache)?.size).to.equal(0) }) it('forgets a request that never settles once a newer one starts', async () => { @@ -360,10 +360,10 @@ describe('caching materials manager: concurrent cache misses', () => { const cmm = cachingCMM(backing, { cache }) startEncrypts(cmm, times(1)) - await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) + await new Promise((resolve) => setTimeout(resolve, FAST.inFlightTTL + 5)) await encryptConcurrently(cmm, times(3)) - expect(batchesByCache.get(cache)?.size).to.equal(0) + expect(inFlightRequestsFor(cache)?.size).to.equal(0) }) describe('requests run in parallel when the response cannot serve another caller', () => { diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index def5f9086..b46ab6869 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -19,7 +19,10 @@ import { createHash, randomBytes, } from 'crypto' -import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' +import { + CryptographicMaterialsCache, + shareRequest, +} from '@aws-crypto/cache-material' import { kdfCounterMode } from '@aws-crypto/kdf-ctr-mode-node' import { CACHE_ENTRY_ID_DIGEST_ALGORITHM, @@ -34,10 +37,6 @@ import { } from './constants' import { BranchKeyIdSupplier } from '@aws-crypto/kms-keyring' import { serializeFactory, uuidv4Factory } from '@aws-crypto/serialize' -import { - deepCopyBranchKeyMaterial, - sharedFetch, -} from './shared_branch_key_fetches' export const stringToUtf8Bytes = (input: string): Buffer => Buffer.from(input, 'utf-8') @@ -198,7 +197,7 @@ export async function getBranchKeyMaterials( ): Promise { const { keyStore, cacheLimitTtl } = hKeyring - // The cached entry, or undefined if it's missing or past this keyring's TTL. + // A copy of the cached entry, or undefined if it's missing or past this keyring's TTL. const lookUp = () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) @@ -212,7 +211,12 @@ export async function getBranchKeyMaterials( //# If this is NOT true, then we MUST treat the cache entry as expired. return undefined } - return cacheEntry + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. + + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + return deepCopyBranchKeyMaterial(cacheEntry.response) } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt @@ -254,16 +258,28 @@ export async function getBranchKeyMaterials( const put = (material: NodeBranchKeyMaterial) => cmc.putBranchKeyMaterial(cacheEntryId, material, cacheLimitTtl) - const cacheEntry = lookUp() - if (cacheEntry) { - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. + // Concurrent misses for this entry share one keystore call. + return shareRequest(cmc, cacheEntryId, { + lookUp, + fetch, + copy: deepCopyBranchKeyMaterial, + put, + }) +} - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. - return deepCopyBranchKeyMaterial(cacheEntry.response) - } - return sharedFetch(cmc, cacheEntryId, fetch, put) +/* The cache zeroes a material's buffer when it evicts it. + * Callers derive keys from the branch key after getBranchKeyMaterials returns, + * so they need their own buffer, or an eviction can zero the key mid-use (#1691). + */ +export function deepCopyBranchKeyMaterial( + material: NodeBranchKeyMaterial +): NodeBranchKeyMaterial { + return new NodeBranchKeyMaterial( + Buffer.from(material.branchKey()), + material.branchKeyIdentifier, + material.branchKeyVersion.toString('utf-8'), + { ...material.encryptionContext } + ) } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts deleted file mode 100644 index c01e53c20..000000000 --- a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - NodeAlgorithmSuite, - NodeBranchKeyMaterial, -} from '@aws-crypto/material-management' -import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' - -/* Concurrent cache misses for the same branch key share one keystore fetch. - * If that fetch fails, its waiting callers share one retry, - * so a failing keystore gets one more call, not one per waiting caller. - */ - -export const SHARED_FETCHES = { - // A fetch older than this may be stuck, so new callers don't join it. - graceInterval: 1000, // milliseconds -} - -interface SharedFetch { - startedAt: number - members: ((outcome: SharedOutcome) => void)[] -} -type SharedOutcome = { material: NodeBranchKeyMaterial } | { error: unknown } - -/* Fetches in progress, by cache and then by cache entry id. - * Keyed by cache, so keyrings that share a cache also share fetches. - * Each entry id holds a list, so a stuck fetch that finishes late removes only itself, - * not a newer fetch for the same entry. - */ -const sharedFetches = new WeakMap< - CryptographicMaterialsCache, - Map ->() - -export function sharedFetchesFor( - cmc: CryptographicMaterialsCache -) { - return sharedFetches.get(cmc) -} - -function isRecent(fetch: SharedFetch) { - return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval -} - -/* Fetch the branch key materials, or wait on another caller's fetch of the same entry. - * Returns a copy with its own buffer (see deepCopyBranchKeyMaterial). - * If the fetch this caller waited on fails, it tries once more, and a second failure throws. - * `retry` is false on that second try. - */ -export async function sharedFetch( - cmc: CryptographicMaterialsCache, - cacheEntryId: string, - fetch: () => Promise, - put: (material: NodeBranchKeyMaterial) => void, - retry = true -): Promise { - const byId = sharedFetches.get(cmc) || new Map() - sharedFetches.set(cmc, byId) - // A fetch that hangs never removes itself. - // This drops it when the next fetch for this entry starts. - const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) - const current = fetches[fetches.length - 1] - - if (current) { - const outcome = await new Promise((resolve) => - current.members.push(resolve) - ) - if ('material' in outcome) return outcome.material - if (!retry) throw outcome.error - // The caller that made the failed fetch already removed it, - // so the first waiting caller to get here starts a new fetch and the others wait on that one. - return sharedFetch(cmc, cacheEntryId, fetch, put, false) - } - - const mine: SharedFetch = { startedAt: Date.now(), members: [] } - fetches.push(mine) - byId.set(cacheEntryId, fetches) - const close = () => { - const list = byId.get(cacheEntryId) - if (!list) return - const index = list.indexOf(mine) - if (index !== -1) list.splice(index, 1) - if (!list.length) byId.delete(cacheEntryId) - } - - let failure: SharedOutcome = { error: undefined } - try { - const material = await fetch() - // Copy before put: once cached, an eviction can zero the material's buffer. - for (const settle of mine.members) { - settle({ material: deepCopyBranchKeyMaterial(material) }) - } - put(material) - return deepCopyBranchKeyMaterial(material) - } catch (error) { - failure = { error } - throw error - } finally { - // If the fetch failed, tell the callers still waiting so they retry once. - // After a success they already have the material, so this does nothing. - close() - for (const settle of mine.members) settle(failure) - } -} - -/* The cache zeroes a material's buffer when it evicts it. - * Callers derive keys from the branch key after getBranchKeyMaterials returns, - * so they need their own buffer, or an eviction can zero the key mid-use (#1691). - */ -export function deepCopyBranchKeyMaterial( - material: NodeBranchKeyMaterial -): NodeBranchKeyMaterial { - return new NodeBranchKeyMaterial( - Buffer.from(material.branchKey()), - material.branchKeyIdentifier, - material.branchKeyVersion.toString('utf-8'), - { ...material.encryptionContext } - ) -} diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index 53242191c..f8ecb0cba 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -31,10 +31,10 @@ import { import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' import { - SHARED_FETCHES, - sharedFetchesFor, -} from '../src/shared_branch_key_fetches' -import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' + SHARED_REQUESTS, + getLocalCryptographicMaterialsCache, + inFlightRequestsFor, +} from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -155,9 +155,9 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// A short grace interval, so the hang tests run in milliseconds. -const FAST = { graceInterval: 40 } -const DEFAULT = { ...SHARED_FETCHES } +// A short inFlightTTL, so the hang tests run in milliseconds. +const FAST = { inFlightTTL: 40 } +const DEFAULT = { ...SHARED_REQUESTS } const wait = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -233,22 +233,22 @@ async function encryptSettled(hkr: KmsHierarchicalKeyRingNode, count: number) { } describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { - beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) - afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) + beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) + afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can take longer than a short grace interval, + // On a slow runner, starting 3000 callers can take longer than a short inFlightTTL, // which starts a second fetch. - Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) + Object.assign(SHARED_REQUESTS, { inFlightTTL: 60 * 1000 }) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can take longer than a short grace interval, + // On a slow runner, starting 3000 callers can take longer than a short inFlightTTL, // which starts a second fetch. - Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) + Object.assign(SHARED_REQUESTS, { inFlightTTL: 60 * 1000 }) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) @@ -317,15 +317,15 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { ) for (const result of results) expect(result.status).to.equal('rejected') - expect(sharedFetchesFor(cache)?.size).to.equal(0) + expect(inFlightRequestsFor(cache)?.size).to.equal(0) }) - it('starts another fetch for new callers once one hangs past graceInterval', async () => { + it('starts another fetch for new callers once one hangs past inFlightTTL', async () => { const keyStore = slowKeyStore(['hang', 'ok']) const hkr = keyringFor(keyStore) startEncrypts(hkr, 1) - await wait(FAST.graceInterval + 5) + await wait(FAST.inFlightTTL + 5) await encryptConcurrently(hkr, 5) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) @@ -353,7 +353,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { ) const stale = get() - await wait(FAST.graceInterval + 5) + await wait(FAST.inFlightTTL + 5) const newer = [get(), get(), get()] await wait(1) expect(deferreds).to.have.lengthOf(2) @@ -378,7 +378,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { const cache = getLocalCryptographicMaterialsCache(100) await encryptConcurrently(keyringFor(slowKeyStore(), { cache }), 5) await encryptSettled(keyringFor(slowKeyStore(['fail']), { cache }), 5) - expect(sharedFetchesFor(cache)?.size).to.equal(0) + expect(inFlightRequestsFor(cache)?.size).to.equal(0) }) }) From b8547a61acf88c9762c0ac4eed59e7977cd13aa1 Mon Sep 17 00:00:00 2001 From: Lucas McDonald Date: Tue, 13 Oct 2026 09:08:43 -0700 Subject: [PATCH 38/38] Revert "refactor: share one in-flight request helper" This reverts commit 61f44421984e23c7e798661413d0df1d95ac8b2c. --- ...hing_cryptographic_materials_decorators.ts | 211 +++++++++++++----- modules/cache-material/src/index.ts | 1 - modules/cache-material/src/shared_requests.ts | 157 +------------ ...c_materials_decorators.concurrency.test.ts | 16 +- .../src/kms_hkeyring_node_helpers.ts | 48 ++-- .../src/shared_branch_key_fetches.ts | 120 ++++++++++ .../kms_hkeyring_node.concurrency.test.ts | 36 +-- 7 files changed, 326 insertions(+), 263 deletions(-) create mode 100644 modules/kms-keyring-node/src/shared_branch_key_fetches.ts diff --git a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts index 3db924607..7e9e531f7 100644 --- a/modules/cache-material/src/caching_cryptographic_materials_decorators.ts +++ b/modules/cache-material/src/caching_cryptographic_materials_decorators.ts @@ -21,17 +21,7 @@ import { Entry, } from './cryptographic_materials_cache' import { CryptographicMaterialsCacheKeyHelpersInterface } from './build_cryptographic_materials_cache_key_helpers' -import { - SharedRequest, - SharedOutcome, - closeRequest, - findRequest, - isRecent, - settleMembers, - shareRequest, - startRequest, - waitOn, -} from './shared_requests' +import { SHARED_REQUESTS, batchesByCache } from './shared_requests' export function decorateProperties( obj: CachingMaterialsManager, @@ -135,17 +125,13 @@ export function getEncryptionMaterials({ // Another caller may already be requesting a data key for this cache entry. // Wait for that one if it can also encrypt this message without going over the limits. - const joinable = findRequest>( + const joinable = findBatch>( this._cache, cacheKey, - (r) => canJoin(this, r, plaintextLength) + (b) => canJoin(this, b, plaintextLength) ) if (joinable) { - // Count this caller's message against the request now, before the data key exists, - // so canJoin sees it when the next caller arrives. - joinable.messages += 1 - joinable.bytes += plaintextLength - const outcome = await waitOn(joinable, plaintextLength) + const outcome = await joinBatch(joinable, plaintextLength) if ('material' in outcome) return outcome.material if ('error' in outcome) { if (!retry) throw outcome.error @@ -163,14 +149,13 @@ export function getEncryptionMaterials({ ) } - const batch = startRequest>(this._cache, cacheKey, { - startedAt: Date.now(), - messages: 1, - bytes: plaintextLength, - requester: this, - members: [], - }) - let release: SharedOutcome> = { unshareable: true } + const batch = startBatch>( + this._cache, + cacheKey, + this, + plaintextLength + ) + let release: Outcome> = { unshareable: true } try { const material = await fetch() @@ -198,7 +183,7 @@ export function getEncryptionMaterials({ // getEncryptionMaterial also adds to the entry's message and byte counts. // Call it once per waiting caller so the cached entry counts their messages too. for (const member of batch.members) { - this._cache.getEncryptionMaterial(cacheKey, member.info) + this._cache.getEncryptionMaterial(cacheKey, member.plaintextLength) } for (const member of batch.members) { member.settle({ material: cloneResponse(material) }) @@ -212,7 +197,7 @@ export function getEncryptionMaterials({ // - on an error, they retry once // - if they can't use this data key, they each make their own request // After a success they already have the data key, so this does nothing. - closeRequest(this._cache, cacheKey, batch) + closeBatch(this._cache, cacheKey, batch) settleMembers(batch, release) } } @@ -274,36 +259,97 @@ export function decryptMaterials({ this._partition, request ) - return shareRequest(this._cache, cacheKey, { - lookUp: () => { - /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ - const entry = this._cache.getDecryptionMaterial(cacheKey) - if (entry && !this._cacheEntryHasExceededLimits(entry)) { - return cloneResponse(entry.response) - } else { - this._cache.del(cacheKey) - return undefined + const fetchAndCache = async () => { + const material = await this._backingMaterialsManager.decryptMaterials( + request + ) + this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge) + return material + } + + // If the request this caller waited on fails, it tries once more, and a second failure throws. + // `retry` is false on that second try. + const getShared = async ( + retry: boolean + ): Promise> => { + /* Check for early return (Postcondition): If I have a valid DecryptionMaterial, return it. */ + const entry = this._cache.getDecryptionMaterial(cacheKey) + if (entry && !this._cacheEntryHasExceededLimits(entry)) { + return cloneResponse(entry.response) + } else { + this._cache.del(cacheKey) + } + + const joinable = findBatch>( + this._cache, + cacheKey, + isRecent + ) + if (joinable) { + const outcome = await joinBatch(joinable, 0) + if ('material' in outcome) return outcome.material + if ('error' in outcome && !retry) throw outcome.error + // The caller that made the failed request already removed it, + // so the first waiting caller to get here starts a new request and the others wait on that one. + return getShared(false) + } + + const batch = startBatch>( + this._cache, + cacheKey, + this, + 0 + ) + let release: Outcome> = { unshareable: true } + try { + const material = await fetchAndCache() + for (const member of batch.members) { + member.settle({ material: cloneResponse(material) }) } - }, - fetch: async () => - this._backingMaterialsManager.decryptMaterials(request), - copy: (material) => cloneResponse(material), - put: (material) => - this._cache.putDecryptionMaterial(cacheKey, material, this._maxAge), - }) + return cloneResponse(material) + } catch (error) { + release = { error } + throw error + } finally { + // If the request failed, tell the callers still waiting so they retry once. + // After a success they already have the material, so this does nothing. + closeBatch(this._cache, cacheKey, batch) + settleMembers(batch, release) + } + } + return getShared(true) } } -// A request for a data key, and the callers waiting on it. -// Each waiting caller's info is its plaintextLength. -interface DataKeyRequest - extends SharedRequest, number> { +// One request to the backing materials manager, and the callers waiting on it. +interface Batch { + startedAt: number // Messages and bytes the data key will encrypt, // counting the caller that made the request and every caller waiting on it. // canJoin checks these against the limits before the data key exists. messages: number bytes: number - requester: CachingMaterialsManager + requester: CachingMaterialsManager + members: { plaintextLength: number; settle: (outcome: Outcome) => void }[] +} + +// What a waiting caller gets back: +// - the material +// - the request's error +// - `unshareable`, when the data key can't be used for other messages +type Outcome = { material: M } | { error: unknown } | { unshareable: true } + +function findBatch( + cache: CryptographicMaterialsCache, + cacheKey: string, + predicate: (batch: Batch) => boolean +): Batch | undefined { + const batches = batchesByCache.get(cache)?.get(cacheKey) + return batches?.find(predicate) +} + +function isRecent(batch: Batch) { + return Date.now() < batch.startedAt + SHARED_REQUESTS.graceInterval } /* The request may come from another caching materials manager that shares this cache. @@ -311,22 +357,79 @@ interface DataKeyRequest */ function canJoin( cmm: CachingMaterialsManager, - request: DataKeyRequest, + batch: Batch, plaintextLength: number ) { - if (!isRecent(request)) return false + if (!isRecent(batch)) return false const next = { response: undefined as any, now: Date.now(), - messagesEncrypted: request.messages + 1, - bytesEncrypted: request.bytes + plaintextLength, + messagesEncrypted: batch.messages + 1, + bytesEncrypted: batch.bytes + plaintextLength, } return ( !cmm._cacheEntryHasExceededLimits(next) && - !request.requester._cacheEntryHasExceededLimits(next) + !batch.requester._cacheEntryHasExceededLimits(next) ) } +/* Call this right after findBatch finds nothing, with no await in between. + * Otherwise callers arriving at the same time each start their own request. + */ +function startBatch( + cache: CryptographicMaterialsCache, + cacheKey: string, + requester: CachingMaterialsManager, + plaintextLength: number +): Batch { + const byKey = batchesByCache.get(cache) || new Map[]>() + batchesByCache.set(cache, byKey) + // A request that hangs never removes itself. + // This drops it when the next request for this key starts. + const batches = (byKey.get(cacheKey) || []).filter(isRecent) + byKey.set(cacheKey, batches) + const batch: Batch = { + startedAt: Date.now(), + messages: 1, + bytes: plaintextLength, + requester, + members: [], + } + batches.push(batch) + return batch +} + +function closeBatch( + cache: CryptographicMaterialsCache, + cacheKey: string, + batch: Batch +) { + const byKey = batchesByCache.get(cache) + const batches = byKey?.get(cacheKey) + if (!byKey || !batches) return + const index = batches.indexOf(batch) + if (index !== -1) batches.splice(index, 1) + if (!batches.length) byKey.delete(cacheKey) +} + +/* Counts this caller's message against the request now, before the data key exists, + * so canJoin sees it when the next caller arrives. + */ +async function joinBatch( + batch: Batch, + plaintextLength: number +): Promise> { + batch.messages += 1 + batch.bytes += plaintextLength + return new Promise>((settle) => + batch.members.push({ plaintextLength, settle }) + ) +} + +function settleMembers(batch: Batch, outcome: Outcome) { + for (const member of batch.members) member.settle(outcome) +} + export function cacheEntryHasExceededLimits< S extends SupportedAlgorithmSuites >(): CacheEntryHasExceededLimits { diff --git a/modules/cache-material/src/index.ts b/modules/cache-material/src/index.ts index 0a9599c7c..8125a3e9e 100644 --- a/modules/cache-material/src/index.ts +++ b/modules/cache-material/src/index.ts @@ -5,4 +5,3 @@ export * from './cryptographic_materials_cache' export * from './caching_cryptographic_materials_decorators' export * from './build_cryptographic_materials_cache_key_helpers' export * from './get_local_cryptographic_materials_cache' -export * from './shared_requests' diff --git a/modules/cache-material/src/shared_requests.ts b/modules/cache-material/src/shared_requests.ts index 39314f624..553e93576 100644 --- a/modules/cache-material/src/shared_requests.ts +++ b/modules/cache-material/src/shared_requests.ts @@ -3,162 +3,19 @@ import { CryptographicMaterialsCache } from './cryptographic_materials_cache' -/* Concurrent cache misses for the same entry share one request to the backing source. +/* Concurrent cache misses for the same entry share one request to the backing materials manager. * If that request fails, its waiting callers share one retry, - * so a failing backing source gets one more call, not one per waiting caller. - * The caching materials manager and the hierarchical keyring both use this. + * so a failing backing materials manager gets one more call, not one per waiting caller. */ export const SHARED_REQUESTS = { // A request older than this may be stuck, so new callers don't join it. - inFlightTTL: 1000, // milliseconds + graceInterval: 1000, // milliseconds } -// One request to the backing source, and the callers waiting on it. -// `Info` is whatever the request's owner needs to know about each waiting caller. -export interface SharedRequest { - startedAt: number - members: { info: Info; settle: (outcome: SharedOutcome) => void }[] -} - -// What a waiting caller gets back: -// - the material -// - the request's error -// - `unshareable`, when the material can't be used for other callers -export type SharedOutcome = - | { material: M } - | { error: unknown } - | { unshareable: true } - -/* Requests in progress, by cache and then by cache key. - * Keyed by cache, so materials managers and keyrings that share a cache also share requests. - * Each key holds a list, so a stuck request that finishes late removes only itself, - * not a newer request for the same key. - */ -const inFlight = new WeakMap< +// Requests in progress, by cache and then by cache key. +// Keyed by cache, so caching materials managers that share a cache also share requests. +export const batchesByCache = new WeakMap< CryptographicMaterialsCache, - Map[]> + Map >() - -export function inFlightRequestsFor(cache: CryptographicMaterialsCache) { - return inFlight.get(cache) -} - -export function isRecent(request: SharedRequest) { - return Date.now() < request.startedAt + SHARED_REQUESTS.inFlightTTL -} - -export function findRequest>( - cache: CryptographicMaterialsCache, - key: string, - canJoin: (request: R) => boolean = isRecent -): R | undefined { - const requests = inFlight.get(cache)?.get(key) as R[] | undefined - return requests?.find(canJoin) -} - -/* Call this right after findRequest finds nothing, with no await in between. - * Otherwise callers arriving at the same time each start their own request. - */ -export function startRequest>( - cache: CryptographicMaterialsCache, - key: string, - request: R -): R { - const byKey = - inFlight.get(cache) || new Map[]>() - inFlight.set(cache, byKey) - // A request that hangs never removes itself. - // This drops it when the next request for this key starts. - const requests = (byKey.get(key) || []).filter(isRecent) - requests.push(request) - byKey.set(key, requests) - return request -} - -export function closeRequest( - cache: CryptographicMaterialsCache, - key: string, - request: SharedRequest -) { - const byKey = inFlight.get(cache) - const requests = byKey?.get(key) - if (!byKey || !requests) return - const index = requests.indexOf(request) - if (index !== -1) requests.splice(index, 1) - if (!requests.length) byKey.delete(key) -} - -export async function waitOn( - request: SharedRequest, - info: Info -): Promise> { - return new Promise>((settle) => - request.members.push({ info, settle }) - ) -} - -export function settleMembers( - request: SharedRequest, - outcome: SharedOutcome -) { - for (const member of request.members) member.settle(outcome) -} - -export interface ShareRequestInput { - // This caller's copy of the cached material, or undefined on a miss. - lookUp: () => M | undefined - fetch: () => Promise - // Each caller needs its own copy, because the cache zeroes its copy on eviction. - copy: (material: M) => M - put: (material: M) => void -} - -/* Return the cached material, or wait on another caller's request for the same key, - * or make the request and share it. - * If the request this caller waited on fails, it tries once more, and a second failure throws. - * `retry` is false on that second try. - */ -export async function shareRequest( - cache: CryptographicMaterialsCache, - key: string, - input: ShareRequestInput, - retry = true -): Promise { - const cached = input.lookUp() - if (cached) return cached - - const current = findRequest>(cache, key) - if (current) { - const outcome = await waitOn(current, undefined) - if ('material' in outcome) return outcome.material - if ('error' in outcome && !retry) throw outcome.error - // The caller that made the failed request already removed it, - // so the first waiting caller to get here starts a new request and the others wait on that one. - return shareRequest(cache, key, input, false) - } - - const mine = startRequest>(cache, key, { - startedAt: Date.now(), - members: [], - }) - let release: SharedOutcome = { unshareable: true } - try { - const material = await input.fetch() - // Copy before put: once cached, an eviction can zero the material. - const own = input.copy(material) - for (const member of mine.members) { - member.settle({ material: input.copy(material) }) - } - input.put(material) - return own - } catch (error) { - release = { error } - throw error - } finally { - // If the request failed, tell the callers still waiting so they retry once. - // After a success they already have the material, so this does nothing. - closeRequest(cache, key, mine) - settleMembers(mine, release) - } -} diff --git a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts index b52e8d32d..fbfdcb6b8 100644 --- a/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts +++ b/modules/cache-material/test/caching_cryptographic_materials_decorators.concurrency.test.ts @@ -10,7 +10,7 @@ import { getEncryptionMaterials, decryptMaterials, } from '../src/caching_cryptographic_materials_decorators' -import { SHARED_REQUESTS, inFlightRequestsFor } from '../src/shared_requests' +import { SHARED_REQUESTS, batchesByCache } from '../src/shared_requests' import { getLocalCryptographicMaterialsCache } from '../src/get_local_cryptographic_materials_cache' import { buildCryptographicMaterialsCacheKeyHelpers } from '../src/build_cryptographic_materials_cache_key_helpers' import { createHash, randomBytes } from 'crypto' @@ -49,8 +49,8 @@ const cacheKeyHelpers = buildCryptographicMaterialsCacheKeyHelpers( .digest() ) -// A short inFlightTTL, so the hang tests run in milliseconds. -const FAST = { inFlightTTL: 20 } +// A short grace interval, so the hang tests run in milliseconds. +const FAST = { graceInterval: 20 } const DEFAULT = { ...SHARED_REQUESTS } // A backing materials manager that takes 5 ms per request and returns a new data key each time. @@ -292,12 +292,12 @@ describe('caching materials manager: concurrent cache misses', () => { expect(backing.calls.decrypt).to.equal(2) }) - it('starts another request for new callers once one hangs past inFlightTTL', async () => { + it('starts another request for new callers once one hangs past graceInterval', async () => { const backing = slowBackingMaterialsManager({ outcomes: ['hang', 'ok'] }) const cmm = cachingCMM(backing) startEncrypts(cmm, times(1)) - await new Promise((resolve) => setTimeout(resolve, FAST.inFlightTTL + 5)) + await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) const materials = await encryptConcurrently(cmm, times(5)) expect(materials).to.have.lengthOf(5) @@ -351,7 +351,7 @@ describe('caching materials manager: concurrent cache misses', () => { ) ) - expect(inFlightRequestsFor(cache)?.size).to.equal(0) + expect(batchesByCache.get(cache)?.size).to.equal(0) }) it('forgets a request that never settles once a newer one starts', async () => { @@ -360,10 +360,10 @@ describe('caching materials manager: concurrent cache misses', () => { const cmm = cachingCMM(backing, { cache }) startEncrypts(cmm, times(1)) - await new Promise((resolve) => setTimeout(resolve, FAST.inFlightTTL + 5)) + await new Promise((resolve) => setTimeout(resolve, FAST.graceInterval + 5)) await encryptConcurrently(cmm, times(3)) - expect(inFlightRequestsFor(cache)?.size).to.equal(0) + expect(batchesByCache.get(cache)?.size).to.equal(0) }) describe('requests run in parallel when the response cannot serve another caller', () => { diff --git a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts index b46ab6869..def5f9086 100644 --- a/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts +++ b/modules/kms-keyring-node/src/kms_hkeyring_node_helpers.ts @@ -19,10 +19,7 @@ import { createHash, randomBytes, } from 'crypto' -import { - CryptographicMaterialsCache, - shareRequest, -} from '@aws-crypto/cache-material' +import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' import { kdfCounterMode } from '@aws-crypto/kdf-ctr-mode-node' import { CACHE_ENTRY_ID_DIGEST_ALGORITHM, @@ -37,6 +34,10 @@ import { } from './constants' import { BranchKeyIdSupplier } from '@aws-crypto/kms-keyring' import { serializeFactory, uuidv4Factory } from '@aws-crypto/serialize' +import { + deepCopyBranchKeyMaterial, + sharedFetch, +} from './shared_branch_key_fetches' export const stringToUtf8Bytes = (input: string): Buffer => Buffer.from(input, 'utf-8') @@ -197,7 +198,7 @@ export async function getBranchKeyMaterials( ): Promise { const { keyStore, cacheLimitTtl } = hKeyring - // A copy of the cached entry, or undefined if it's missing or past this keyring's TTL. + // The cached entry, or undefined if it's missing or past this keyring's TTL. const lookUp = () => { //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt //# The hierarchical keyring MUST attempt to find [branch key materials](../structures.md#branch-key-materials) @@ -211,12 +212,7 @@ export async function getBranchKeyMaterials( //# If this is NOT true, then we MUST treat the cache entry as expired. return undefined } - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. - - //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt - //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. - return deepCopyBranchKeyMaterial(cacheEntry.response) + return cacheEntry } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt @@ -258,28 +254,16 @@ export async function getBranchKeyMaterials( const put = (material: NodeBranchKeyMaterial) => cmc.putBranchKeyMaterial(cacheEntryId, material, cacheLimitTtl) - // Concurrent misses for this entry share one keystore call. - return shareRequest(cmc, cacheEntryId, { - lookUp, - fetch, - copy: deepCopyBranchKeyMaterial, - put, - }) -} + const cacheEntry = lookUp() + if (cacheEntry) { + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#ondecrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key unwrapping. -/* The cache zeroes a material's buffer when it evicts it. - * Callers derive keys from the branch key after getBranchKeyMaterials returns, - * so they need their own buffer, or an eviction can zero the key mid-use (#1691). - */ -export function deepCopyBranchKeyMaterial( - material: NodeBranchKeyMaterial -): NodeBranchKeyMaterial { - return new NodeBranchKeyMaterial( - Buffer.from(material.branchKey()), - material.branchKeyIdentifier, - material.branchKeyVersion.toString('utf-8'), - { ...material.encryptionContext } - ) + //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt + //# If a cache entry is found and the entry's TTL has not expired, the hierarchical keyring MUST use those branch key materials for key wrapping. + return deepCopyBranchKeyMaterial(cacheEntry.response) + } + return sharedFetch(cmc, cacheEntryId, fetch, put) } //= aws-encryption-sdk-specification/framework/aws-kms/aws-kms-hierarchical-keyring.md#onencrypt diff --git a/modules/kms-keyring-node/src/shared_branch_key_fetches.ts b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts new file mode 100644 index 000000000..c01e53c20 --- /dev/null +++ b/modules/kms-keyring-node/src/shared_branch_key_fetches.ts @@ -0,0 +1,120 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + NodeAlgorithmSuite, + NodeBranchKeyMaterial, +} from '@aws-crypto/material-management' +import { CryptographicMaterialsCache } from '@aws-crypto/cache-material' + +/* Concurrent cache misses for the same branch key share one keystore fetch. + * If that fetch fails, its waiting callers share one retry, + * so a failing keystore gets one more call, not one per waiting caller. + */ + +export const SHARED_FETCHES = { + // A fetch older than this may be stuck, so new callers don't join it. + graceInterval: 1000, // milliseconds +} + +interface SharedFetch { + startedAt: number + members: ((outcome: SharedOutcome) => void)[] +} +type SharedOutcome = { material: NodeBranchKeyMaterial } | { error: unknown } + +/* Fetches in progress, by cache and then by cache entry id. + * Keyed by cache, so keyrings that share a cache also share fetches. + * Each entry id holds a list, so a stuck fetch that finishes late removes only itself, + * not a newer fetch for the same entry. + */ +const sharedFetches = new WeakMap< + CryptographicMaterialsCache, + Map +>() + +export function sharedFetchesFor( + cmc: CryptographicMaterialsCache +) { + return sharedFetches.get(cmc) +} + +function isRecent(fetch: SharedFetch) { + return Date.now() < fetch.startedAt + SHARED_FETCHES.graceInterval +} + +/* Fetch the branch key materials, or wait on another caller's fetch of the same entry. + * Returns a copy with its own buffer (see deepCopyBranchKeyMaterial). + * If the fetch this caller waited on fails, it tries once more, and a second failure throws. + * `retry` is false on that second try. + */ +export async function sharedFetch( + cmc: CryptographicMaterialsCache, + cacheEntryId: string, + fetch: () => Promise, + put: (material: NodeBranchKeyMaterial) => void, + retry = true +): Promise { + const byId = sharedFetches.get(cmc) || new Map() + sharedFetches.set(cmc, byId) + // A fetch that hangs never removes itself. + // This drops it when the next fetch for this entry starts. + const fetches = (byId.get(cacheEntryId) || []).filter(isRecent) + const current = fetches[fetches.length - 1] + + if (current) { + const outcome = await new Promise((resolve) => + current.members.push(resolve) + ) + if ('material' in outcome) return outcome.material + if (!retry) throw outcome.error + // The caller that made the failed fetch already removed it, + // so the first waiting caller to get here starts a new fetch and the others wait on that one. + return sharedFetch(cmc, cacheEntryId, fetch, put, false) + } + + const mine: SharedFetch = { startedAt: Date.now(), members: [] } + fetches.push(mine) + byId.set(cacheEntryId, fetches) + const close = () => { + const list = byId.get(cacheEntryId) + if (!list) return + const index = list.indexOf(mine) + if (index !== -1) list.splice(index, 1) + if (!list.length) byId.delete(cacheEntryId) + } + + let failure: SharedOutcome = { error: undefined } + try { + const material = await fetch() + // Copy before put: once cached, an eviction can zero the material's buffer. + for (const settle of mine.members) { + settle({ material: deepCopyBranchKeyMaterial(material) }) + } + put(material) + return deepCopyBranchKeyMaterial(material) + } catch (error) { + failure = { error } + throw error + } finally { + // If the fetch failed, tell the callers still waiting so they retry once. + // After a success they already have the material, so this does nothing. + close() + for (const settle of mine.members) settle(failure) + } +} + +/* The cache zeroes a material's buffer when it evicts it. + * Callers derive keys from the branch key after getBranchKeyMaterials returns, + * so they need their own buffer, or an eviction can zero the key mid-use (#1691). + */ +export function deepCopyBranchKeyMaterial( + material: NodeBranchKeyMaterial +): NodeBranchKeyMaterial { + return new NodeBranchKeyMaterial( + Buffer.from(material.branchKey()), + material.branchKeyIdentifier, + material.branchKeyVersion.toString('utf-8'), + { ...material.encryptionContext } + ) +} diff --git a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts index f8ecb0cba..53242191c 100644 --- a/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts +++ b/modules/kms-keyring-node/test/kms_hkeyring_node.concurrency.test.ts @@ -31,10 +31,10 @@ import { import { getBranchKeyMaterials } from '../src/kms_hkeyring_node_helpers' import { - SHARED_REQUESTS, - getLocalCryptographicMaterialsCache, - inFlightRequestsFor, -} from '@aws-crypto/cache-material' + SHARED_FETCHES, + sharedFetchesFor, +} from '../src/shared_branch_key_fetches' +import { getLocalCryptographicMaterialsCache } from '@aws-crypto/cache-material' import { NodeAlgorithmSuite } from '@aws-crypto/material-management' import { v4 } from 'uuid' @@ -155,9 +155,9 @@ describe('KmsHierarchicalKeyRingNode: concurrent cold-cache operations (#1691)', const CONCURRENT_OPERATIONS = 3000 -// A short inFlightTTL, so the hang tests run in milliseconds. -const FAST = { inFlightTTL: 40 } -const DEFAULT = { ...SHARED_REQUESTS } +// A short grace interval, so the hang tests run in milliseconds. +const FAST = { graceInterval: 40 } +const DEFAULT = { ...SHARED_FETCHES } const wait = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -233,22 +233,22 @@ async function encryptSettled(hkr: KmsHierarchicalKeyRingNode, count: number) { } describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { - beforeEach(() => Object.assign(SHARED_REQUESTS, FAST)) - afterEach(() => Object.assign(SHARED_REQUESTS, DEFAULT)) + beforeEach(() => Object.assign(SHARED_FETCHES, FAST)) + afterEach(() => Object.assign(SHARED_FETCHES, DEFAULT)) it(`${CONCURRENT_OPERATIONS} concurrent onEncrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can take longer than a short inFlightTTL, + // On a slow runner, starting 3000 callers can take longer than a short grace interval, // which starts a second fetch. - Object.assign(SHARED_REQUESTS, { inFlightTTL: 60 * 1000 }) + Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() await encryptConcurrently(keyringFor(keyStore), CONCURRENT_OPERATIONS) expect(keyStore.getActiveBranchKey.callCount).to.equal(1) }) it(`${CONCURRENT_OPERATIONS} concurrent onDecrypt misses share one keystore call`, async () => { - // On a slow runner, starting 3000 callers can take longer than a short inFlightTTL, + // On a slow runner, starting 3000 callers can take longer than a short grace interval, // which starts a second fetch. - Object.assign(SHARED_REQUESTS, { inFlightTTL: 60 * 1000 }) + Object.assign(SHARED_FETCHES, { graceInterval: 60 * 1000 }) const keyStore = slowKeyStore() const [encrypted] = await encryptConcurrently(keyringFor(keyStore), 1) const expectedPdk = unwrapDataKey(encrypted.getUnencryptedDataKey()) @@ -317,15 +317,15 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { ) for (const result of results) expect(result.status).to.equal('rejected') - expect(inFlightRequestsFor(cache)?.size).to.equal(0) + expect(sharedFetchesFor(cache)?.size).to.equal(0) }) - it('starts another fetch for new callers once one hangs past inFlightTTL', async () => { + it('starts another fetch for new callers once one hangs past graceInterval', async () => { const keyStore = slowKeyStore(['hang', 'ok']) const hkr = keyringFor(keyStore) startEncrypts(hkr, 1) - await wait(FAST.inFlightTTL + 5) + await wait(FAST.graceInterval + 5) await encryptConcurrently(hkr, 5) expect(keyStore.getActiveBranchKey.callCount).to.equal(2) @@ -353,7 +353,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { ) const stale = get() - await wait(FAST.inFlightTTL + 5) + await wait(FAST.graceInterval + 5) const newer = [get(), get(), get()] await wait(1) expect(deferreds).to.have.lengthOf(2) @@ -378,7 +378,7 @@ describe('KmsHierarchicalKeyRingNode: shared keystore fetches (#1663)', () => { const cache = getLocalCryptographicMaterialsCache(100) await encryptConcurrently(keyringFor(slowKeyStore(), { cache }), 5) await encryptSettled(keyringFor(slowKeyStore(['fail']), { cache }), 5) - expect(inFlightRequestsFor(cache)?.size).to.equal(0) + expect(sharedFetchesFor(cache)?.size).to.equal(0) }) })