Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions azure/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,41 @@ export declare class LocationListStep<T extends ILocationWizardContext> extends
public static getQuickPickDescription?: (location: AzExtLocation) => string | undefined;
}

/**
* A simple cache for location data that deduplicates in-flight requests.
*
* Currently backed by an in-memory Map. Designed so the backing store can be
* swapped to persistent storage (e.g. `vscode.Memento` / globalState) in the
* future to survive across VS Code restarts with a longer TTL (e.g. 7 days).
*/
export declare class LocationCache {
/**
* @param ttlMs Optional time-to-live in milliseconds. When omitted, entries
* never expire (suitable for in-memory caches that reset on extension
* deactivation). Set this when switching to persistent storage.
*/
constructor(ttlMs?: number);
/**
* Get a value from the cache, or fetch it if missing/expired.
* Concurrent calls with the same key share a single in-flight request.
*/
getOrLoad<T>(key: string, loader: () => Promise<T>): Promise<T>;
/** Remove all entries (e.g. on sign-out or subscription change) */
clear(): void;
}

/**
* Module-level cache for subscription locations, shared across all wizard instances
* within the same extension activation. Keyed by `subscriptionId|includeExtended`.
*/
export declare const subscriptionLocationsCache: LocationCache;

/**
* Module-level cache for provider resource-type locations, shared across all wizard instances
* within the same extension activation. Keyed by `subscriptionId|provider|resourceType`.
*/
export declare const providerLocationsCache: LocationCache;

/**
* Checks to see if providers (i.e. 'Microsoft.Web') are registered and registers them if they're not
*/
Expand Down
1 change: 1 addition & 0 deletions azure/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './utils/parseAzureResourceId';
export * from './utils/setupAzureLogger';
export * from './utils/uiUtils';
export * from './wizard/LocationListStep';
export { LocationCache, subscriptionLocationsCache, providerLocationsCache } from './wizard/LocationCache';
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Exporting the singleton caches (subscriptionLocationsCache, providerLocationsCache) makes them part of the package’s public API (also reflected in index.d.ts). If these are intended to be internal implementation details, consider not exporting them (or marking as internal/unstable) to avoid external consumers depending on shared global state that may need to change later.

Suggested change
export { LocationCache, subscriptionLocationsCache, providerLocationsCache } from './wizard/LocationCache';
export { LocationCache } from './wizard/LocationCache';

Copilot uses AI. Check for mistakes.
export * from './wizard/ResourceGroupCreateStep';
export * from './wizard/ResourceGroupListStep';
export * from './wizard/ResourceGroupNameStep';
Expand Down
82 changes: 82 additions & 0 deletions azure/src/wizard/LocationCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* A simple cache for location data that deduplicates in-flight requests.
*
* Currently backed by an in-memory Map. Designed so the backing store can be
* swapped to persistent storage (e.g. `vscode.Memento` / globalState) in the
* future to survive across VS Code restarts with a longer TTL (e.g. 7 days).
*/

interface CacheEntry<T> {
data: T;
/** Unix timestamp (ms) when this entry was stored */
storedAt: number;
}

