Skip to content

Commit 3e66235

Browse files
fix(resources): dedupe and invalidate memoized AWS caches
memoize() cached only a promise's resolved value, set inside .then(), so concurrent callers each fired their own request before the first resolved — defeating the cache exactly during tree expansion, when many nodes resolve at once. Cached data also lived for the whole session with no way to invalidate, so the refresh button replayed stale account ids / region lists and never picked up re-authenticated credentials. Cache the promise itself (concurrent callers now share one in-flight request) and evict it on rejection so failures aren't served forever. Add clear() per memoized function plus a registry-backed clearMemoizedCaches(), and wire it into the refreshResources command so a manual refresh re-queries AWS and rebuilds clients (also picking up an endpoint change for the profile-only-keyed clients). Add memoize.test.ts covering dedup, rejection-eviction, and clear paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71a8fe5 commit 3e66235

3 files changed

Lines changed: 134 additions & 20 deletions

File tree

src/plugins/resource-browser.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { commands, window, workspace } from "vscode";
22

33
import { ProviderFactory } from "../platforms/aws/services/providerFactory.ts";
44
import { createPlugin } from "../plugins.ts";
5+
import { clearMemoizedCaches } from "../utils/memoize.ts";
56
import { registerLocalStackCommands } from "../views/explore/commands.ts";
67
import { LocalStackViewProvider } from "../views/explore/viewProvider.ts";
78
import { ResourceDetailsViewProvider } from "../views/resource-details/viewProvider.ts";
@@ -66,9 +67,13 @@ export default createPlugin(
6667
);
6768

