Skip to content

Commit 847482f

Browse files
authored
feat: add limit option to useResponseCaching for entry eviction (#737)
1 parent 47a3470 commit 847482f

4 files changed

Lines changed: 308 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- API callers (`ItemApiState`, `ListApiState`) are now awaitable. `await caller` now resolves to `caller.result` after the current operation is complete, or resolves immediately to the previous result if no operation is in progress.
1313
- `useAppUpdateCheck` now also listens for Vite's `vite:preloadError` event, showing the update notification when dynamic imports fail due to stale chunks after a deployment.
1414
- `useAppUpdateCheck` now persists the observed build in `sessionStorage` (keyed by a fingerprint of loaded script URLs), enabling detection of server updates after a browser discards and restores a tab from cached HTML.
15+
- Added `limit` option to `useResponseCaching` to cap the number (`maxEntries`) or total size (`maxBytes`) of cached responses per endpoint group. Oldest entries are evicted first when limits are exceeded.
1516
- Added `returnViewModel` prop to `c-select`, enabling ViewModel instances to be returned directly when bound with `for="TypeName"`.
1617
- Added `adminOverrides` option to `createCoalesceVuetify()`, allowing custom Vue components to replace the default input and/or display components used in admin pages (`c-admin-editor`, `c-admin-method`, `c-table`) for specific model properties, method parameters, or method return values.
1718
- `c-datetime-picker`: Assorted UI and UX improvements and fixes.

docs/stacks/vue/layers/api-clients.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ Enables response caching on the API Caller.
340340

341341
Only [HTTP GET methods](/modeling/model-components/attributes/controller-action.md) are supported, and [file-returning methods](/modeling/model-components/methods.md#file-downloads) are not supported. Call with `false` to disable caching after it was previously enabled.
342342

343+
The `limit` option can be used to cap the number or total size of cached responses in a group. When limits are exceeded, the oldest entries are evicted first. By default, entries are grouped by endpoint URL path (without query parameters), but a custom `key` can be provided to group entries differently. Group metadata is stored alongside cache entries in the same `Storage`.
344+
343345
- **Example**
344346

345347
```ts
@@ -350,6 +352,14 @@ Enables response caching on the API Caller.
350352
});
351353
```
352354

355+
```ts
356+
// Limit cached responses to 10 entries per endpoint
357+
const caller = client.$makeCaller("item", (c, id: number) => c.getItem(id));
358+
caller.useResponseCaching({
359+
limit: { maxEntries: 10 },
360+
});
361+
```
362+
353363
### useSimultaneousRequestCaching() {#usesimultaneousrequestcaching-caller}
354364

355365
Enables simultaneous request caching on the API Caller.

src/coalesce-vue/src/api-client.ts

Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,19 @@ export type ResponseCachingConfiguration = {
14481448

14491449
/** The Storage (default `sessionStorage`) that will hold cached responses. */
14501450
storage?: Storage;
1451+
1452+
/** Limits for the number or total size of cached responses in a group.
1453+
* When limits are exceeded, the oldest entries are evicted first.
1454+
*/
1455+
limit?: {
1456+
/** The group key for this set of cached responses. Entries sharing the same group key share a limit.
1457+
* Defaults to the endpoint URL path without query parameters. */
1458+
key?: string | ((req: AxiosRequestConfig, defaultKey: string) => string);
1459+
/** Maximum total size in bytes of serialized cached responses in this group. */
1460+
maxBytes?: number;
1461+
/** Maximum number of cached entries in this group. */
1462+
maxEntries?: number;
1463+
};
14511464
};
14521465

14531466
// Base class for ApiState that contains nothing but the logic for
@@ -1849,6 +1862,7 @@ export abstract class ApiState<
18491862
key: keyFunc,
18501863
storage = sessionStorage,
18511864
maxAgeSeconds: configuredMaxAge = 3600,
1865+
limit,
18521866
} = responseCacheConfig!;
18531867

18541868
if (request.method?.toUpperCase() != "GET") {
@@ -1922,18 +1936,38 @@ export abstract class ApiState<
19221936
const data = resp.data;
19231937
try {
19241938
purgeStaleCacheEntries(storage);
1925-
storage.setItem(
1926-
key,
1927-
JSON.stringify(
1928-
{
1929-
time: Date.now() / 1000,
1930-
maxAge: configuredMaxAge,
1931-
result: data,
1932-
},
1933-
(key, value) =>
1934-
key == "$metadata" || value === null ? undefined : value,
1935-
),
1939+
const nowSeconds = Date.now() / 1000;
1940+
const serialized = JSON.stringify(
1941+
{
1942+
time: nowSeconds,
1943+
maxAge: configuredMaxAge,
1944+
result: data,
1945+
},
1946+
(key, value) =>
1947+
key == "$metadata" || value === null ? undefined : value,
19361948
);
1949+
storage.setItem(key, serialized);
1950+
1951+
if (limit) {
1952+
const entrySizeBytes =
1953+
typeof TextEncoder !== "undefined"
1954+
? new TextEncoder().encode(serialized).length
1955+
: serialized.length;
1956+
1957+
enforceGroupLimits(
1958+
storage,
1959+
limit.key
1960+
? typeof limit.key === "function"
1961+
? limit.key(request, defaultKey)
1962+
: limit.key
1963+
: defaultKey.split("?")[0],
1964+
key,
1965+
entrySizeBytes,
1966+
nowSeconds,
1967+
limit.maxEntries,
1968+
limit.maxBytes,
1969+
);
1970+
}
19371971
} catch (e) {
19381972
console.warn(
19391973
"coalesce: useResponseCaching: Unable to store response",
@@ -2108,6 +2142,79 @@ function purgeStaleCacheEntries(storage: Storage) {
21082142
}
21092143
}
21102144

2145+
interface CacheGroupEntry {
2146+
time: number;
2147+
size: number;
2148+
}
2149+
2150+
interface CacheGroupMetadata {
2151+
entries: Record<string, CacheGroupEntry>;
2152+
}
2153+
2154+
function enforceGroupLimits(
2155+
storage: Storage,
2156+
groupKey: string,
2157+
cacheKey: string,
2158+
entrySize: number,
2159+
entryTime: number,
2160+
maxEntries?: number,
2161+
maxBytes?: number,
2162+
) {
2163+
const groupStorageKey = `coalesce:group:${groupKey}`;
2164+
let metadata: CacheGroupMetadata;
2165+
2166+
try {
2167+
const raw = storage.getItem(groupStorageKey);
2168+
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
2169+
2170+
metadata =
2171+
parsed &&
2172+
typeof parsed === "object" &&
2173+
"entries" in parsed &&
2174+
(parsed as any).entries &&
2175+
typeof (parsed as any).entries === "object"
2176+
? (parsed as CacheGroupMetadata)
2177+
: { entries: {} };
2178+
} catch {
2179+
metadata = { entries: {} };
2180+
}
2181+
2182+
// Add/update current entry
2183+
metadata.entries[cacheKey] = { time: entryTime, size: entrySize };
2184+
2185+
// Remove references to entries that no longer exist in storage
2186+
for (const key of Object.keys(metadata.entries)) {
2187+
if (key !== cacheKey && storage.getItem(key) === null) {
2188+
delete metadata.entries[key];
2189+
}
2190+
}
2191+
2192+
// Sort entries by time (oldest first) for eviction
2193+
const sortedEntries = Object.entries(metadata.entries).sort(
2194+
([, a], [, b]) => a.time - b.time,
2195+
);
2196+
2197+
// Evict oldest entries to satisfy maxEntries
2198+
while (maxEntries != null && sortedEntries.length > maxEntries) {
2199+
const [evictKey] = sortedEntries.shift()!;
2200+
storage.removeItem(evictKey);
2201+
delete metadata.entries[evictKey];
2202+
}
2203+
2204+
// Evict oldest entries to satisfy maxBytes (always keep at least the newest entry)
2205+
if (maxBytes != null) {
2206+
let totalSize = sortedEntries.reduce((sum, [, e]) => sum + e.size, 0);
2207+
while (totalSize > maxBytes && sortedEntries.length > 1) {
2208+
const [evictKey, evictEntry] = sortedEntries.shift()!;
2209+
totalSize -= evictEntry.size;
2210+
storage.removeItem(evictKey);
2211+
delete metadata.entries[evictKey];
2212+
}
2213+
}
2214+
2215+
storage.setItem(groupStorageKey, JSON.stringify(metadata));
2216+
}
2217+
21112218
purgeStaleCacheEntries(localStorage);
21122219
purgeStaleCacheEntries(sessionStorage);
21132220

src/coalesce-vue/test/api-client.spec.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,6 +1383,185 @@ describe("$makeCaller", () => {
13831383
await delay(1);
13841384
expect(caller2.result).toBe("response2");
13851385
});
1386+
1387+
describe("limit", () => {
1388+
test("maxEntries evicts oldest entries", async () => {
1389+
let callNum = 0;
1390+
AxiosClient.defaults.adapter = () =>
1391+
makeEndpointMock("result" + ++callNum)();
1392+
1393+
const makeCaller = (id: number) => {
1394+
const caller = new PersonApiClient().$makeCaller("item", (c) =>
1395+
c.fullNameAndAge(id),
1396+
);
1397+
caller.useResponseCaching({
1398+
limit: { maxEntries: 2 },
1399+
});
1400+
return caller;
1401+
};
1402+
1403+
// Populate three entries with different ids (different cache keys)
1404+
await makeCaller(1)();
1405+
await makeCaller(2)();
1406+
await makeCaller(3)();
1407+
1408+
// The group should have evicted the oldest (id=1) entry.
1409+
// Make a new caller for id=1 - it should NOT get a cached result.
1410+
const check1 = makeCaller(1);
1411+
check1();
1412+
expect(check1.result).toBe(null);
1413+
1414+
// Make a new caller for id=2 - it SHOULD get a cached result.
1415+
const check2 = makeCaller(2);
1416+
check2();
1417+
expect(check2.result).toBe("result2");
1418+
1419+
// Make a new caller for id=3 - it SHOULD get a cached result.
1420+
const check3 = makeCaller(3);
1421+
check3();
1422+
expect(check3.result).toBe("result3");
1423+
});
1424+
1425+
test("maxBytes evicts oldest entries", async () => {
1426+
let callNum = 0;
1427+
AxiosClient.defaults.adapter = () =>
1428+
makeEndpointMock("result" + ++callNum)();
1429+
1430+
const makeCaller = (id: number) => {
1431+
const caller = new PersonApiClient().$makeCaller("item", (c) =>
1432+
c.fullNameAndAge(id),
1433+
);
1434+
caller.useResponseCaching({
1435+
// Set maxBytes very small so that only one entry fits
1436+
limit: { maxBytes: 1 },
1437+
});
1438+
return caller;
1439+
};
1440+
1441+
await makeCaller(1)();
1442+
await makeCaller(2)();
1443+
1444+
// id=1 should have been evicted because maxBytes is too small for two entries
1445+
const check1 = makeCaller(1);
1446+
check1();
1447+
expect(check1.result).toBe(null);
1448+
1449+
// id=2 should still be cached (most recent, always kept)
1450+
const check2 = makeCaller(2);
1451+
check2();
1452+
expect(check2.result).toBe("result2");
1453+
});
1454+
1455+
test("custom group key groups entries independently", async () => {
1456+
let callNum = 0;
1457+
AxiosClient.defaults.adapter = () =>
1458+
makeEndpointMock("result" + ++callNum)();
1459+
1460+
const makeCaller = (id: number, groupKey: string) => {
1461+
const caller = new PersonApiClient().$makeCaller("item", (c) =>
1462+
c.fullNameAndAge(id),
1463+
);
1464+
caller.useResponseCaching({
1465+
limit: { key: groupKey, maxEntries: 1 },
1466+
});
1467+
return caller;
1468+
};
1469+
1470+
// Populate two separate groups
1471+
await makeCaller(1, "groupA")();
1472+
await makeCaller(2, "groupB")();
1473+
1474+
// Add another entry to groupA, evicting id=1
1475+
await makeCaller(3, "groupA")();
1476+
1477+
// id=1 should be evicted from groupA
1478+
const check1 = makeCaller(1, "groupA");
1479+
check1();
1480+
expect(check1.result).toBe(null);
1481+
1482+
// id=3 should be in groupA
1483+
const check3 = makeCaller(3, "groupA");
1484+
check3();
1485+
expect(check3.result).toBe("result3");
1486+
1487+
// id=2 in groupB should be unaffected
1488+
const check2 = makeCaller(2, "groupB");
1489+
check2();
1490+
expect(check2.result).toBe("result2");
1491+
});
1492+
1493+
test("group key as function", async () => {
1494+
let callNum = 0;
1495+
AxiosClient.defaults.adapter = () =>
1496+
makeEndpointMock("result" + ++callNum)();
1497+
1498+
const makeCaller = (id: number) => {
1499+
const caller = new PersonApiClient().$makeCaller("item", (c) =>
1500+
c.fullNameAndAge(id),
1501+
);
1502+
caller.useResponseCaching({
1503+
limit: {
1504+
key: (_req, defaultKey) => "fn-" + defaultKey.split("?")[0],
1505+
maxEntries: 1,
1506+
},
1507+
});
1508+
return caller;
1509+
};
1510+
1511+
await makeCaller(1)();
1512+
await makeCaller(2)();
1513+
1514+
// id=1 should be evicted (maxEntries=1)
1515+
const check1 = makeCaller(1);
1516+
check1();
1517+
expect(check1.result).toBe(null);
1518+
1519+
// id=2 should still be cached
1520+
const check2 = makeCaller(2);
1521+
check2();
1522+
expect(check2.result).toBe("result2");
1523+
1524+
// Verify the group metadata key uses the function result
1525+
const groupMetaKey = Object.keys(sessionStorage).find((k) =>
1526+
k.startsWith("coalesce:group:fn-"),
1527+
);
1528+
expect(groupMetaKey).toBeTruthy();
1529+
});
1530+
1531+
test("group metadata cleans up stale references", async () => {
1532+
let callNum = 0;
1533+
AxiosClient.defaults.adapter = () =>
1534+
makeEndpointMock("result" + ++callNum)();
1535+
1536+
const makeCaller = (id: number) => {
1537+
const caller = new PersonApiClient().$makeCaller("item", (c) =>
1538+
c.fullNameAndAge(id),
1539+
);
1540+
caller.useResponseCaching({
1541+
maxAgeSeconds: 0.3,
1542+
limit: { maxEntries: 5 },
1543+
});
1544+
return caller;
1545+
};
1546+
1547+
await makeCaller(1)();
1548+
await makeCaller(2)();
1549+
1550+
// Wait for entries to expire
1551+
await delay(400);
1552+
1553+
// Add a new entry - the stale references should be cleaned up
1554+
await makeCaller(3)();
1555+
1556+
// Verify the group metadata only has the new entry
1557+
const groupMetaKey = Object.keys(sessionStorage).find((k) =>
1558+
k.startsWith("coalesce:group:"),
1559+
);
1560+
expect(groupMetaKey).toBeTruthy();
1561+
const parsed = JSON.parse(sessionStorage.getItem(groupMetaKey!)!);
1562+
expect(Object.keys(parsed.entries)).toHaveLength(1);
1563+
});
1564+
});
13861565
});
13871566
});
13881567

0 commit comments

Comments
 (0)