Skip to content

Commit 419a803

Browse files
azure: Add in-memory cache for Azure location/provider API calls (#2239)
* azure: Add in-memory cache for Azure location/provider API calls LocationCache deduplicates concurrent requests and caches results across wizard instances within a single extension activation. Wired into getAllLocations and getProviderLocations in LocationListStep.
1 parent d856f3a commit 419a803

File tree

7 files changed

+336
-13
lines changed

7 files changed

+336
-13
lines changed

azure/index.d.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,31 @@ export declare class LocationListStep<T extends ILocationWizardContext> extends
201201
public static getQuickPickDescription?: (location: AzExtLocation) => string | undefined;
202202
}
203203

204+
/**
205+
* A simple cache that deduplicates in-flight requests.
206+
*
207+
* Currently backed by an in-memory Map. Designed so the backing store can be
208+
* swapped to persistent storage (e.g. `vscode.Memento` / globalState) in the
209+
* future to survive across VS Code restarts with a longer TTL (e.g. 7 days).
210+
*/
211+
export declare class LocationCache<T> {
212+
/**
213+
* @param ttlMs Optional time-to-live in milliseconds. When omitted, entries
214+
* never expire (suitable for in-memory caches that reset on extension
215+
* deactivation). Set this when switching to persistent storage.
216+
* @param now Clock function used for TTL checks. Override in tests to avoid
217+
* real timers.
218+
*/
219+
constructor(ttlMs?: number, now?: () => number);
220+
/**
221+
* Get a value from the cache, or fetch it if missing/expired.
222+
* Concurrent calls with the same key share a single in-flight request.
223+
*/
224+
getOrLoad(key: string, loader: () => Promise<T>): Promise<T>;
225+
/** Remove all cached entries. */
226+
clear(): void;
227+
}
228+
204229
/**
205230
* Checks to see if providers (i.e. 'Microsoft.Web') are registered and registers them if they're not
206231
*/

azure/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