6869
context.subscriptions.push(
69-
commands.registerCommand("localstack.refreshResources", () =>
70-
resourcesProvider.refresh(),
71-
),
70+
commands.registerCommand("localstack.refreshResources", () => {
71+
/* A manual refresh should re-query AWS, not replay cached account
72+
* ids / region lists / clients — drop the caches first so newly
73+
* created resources and re-authenticated credentials are picked up. */
74+
clearMemoizedCaches();
75+
return resourcesProvider.refresh();
76+
}),
7277
commands.registerCommand("localstack.refreshResourceDetails", () =>
7378
detailsProvider.refresh(),
7479
),

src/test/memoize.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as assert from "node:assert";
2+
3+
import { clearMemoizedCaches, memoize } from "../utils/memoize.ts";
4+
5+
suite("memoize", () => {
6+
test("caches by argument key", () => {
7+
let calls = 0;
8+
const m = memoize((x: number) => {
9+
calls++;
10+
return x * 2;
11+
});
12+
assert.strictEqual(m(2), 4);
13+
assert.strictEqual(m(2), 4);
14+
assert.strictEqual(calls, 1);
15+
assert.strictEqual(m(3), 6);
16+
assert.strictEqual(calls, 2);
17+
});
18+
19+
test("dedupes concurrent in-flight async callers into one invocation", async () => {
20+
let calls = 0;
21+
const m = memoize((key: string) => {
22+
calls++;
23+
return Promise.resolve(`${key}!`);
24+
});
25+
26+
/* Both calls happen before the first resolves; with promise caching they
27+
* share the single in-flight request rather than each invoking `func`. */
28+
const [a, b] = await Promise.all([m("x"), m("x")]);
29+
assert.strictEqual(a, "x!");
30+
assert.strictEqual(b, "x!");
31+
assert.strictEqual(calls, 1);
32+
});
33+
34+
test("does not cache a rejected promise; the next call retries", async () => {
35+
let attempts = 0;
36+
const m = memoize((key: string) => {
37+
attempts++;
38+
return attempts === 1
39+
? Promise.reject(new Error("transient"))
40+
: Promise.resolve(`${key}-ok`);
41+
});
42+
43+
await assert.rejects(m("x"), /transient/);
44+
/* The failure was evicted, so the retry re-invokes and succeeds. */
45+
assert.strictEqual(await m("x"), "x-ok");
46+
assert.strictEqual(attempts, 2);
47+
});
48+
49+
test("clear() drops a single function's cache", () => {
50+
let calls = 0;
51+
const m = memoize((x: number) => {
52+
calls++;
53+
return x;
54+
});
55+
m(1);
56+
m(1);
57+
assert.strictEqual(calls, 1);
58+
m.clear();
59+
m(1);
60+
assert.strictEqual(calls, 2);
61+
});
62+
63+
test("clearMemoizedCaches() drops registered caches", () => {
64+
let calls = 0;
65+
const m = memoize((x: number) => {
66+
calls++;
67+
return x;
68+
});
69+
m(1);
70+
assert.strictEqual(calls, 1);
71+
clearMemoizedCaches();
72+
m(1);
73+
assert.strictEqual(calls, 2);
74+
});
75+
});

src/utils/memoize.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,69 @@
1+
/**
2+
* A memoized function, with a `clear()` to drop its cached results.
3+
*/
4+
export type Memoized<Args extends unknown[], Result> = ((
5+
...args: Args
6+
) => Result) & { clear: () => void };
7+
8+
/*
9+
* Every memoized function's `clear` is registered here so all caches can be
10+
* dropped at once — e.g. when the user refreshes to re-query AWS, or when
11+
* credentials/endpoints may have changed and cached clients/results are stale.
12+
*/
13+
const registry = new Set<() => void>();
14+
15+
/** Drop every memoized cache (cached SDK clients and fetched data alike). */
16+
export function clearMemoizedCaches(): void {
17+
for (const clear of registry) {
18+
clear();
19+
}
20+
}
21+
122
/**
223
* Memoizes a function's results based on its arguments.
324
*
25+
* For promise-returning functions the *promise* is cached (not just its
26+
* resolved value), so concurrent callers share a single in-flight request
27+
* instead of each firing their own. A rejected promise is evicted so the
28+
* failure is not served forever and the next call retries.
29+
*
30+
* Note: keys are derived via `JSON.stringify(args)`, so this is intended for
31+
* primitive arguments (strings/numbers). Object args with differing key order
32+
* — or non-serializable values — will not key reliably.
33+
*
434
* @param func The function to memoize.
5-
* @returns A memoized version of the function.
35+
* @returns A memoized version of `func`, with a `clear()` to drop its cache.
636
*/
737
export function memoize<Args extends unknown[], Result>(
838
func: (...args: Args) => Result,
9-
): (...args: Args) => Result {
10-
/* For promise-returning functions we cache the resolved value (not the
11-
* promise), so the cache holds `Awaited<Result>`. */
12-
const cache = new Map<string, Awaited<Result>>();
13-
14-
return (...args: Args): Result => {
15-
const key = JSON.stringify(args); // Create a unique key from arguments
39+
): Memoized<Args, Result> {
40+
const cache = new Map<string, Result>();
1641

42+
const memoized = ((...args: Args): Result => {
43+
const key = JSON.stringify(args);
1744
if (cache.has(key)) {
1845
return cache.get(key) as Result;
1946
}
2047

2148
const result = func(...args);
49+
cache.set(key, result);
2250

23-
/* for Promises, only cache them if they're successful */
2451
if (result instanceof Promise) {
25-
return (result as Promise<Awaited<Result>>).then((res) => {
26-
cache.set(key, res);
27-
return res;
28-
}) as Result;
29-
30-
/* For non-Promises, always cache the result */
52+
/* Don't let a rejection stick in the cache. Evict only if this exact
53+
* promise is still the cached entry (a clear/refresh may have replaced
54+
* it). The caller handles the rejection on its own copy of the promise;
55+
* this handler exists solely to evict. */
56+
result.catch(() => {
57+
if (cache.get(key) === result) {
58+
cache.delete(key);
59+
}
60+
});
3161
}
32-
cache.set(key, result as Awaited<Result>);
62+
3363
return result;
34-
};
64+
}) as Memoized<Args, Result>;
65+
66+
memoized.clear = () => cache.clear();
67+
registry.add(memoized.clear);
68+
return memoized;
3569
}

0 commit comments

Comments
 (0)