export class LocationCache {
private readonly cache = new Map<string, CacheEntry<unknown>>();
Comment thread
alexweininger marked this conversation as resolved.
Outdated

/**
* In-flight promises keyed the same way as the cache.
* Ensures concurrent callers share the same request instead of firing duplicates.
*/
private readonly inflight = new Map<string, Promise<unknown>>();

/**
* @param ttlMs Optional time-to-live in milliseconds. When omitted, entries
* never expire (suitable for in-memory caches that reset on extension
* deactivation). Set this when switching to persistent storage.
*/
constructor(private readonly ttlMs?: number) { }

/**
* Get a value from the cache, or fetch it if missing/expired.
* Concurrent calls with the same key share a single in-flight request.
*/
async getOrLoad<T>(key: string, loader: () => Promise<T>): Promise<T> {
const cached = this.cache.get(key) as CacheEntry<T> | undefined;
if (cached && !this.isExpired(cached)) {
return cached.data;
}

// Check for an in-flight request we can piggy-back on
const existing = this.inflight.get(key) as Promise<T> | undefined;
if (existing) {
return existing;
}

const promise = loader().then(data => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

loader() can throw synchronously before returning a Promise. In that case, inflight is never set, and callers won't get consistent dedup/error behavior. Wrap the call so sync throws become a rejected Promise (e.g., start from Promise.resolve().then(loader)), and ensure inflight bookkeeping is consistent.

Suggested change
const promise = loader().then(data => {
const promise = Promise.resolve().then(loader).then(data => {

Copilot uses AI. Check for mistakes.
this.cache.set(key, { data, storedAt: Date.now() });
this.inflight.delete(key);
return data;
}).catch(err => {
this.inflight.delete(key);
throw err;
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Calling clear() can be undone by currently in-flight requests: once they resolve, they will repopulate this.cache via this.cache.set(...), potentially reintroducing stale entries right after a sign-out/subscription change. Consider clearing inflight as well, or introduce a cache 'generation' token checked before writing results (ignore writes from older generations).

Copilot uses AI. Check for mistakes.

this.inflight.set(key, promise);
return promise;
}

/** Remove all entries (e.g. on sign-out or subscription change) */
clear(): void {
this.cache.clear();
// Don't clear inflight — let pending requests finish naturally
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Calling clear() can be undone by currently in-flight requests: once they resolve, they will repopulate this.cache via this.cache.set(...), potentially reintroducing stale entries right after a sign-out/subscription change. Consider clearing inflight as well, or introduce a cache 'generation' token checked before writing results (ignore writes from older generations).

Copilot uses AI. Check for mistakes.

private isExpired(entry: CacheEntry<unknown>): boolean {
return this.ttlMs !== undefined && (Date.now() - entry.storedAt) > this.ttlMs;
}
}

/**
* Module-level caches shared across all wizard instances within the same
* extension activation. Keyed by subscription ID (+ provider info for
* provider locations).
*/
export const subscriptionLocationsCache = new LocationCache();
export const providerLocationsCache = new LocationCache();
30 changes: 20 additions & 10 deletions azure/src/wizard/LocationListStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createResourcesClient, createSubscriptionsClient } from '../clients';
import { resourcesProvider } from '../constants';
import { ext } from '../extensionVariables';
import { uiUtils } from '../utils/uiUtils';
import { providerLocationsCache, subscriptionLocationsCache } from './LocationCache';

/* eslint-disable @typescript-eslint/naming-convention */
interface ILocationWizardContextInternal extends types.ILocationWizardContext {
Expand Down Expand Up @@ -254,19 +255,28 @@ export class LocationListStep<T extends ILocationWizardContextInternal> extends
}

async function getAllLocations(wizardContext: types.ILocationWizardContext): Promise<types.AzExtLocation[]> {
const client = await createSubscriptionsClient(wizardContext);
const locations = await uiUtils.listAllIterator<Location>(client.subscriptions.listLocations(wizardContext.subscriptionId, { includeExtendedLocations: wizardContext.includeExtendedLocations }));
return locations.filter((l): l is types.AzExtLocation => !!(l.id && l.name && l.displayName));
const includeExtended = !!wizardContext.includeExtendedLocations;
const cacheKey = `${wizardContext.subscriptionId}|${includeExtended}`;

return subscriptionLocationsCache.getOrLoad(cacheKey, async () => {
const client = await createSubscriptionsClient(wizardContext);
const locations = await uiUtils.listAllIterator<Location>(client.subscriptions.listLocations(wizardContext.subscriptionId, { includeExtendedLocations: includeExtended }));
return locations.filter((l): l is types.AzExtLocation => !!(l.id && l.name && l.displayName));
});
}

async function getProviderLocations(wizardContext: types.ILocationWizardContext, provider: string, resourceType: string): Promise<string[]> {
const rgClient = await createResourcesClient(wizardContext);
const providerData = await rgClient.providers.get(provider);
const resourceTypeData = providerData.resourceTypes?.find(rt => rt.resourceType?.toLowerCase() === resourceType.toLowerCase());
if (!resourceTypeData) {
throw new ProviderResourceTypeNotFoundError(providerData, resourceType);
}
return nonNullProp(resourceTypeData, 'locations');
const cacheKey = `${wizardContext.subscriptionId}|${provider.toLowerCase()}|${resourceType.toLowerCase()}`;

return providerLocationsCache.getOrLoad(cacheKey, async () => {
const rgClient = await createResourcesClient(wizardContext);
const providerData = await rgClient.providers.get(provider);
const resourceTypeData = providerData.resourceTypes?.find(rt => rt.resourceType?.toLowerCase() === resourceType.toLowerCase());
if (!resourceTypeData) {
throw new ProviderResourceTypeNotFoundError(providerData, resourceType);
}
return nonNullProp(resourceTypeData, 'locations');
});
}

function compareLocation(l1: types.AzExtLocation, l2: types.AzExtLocation): number {
Expand Down
154 changes: 154 additions & 0 deletions azure/test/LocationCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { LocationCache } from '../src/wizard/LocationCache';

suite('LocationCache', () => {
let cache: LocationCache;

setup(() => {
cache = new LocationCache();
});

test('returns data from loader on cache miss', async () => {
const result = await cache.getOrLoad('key1', async () => ['eastus', 'westus']);

Check failure on line 17 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression
assert.deepStrictEqual(result, ['eastus', 'westus']);
});

test('returns cached data on subsequent calls without calling loader again', async () => {
let callCount = 0;
const loader = async () => {

Check failure on line 23 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function 'loader' has no 'await' expression
callCount++;
return ['eastus'];
};

const result1 = await cache.getOrLoad('key1', loader);
const result2 = await cache.getOrLoad('key1', loader);

assert.deepStrictEqual(result1, ['eastus']);
assert.deepStrictEqual(result2, ['eastus']);
assert.strictEqual(callCount, 1, 'loader should only be called once');
});

test('uses separate entries for different keys', async () => {
await cache.getOrLoad('sub1|false', async () => ['eastus']);

Check failure on line 37 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression
await cache.getOrLoad('sub2|false', async () => ['westus']);

Check failure on line 38 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression

const result1 = await cache.getOrLoad('sub1|false', async () => { throw new Error('should not call'); });

Check failure on line 40 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression
const result2 = await cache.getOrLoad('sub2|false', async () => { throw new Error('should not call'); });

Check failure on line 41 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression

assert.deepStrictEqual(result1, ['eastus']);
assert.deepStrictEqual(result2, ['westus']);
});

test('deduplicates concurrent in-flight requests for the same key', async () => {
let callCount = 0;
let resolve: (value: string[]) => void;
const loader = () => {
callCount++;
return new Promise<string[]>(r => { resolve = r; });
};

const p1 = cache.getOrLoad('key1', loader);
const p2 = cache.getOrLoad('key1', loader);

// Both should be waiting on the same promise
assert.strictEqual(callCount, 1, 'loader should only be called once for concurrent requests');

resolve!(['eastus']);
const [result1, result2] = await Promise.all([p1, p2]);

assert.deepStrictEqual(result1, ['eastus']);
assert.deepStrictEqual(result2, ['eastus']);
});

test('clear removes all cached entries', async () => {
let callCount = 0;
const loader = async () => {

Check failure on line 70 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function 'loader' has no 'await' expression
callCount++;
return ['eastus'];
};

await cache.getOrLoad('key1', loader);
assert.strictEqual(callCount, 1);

cache.clear();

await cache.getOrLoad('key1', loader);
assert.strictEqual(callCount, 2, 'loader should be called again after clear');
});

test('expired entries are refreshed', async () => {
// Use a very short TTL
const shortCache = new LocationCache(1);
let callCount = 0;
const loader = async () => {

Check failure on line 88 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function 'loader' has no 'await' expression
callCount++;
return [`result-${callCount}`];
};

const result1 = await shortCache.getOrLoad('key1', loader);
assert.deepStrictEqual(result1, ['result-1']);

// Wait for expiry
await new Promise(r => setTimeout(r, 10));
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This time-based test can be flaky under slow/loaded CI agents. Prefer fake timers (if the repo test stack supports them) or otherwise make the timing deterministic (e.g., inject a clock into LocationCache for tests) to avoid intermittent failures.

Copilot uses AI. Check for mistakes.

const result2 = await shortCache.getOrLoad('key1', loader);
assert.deepStrictEqual(result2, ['result-2']);
assert.strictEqual(callCount, 2, 'loader should be called again after expiry');
});

test('entries without TTL never expire', async () => {
// Default constructor has no TTL
let callCount = 0;

await cache.getOrLoad('key1', async () => { callCount++; return ['eastus']; });

Check failure on line 108 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression
await cache.getOrLoad('key1', async () => { callCount++; return ['westus']; });

Check failure on line 109 in azure/test/LocationCache.test.ts

View workflow job for this annotation

GitHub Actions / Build (azure) / Build

Async arrow function has no 'await' expression

assert.strictEqual(callCount, 1);
});

test('loader error does not poison the cache', async () => {
let shouldFail = true;
const loader = async () => {
if (shouldFail) {
throw new Error('network error');
}
return ['eastus'];
};

await assert.rejects(() => cache.getOrLoad('key1', loader), /network error/);

shouldFail = false;
const result = await cache.getOrLoad('key1', loader);
assert.deepStrictEqual(result, ['eastus']);
});

test('loader error is propagated to all concurrent waiters', async () => {
let reject: (err: Error) => void;
const loader = () => new Promise<string[]>((_, r) => { reject = r; });

const p1 = cache.getOrLoad('key1', loader);
const p2 = cache.getOrLoad('key1', loader);

reject!(new Error('boom'));

await assert.rejects(() => p1, /boom/);
await assert.rejects(() => p2, /boom/);
});

test('after error, a new loader call succeeds', async () => {
let callCount = 0;
const failLoader = async () => { callCount++; throw new Error('fail'); };
const okLoader = async () => { callCount++; return ['eastus']; };

await assert.rejects(() => cache.getOrLoad('key1', failLoader), /fail/);
const result = await cache.getOrLoad('key1', okLoader);

assert.deepStrictEqual(result, ['eastus']);
assert.strictEqual(callCount, 2);
});
});
Loading