azure/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@microsoft/vscode-azext-azureutils",
33
"author": "Microsoft Corporation",
4-
"version": "4.0.2",
4+
"version": "4.1.0",
55
"description": "Common Azure utils for developing Azure extensions for VS Code",
66
"tags": [
77
"azure",

azure/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './utils/createPortalUri';
1515
export * from './utils/parseAzureResourceId';
1616
export * from './utils/setupAzureLogger';
1717
export * from './utils/uiUtils';
18+
export { LocationCache } from './wizard/LocationCache';
1819
export * from './wizard/LocationListStep';
1920
export * from './wizard/ResourceGroupCreateStep';
2021
export * from './wizard/ResourceGroupListStep';

azure/src/wizard/LocationCache.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/**
7+
* A simple cache that deduplicates in-flight requests.
8+
*
9+
* Currently backed by an in-memory Map. Designed so the backing store can be
10+
* swapped to persistent storage (e.g. `vscode.Memento` / globalState) in the
11+
* future to survive across VS Code restarts with a longer TTL (e.g. 7 days).
12+
*/
13+
14+
interface CacheEntry<T> {
15+
data: T;
16+
/** Unix timestamp (ms) when this entry was stored */
17+
storedAt: number;
18+
}
19+
20+
export class LocationCache<T> {
21+
private readonly cache = new Map<string, CacheEntry<T>>();
22+
23+
/**
24+
* In-flight promises keyed the same way as the cache.
25+
* Ensures concurrent callers share the same request instead of firing duplicates.
26+
*/
27+
private readonly inflight = new Map<string, Promise<T>>();
28+
29+
/**
30+
* Monotonically increasing counter incremented on each {@link clear} call.
31+
* In-flight requests captured before a clear will see a stale generation
32+
* and skip writing their result back into the cache.
33+
*/
34+
private generation = 0;
35+
36+
/**
37+
* @param ttlMs Optional time-to-live in milliseconds. When omitted, entries
38+
* never expire (suitable for in-memory caches that reset on extension
39+
* deactivation). Set this when switching to persistent storage.
40+
* @param now Clock function used for TTL checks. Override in tests to avoid
41+
* real timers.
42+
*/
43+
constructor(private readonly ttlMs?: number, private readonly now: () => number = Date.now) { }
44+
45+
/**
46+
* Get a value from the cache, or fetch it if missing/expired.
47+
* Concurrent calls with the same key share a single in-flight request.
48+
*/
49+
getOrLoad(key: string, loader: () => Promise<T>): Promise<T> {
50+
const cached = this.cache.get(key);
51+
if (cached && !this.isExpired(cached)) {
52+
return Promise.resolve(cached.data);
53+
}
54+
55+
// Check for an in-flight request we can piggy-back on
56+
const existing = this.inflight.get(key);
57+
if (existing) {
58+
return existing;
59+
}
60+
61+
const gen = this.generation;
62+
63+
let loaderPromise: Promise<T>;
64+
try {
65+
loaderPromise = loader();
66+
} catch (err) {
67+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
68+
}
69+
70+
const promise = loaderPromise.then(data => {
71+
if (this.generation === gen) {
72+
this.cache.set(key, { data, storedAt: this.now() });
73+
}
74+
this.inflight.delete(key);
75+
return data;
76+
}).catch(err => {
77+
this.inflight.delete(key);
78+
throw err;
79+
});
80+
81+
this.inflight.set(key, promise);
82+
return promise;
83+
}
84+
85+
/** Remove all cached entries. */
86+
clear(): void {
87+
this.cache.clear();
88+
this.generation++;
89+
}
90+
91+
private isExpired(entry: CacheEntry<T>): boolean {
92+
return this.ttlMs !== undefined && (this.now() - entry.storedAt) > this.ttlMs;
93+
}
94+
}

azure/src/wizard/LocationListStep.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { createResourcesClient, createSubscriptionsClient } from '../clients';
1212
import { resourcesProvider } from '../constants';
1313
import { ext } from '../extensionVariables';
1414
import { uiUtils } from '../utils/uiUtils';
15+
import { LocationCache } from './LocationCache';
16+
17+
const allLocationsCache = new LocationCache<types.AzExtLocation[]>();
18+
const providerLocationCache = new LocationCache<string[]>();
1519

1620
/* eslint-disable @typescript-eslint/naming-convention */
1721
interface ILocationWizardContextInternal extends types.ILocationWizardContext {
@@ -254,19 +258,34 @@ export class LocationListStep<T extends ILocationWizardContextInternal> extends
254258
}
255259

256260
async function getAllLocations(wizardContext: types.ILocationWizardContext): Promise<types.AzExtLocation[]> {
257-
const client = await createSubscriptionsClient(wizardContext);
258-
const locations = await uiUtils.listAllIterator<Location>(client.subscriptions.listLocations(wizardContext.subscriptionId, { includeExtendedLocations: wizardContext.includeExtendedLocations }));
259-
return locations.filter((l): l is types.AzExtLocation => !!(l.id && l.name && l.displayName));
261+
const includeExtended = !!wizardContext.includeExtendedLocations;
262+
const cacheKey = `${wizardContext.subscriptionId}|${includeExtended}`;
263+
264+
return allLocationsCache.getOrLoad(cacheKey, async () => {
265+
ext.outputChannel.appendLog(`Cache miss for all locations (key: "${cacheKey}"). Fetching from API...`);
266+
const client = await createSubscriptionsClient(wizardContext);
267+
const locations = await uiUtils.listAllIterator<Location>(client.subscriptions.listLocations(wizardContext.subscriptionId, { includeExtendedLocations: includeExtended }));
268+
const filtered = locations.filter((l): l is types.AzExtLocation => !!(l.id && l.name && l.displayName));
269+
ext.outputChannel.appendLog(`Fetched and cached ${filtered.length} locations for subscription "${wizardContext.subscriptionId}".`);
270+
return filtered;
271+
});
260272
}
261273

262274
async function getProviderLocations(wizardContext: types.ILocationWizardContext, provider: string, resourceType: string): Promise<string[]> {
263-
const rgClient = await createResourcesClient(wizardContext);
264-
const providerData = await rgClient.providers.get(provider);
265-
const resourceTypeData = providerData.resourceTypes?.find(rt => rt.resourceType?.toLowerCase() === resourceType.toLowerCase());
266-
if (!resourceTypeData) {
267-
throw new ProviderResourceTypeNotFoundError(providerData, resourceType);
268-
}
269-
return nonNullProp(resourceTypeData, 'locations');
275+
const cacheKey = `${wizardContext.subscriptionId}|${provider.toLowerCase()}|${resourceType.toLowerCase()}`;
276+
277+
return providerLocationCache.getOrLoad(cacheKey, async () => {
278+
ext.outputChannel.appendLog(`Cache miss for provider locations (key: "${cacheKey}"). Fetching from API...`);
279+
const rgClient = await createResourcesClient(wizardContext);
280+
const providerData = await rgClient.providers.get(provider);
281+
const resourceTypeData = providerData.resourceTypes?.find(rt => rt.resourceType?.toLowerCase() === resourceType.toLowerCase());
282+
if (!resourceTypeData) {
283+
throw new ProviderResourceTypeNotFoundError(providerData, resourceType);
284+
}
285+
const locations = nonNullProp(resourceTypeData, 'locations');
286+
ext.outputChannel.appendLog(`Fetched and cached ${locations.length} locations for provider "${provider}/${resourceType}".`);
287+
return locations;
288+
});
270289
}
271290

272291
function compareLocation(l1: types.AzExtLocation, l2: types.AzExtLocation): number {

azure/test/LocationCache.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as assert from 'assert';
7+
import { LocationCache } from '../src/wizard/LocationCache';
8+
9+
suite('LocationCache', () => {
10+
let cache: LocationCache<string[]>;
11+
12+
setup(() => {
13+
cache = new LocationCache();
14+
});
15+
16+
test('returns data from loader on cache miss', async () => {
17+
const result = await cache.getOrLoad('key1', () => Promise.resolve(['eastus', 'westus']));
18+
assert.deepStrictEqual(result, ['eastus', 'westus']);
19+
});
20+
21+
test('returns cached data on subsequent calls without calling loader again', async () => {
22+
let callCount = 0;
23+
const loader = () => {
24+
callCount++;
25+
return Promise.resolve(['eastus']);
26+
};
27+
28+
const result1 = await cache.getOrLoad('key1', loader);
29+
const result2 = await cache.getOrLoad('key1', loader);
30+
31+
assert.deepStrictEqual(result1, ['eastus']);
32+
assert.deepStrictEqual(result2, ['eastus']);
33+
assert.strictEqual(callCount, 1, 'loader should only be called once');
34+
});
35+
36+
test('uses separate entries for different keys', async () => {
37+
await cache.getOrLoad('sub1|false', () => Promise.resolve(['eastus']));
38+
await cache.getOrLoad('sub2|false', () => Promise.resolve(['westus']));
39+
40+
const result1 = await cache.getOrLoad('sub1|false', () => Promise.reject(new Error('should not call')));
41+
const result2 = await cache.getOrLoad('sub2|false', () => Promise.reject(new Error('should not call')));
42+
43+
assert.deepStrictEqual(result1, ['eastus']);
44+
assert.deepStrictEqual(result2, ['westus']);
45+
});
46+
47+
test('deduplicates concurrent in-flight requests for the same key', async () => {
48+
let callCount = 0;
49+
let resolve: (value: string[]) => void;
50+
const loader = () => {
51+
callCount++;
52+
return new Promise<string[]>(r => { resolve = r; });
53+
};
54+
55+
const p1 = cache.getOrLoad('key1', loader);
56+
const p2 = cache.getOrLoad('key1', loader);
57+
58+
// Both should be waiting on the same promise
59+
assert.strictEqual(callCount, 1, 'loader should only be called once for concurrent requests');
60+
61+
resolve!(['eastus']);
62+
const [result1, result2] = await Promise.all([p1, p2]);
63+
64+
assert.deepStrictEqual(result1, ['eastus']);
65+
assert.deepStrictEqual(result2, ['eastus']);
66+
});
67+
68+
test('clear removes all cached entries', async () => {
69+
let callCount = 0;
70+
const loader = () => {
71+
callCount++;
72+
return Promise.resolve(['eastus']);
73+
};
74+
75+
await cache.getOrLoad('key1', loader);
76+
assert.strictEqual(callCount, 1);
77+
78+
cache.clear();
79+
80+
await cache.getOrLoad('key1', loader);
81+
assert.strictEqual(callCount, 2, 'loader should be called again after clear');
82+
});
83+
84+
test('clear prevents in-flight requests from repopulating the cache', async () => {
85+
let resolve: (value: string[]) => void;
86+
const loader = () => new Promise<string[]>(r => { resolve = r; });
87+
88+
const p1 = cache.getOrLoad('key1', loader);
89+
90+
// Clear while the request is still in-flight
91+
cache.clear();
92+
93+
// Resolve the stale request
94+
resolve!(['stale']);
95+
await p1;
96+
97+
// The stale result should NOT have been cached, so a new loader fires
98+
let callCount = 0;
99+
await cache.getOrLoad('key1', () => { callCount++; return Promise.resolve(['fresh']); });
100+
assert.strictEqual(callCount, 1, 'loader should be called because stale result was not cached');
101+
});
102+
103+
test('expired entries are refreshed (injectable clock)', async () => {
104+
let time = 1000;
105+
const clock = () => time;
106+
const ttlCache = new LocationCache<string[]>(100, clock);
107+
108+
let callCount = 0;
109+
const loader = () => {
110+
callCount++;
111+
return Promise.resolve([`result-${callCount}`]);
112+
};
113+
114+
const result1 = await ttlCache.getOrLoad('key1', loader);
115+
assert.deepStrictEqual(result1, ['result-1']);
116+
117+
// Advance past TTL
118+
time = 1200;
119+
120+
const result2 = await ttlCache.getOrLoad('key1', loader);
121+
assert.deepStrictEqual(result2, ['result-2']);
122+
assert.strictEqual(callCount, 2, 'loader should be called again after expiry');
123+
});
124+
125+
test('entries without TTL never expire', async () => {
126+
let callCount = 0;
127+
128+
await cache.getOrLoad('key1', () => { callCount++; return Promise.resolve(['eastus']); });
129+
await cache.getOrLoad('key1', () => { callCount++; return Promise.resolve(['westus']); });
130+
131+
assert.strictEqual(callCount, 1);
132+
});
133+
134+
test('loader error does not poison the cache', async () => {
135+
let shouldFail = true;
136+
const loader = () => {
137+
if (shouldFail) {
138+
return Promise.reject(new Error('network error'));
139+
}
140+
return Promise.resolve(['eastus']);
141+
};
142+
143+
await assert.rejects(() => cache.getOrLoad('key1', loader), /network error/);
144+
145+
shouldFail = false;
146+
const result = await cache.getOrLoad('key1', loader);
147+
assert.deepStrictEqual(result, ['eastus']);
148+
});
149+
150+
test('loader error is propagated to all concurrent waiters', async () => {
151+
let reject: (err: Error) => void;
152+
const loader = () => new Promise<string[]>((_, r) => { reject = r; });
153+
154+
const p1 = cache.getOrLoad('key1', loader);
155+
const p2 = cache.getOrLoad('key1', loader);
156+
157+
reject!(new Error('boom'));
158+
159+
await assert.rejects(() => p1, /boom/);
160+
await assert.rejects(() => p2, /boom/);
161+
});
162+
163+
test('after error, a new loader call succeeds', async () => {
164+
let callCount = 0;
165+
const failLoader = () => { callCount++; return Promise.reject(new Error('fail')); };
166+
const okLoader = () => { callCount++; return Promise.resolve(['eastus']); };
167+
168+
await assert.rejects(() => cache.getOrLoad('key1', failLoader), /fail/);
169+
const result = await cache.getOrLoad('key1', okLoader);
170+
171+
assert.deepStrictEqual(result, ['eastus']);
172+
assert.strictEqual(callCount, 2);
173+
});
174+
175+
test('synchronous throw from loader is handled', async () => {
176+
const loader = (): Promise<string[]> => { throw new Error('sync boom'); };
177+
178+
await assert.rejects(() => cache.getOrLoad('key1', loader), /sync boom/);
179+
180+
// Cache should not be poisoned
181+
const result = await cache.getOrLoad('key1', () => Promise.resolve(['eastus']));
182+
assert.deepStrictEqual(result, ['eastus']);
183+
});
184+
});

0 commit comments

Comments
 (0)