Skip to content

Commit 8a61db1

Browse files
committed
Fixed pongo transaction cache handling
1 parent 4289a8d commit 8a61db1

8 files changed

Lines changed: 478 additions & 51 deletions

File tree

src/packages/pongo/src/core/cache/collectionCache.int.spec.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,267 @@ describe('pongoCollection cache integration', () => {
231231
expect(doc?.name).toBe('Karl');
232232
});
233233
});
234+
235+
describe('cache type resolution cascade', () => {
236+
it('collection identity-map overrides client in-memory', async () => {
237+
client = pongoClient({
238+
driver: sqlite3Driver,
239+
connectionString: memoryConnectionString(),
240+
cache: { type: 'in-memory', max: 5 },
241+
});
242+
await client.connect();
243+
244+
const col = client
245+
.db('db')
246+
.collection<User>('users', { cache: { type: 'identity-map' } });
247+
248+
const ids: string[] = [];
249+
for (let i = 0; i < 10; i++) {
250+
const { insertedId } = await col.insertOne({ name: `User${i}` });
251+
ids.push(insertedId!);
252+
}
253+
254+
for (const id of ids) {
255+
const doc = await col.findOne({ _id: id });
256+
expect(doc).not.toBeNull();
257+
}
258+
});
259+
260+
it('client identity-map is used by collection with no override', async () => {
261+
client = pongoClient({
262+
driver: sqlite3Driver,
263+
connectionString: memoryConnectionString(),
264+
cache: { type: 'identity-map' },
265+
});
266+
await client.connect();
267+
268+
const col = client.db('db').collection<User>('users');
269+
const ids: string[] = [];
270+
for (let i = 0; i < 2000; i++) {
271+
const { insertedId } = await col.insertOne({ name: `User${i}` });
272+
ids.push(insertedId!);
273+
}
274+
275+
for (const id of ids) {
276+
const doc = await col.findOne({ _id: id });
277+
expect(doc).not.toBeNull();
278+
}
279+
});
280+
281+
it('collection disabled overrides client identity-map', async () => {
282+
client = pongoClient({
283+
driver: sqlite3Driver,
284+
connectionString: memoryConnectionString(),
285+
cache: { type: 'identity-map' },
286+
});
287+
await client.connect();
288+
289+
const db = client.db('db');
290+
const col = db.collection<User>('users', { cache: 'disabled' });
291+
const noCache = db.collection<User>('users', { cache: 'disabled' });
292+
293+
const { insertedId } = await col.insertOne({ name: 'Leo' });
294+
await noCache.updateOne(
295+
{ _id: insertedId! },
296+
{ $set: { name: 'LeoUpdated' } },
297+
);
298+
299+
const doc = await col.findOne({ _id: insertedId! });
300+
expect(doc?.name).toBe('LeoUpdated');
301+
});
302+
303+
it('two collections can use different cache types on the same db', async () => {
304+
client = pongoClient({
305+
driver: sqlite3Driver,
306+
connectionString: memoryConnectionString(),
307+
});
308+
await client.connect();
309+
310+
const db = client.db('db');
311+
const lruCol = db.collection<User>('users', {
312+
cache: { type: 'in-memory', max: 5 },
313+
});
314+
const idMapCol = db.collection<User>('orders', {
315+
cache: { type: 'identity-map' },
316+
});
317+
318+
const lruIds: string[] = [];
319+
const idMapIds: string[] = [];
320+
321+
for (let i = 0; i < 10; i++) {
322+
const { insertedId: u } = await lruCol.insertOne({ name: `User${i}` });
323+
lruIds.push(u!);
324+
const { insertedId: o } = await idMapCol.insertOne({
325+
name: `Order${i}`,
326+
});
327+
idMapIds.push(o!);
328+
}
329+
330+
// LRU with max 5: oldest 5 should be evicted from cache (reads go to DB)
331+
// identity-map: all 10 present in cache
332+
for (const id of idMapIds) {
333+
const doc = await idMapCol.findOne({ _id: id });
334+
expect(doc).not.toBeNull();
335+
}
336+
337+
// At least the most-recent LRU entries should still be in cache
338+
const recent = lruIds.slice(-5);
339+
for (const id of recent) {
340+
const doc = await lruCol.findOne({ _id: id });
341+
expect(doc).not.toBeNull();
342+
}
343+
});
344+
});
345+
346+
describe('transaction cache integration', () => {
347+
beforeEach(async () => {
348+
client = pongoClient({
349+
driver: sqlite3Driver,
350+
connectionString: memoryConnectionString(),
351+
});
352+
await client.connect();
353+
});
354+
355+
it('insertOne within transaction does NOT populate collection cache until commit', async () => {
356+
const { cache, spies } = spyCache('col');
357+
const col = client.db('db').collection<User>('users', { cache });
358+
359+
const session = client.startSession();
360+
session.startTransaction();
361+
await col.insertOne({ name: 'Alice' }, { session });
362+
363+
expect(spies.set).not.toHaveBeenCalled();
364+
365+
await session.commitTransaction();
366+
await session.endSession();
367+
368+
expect(spies.set).toHaveBeenCalled();
369+
});
370+
371+
it('findOne within transaction returns doc from transaction cache', async () => {
372+
const { cache, spies } = spyCache('col');
373+
const col = client.db('db').collection<User>('users', { cache });
374+
375+
const session = client.startSession();
376+
session.startTransaction();
377+
const { insertedId } = await col.insertOne(
378+
{ name: 'Carol' },
379+
{ session },
380+
);
381+
382+
const doc = await col.findOne({ _id: insertedId! }, { session });
383+
expect(doc?.name).toBe('Carol');
384+
// should have hit tx cache, not collection cache get
385+
expect(spies.get).not.toHaveBeenCalled();
386+
387+
await session.abortTransaction();
388+
await session.endSession();
389+
});
390+
391+
it('findOne within transaction falls through to collection cache on tx miss', async () => {
392+
const { cache } = spyCache('col');
393+
const col = client.db('db').collection<User>('users', { cache });
394+
395+
const { insertedId } = await col.insertOne({ name: 'Dave' });
396+
397+
const session = client.startSession();
398+
session.startTransaction();
399+
const doc = await col.findOne({ _id: insertedId! }, { session });
400+
expect(doc?.name).toBe('Dave');
401+
402+
await session.abortTransaction();
403+
await session.endSession();
404+
});
405+
406+
it('rollback discards buffered cache ops — collection cache stays clean', async () => {
407+
const { cache, spies } = spyCache('col');
408+
const col = client.db('db').collection<User>('users', { cache });
409+
410+
const session = client.startSession();
411+
session.startTransaction();
412+
await col.insertOne({ name: 'Bob' }, { session });
413+
await session.abortTransaction();
414+
await session.endSession();
415+
416+
expect(spies.set).not.toHaveBeenCalled();
417+
});
418+
419+
it('deleteOne within transaction does NOT evict from collection cache until commit', async () => {
420+
const { cache, spies } = spyCache('col');
421+
const col = client.db('db').collection<User>('users', { cache });
422+
423+
const { insertedId } = await col.insertOne({ name: 'Eve' });
424+
spies.delete.mockClear();
425+
426+
const session = client.startSession();
427+
session.startTransaction();
428+
await col.deleteOne({ _id: insertedId! }, { session });
429+
430+
expect(spies.delete).not.toHaveBeenCalled();
431+
432+
await session.commitTransaction();
433+
await session.endSession();
434+
435+
expect(spies.delete).toHaveBeenCalled();
436+
});
437+
438+
it('updateOne within transaction does NOT evict from collection cache until commit', async () => {
439+
const { cache, spies } = spyCache('col');
440+
const col = client.db('db').collection<User>('users', { cache });
441+
442+
const { insertedId } = await col.insertOne({ name: 'Frank' });
443+
spies.delete.mockClear();
444+
445+
const session = client.startSession();
446+
session.startTransaction();
447+
await col.updateOne(
448+
{ _id: insertedId! },
449+
{ $set: { name: 'Updated' } },
450+
{ session },
451+
);
452+
453+
expect(spies.delete).not.toHaveBeenCalled();
454+
455+
// reading without session should still return cached 'Frank'
456+
const doc = await col.findOne({ _id: insertedId! });
457+
expect(doc?.name).toBe('Frank');
458+
459+
await session.commitTransaction();
460+
await session.endSession();
461+
462+
expect(spies.delete).toHaveBeenCalled();
463+
});
464+
465+
it('replaceOne within transaction buffers cache set until commit', async () => {
466+
const { cache, spies } = spyCache('col');
467+
const col = client.db('db').collection<User>('users', { cache });
468+
469+
const { insertedId } = await col.insertOne({ name: 'Grace' });
470+
spies.set.mockClear();
471+
472+
const session = client.startSession();
473+
session.startTransaction();
474+
await col.replaceOne(
475+
{ _id: insertedId! },
476+
{ name: 'Replaced' },
477+
{ session },
478+
);
479+
480+
expect(spies.set).not.toHaveBeenCalled();
481+
482+
await session.commitTransaction();
483+
await session.endSession();
484+
485+
expect(spies.set).toHaveBeenCalled();
486+
});
487+
488+
it('without transaction, operations update collection cache directly', async () => {
489+
const { cache, spies } = spyCache('col');
490+
const col = client.db('db').collection<User>('users', { cache });
491+
492+
await col.insertOne({ name: 'Hank' });
493+
494+
expect(spies.set).toHaveBeenCalled();
495+
});
496+
});
234497
});

src/packages/pongo/src/core/cache/pongoCache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { pongoCacheWrapper } from './cacheWrapper';
2-
import { noopCacheProvider, lruCache } from './providers';
2+
import { noopCacheProvider, lruCache, identityMapCache } from './providers';
33
import type { CacheConfig, CacheSettings, PongoCache } from './types';
44

55
const DEFAULT_CONFIG: CacheSettings = { type: 'in-memory', max: 1000 };
@@ -14,6 +14,8 @@ export const pongoCache = (
1414

1515
const config = options ?? DEFAULT_CONFIG;
1616

17+
if (config.type === 'identity-map') return identityMapCache();
18+
1719
const raw = lruCache({
1820
...(config.max !== undefined ? { max: config.max } : {}),
1921
...(config.ttl !== undefined ? { ttl: config.ttl } : {}),
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { identityMapCache } from './providers';
3+
import { pongoCache } from './pongoCache';
4+
5+
describe('pongoCache factory', () => {
6+
describe("'identity-map' type", () => {
7+
it('creates an identity-map cache when type is identity-map', async () => {
8+
const cache = pongoCache({ type: 'identity-map' });
9+
10+
expect(cache.cacheType).toBe('pongo:cache:identity-map');
11+
12+
await cache.set('db:col:1', { _id: '1', name: 'Alice' });
13+
const result = await cache.get('db:col:1');
14+
expect(result).toEqual({ _id: '1', name: 'Alice' });
15+
});
16+
17+
it('identity-map cache has no max size eviction', async () => {
18+
const cache = pongoCache({ type: 'identity-map' });
19+
const count = 2000;
20+
21+
for (let i = 0; i < count; i++) {
22+
await cache.set(`db:col:${i}`, { _id: String(i), n: i });
23+
}
24+
25+
for (let i = 0; i < count; i++) {
26+
const doc = await cache.get(`db:col:${i}`);
27+
expect(doc).not.toBeNull();
28+
}
29+
});
30+
});
31+
32+
describe("'in-memory' type", () => {
33+
it('creates an LRU cache when type is in-memory', () => {
34+
const cache = pongoCache({ type: 'in-memory', max: 100 });
35+
expect(cache.cacheType).toBe('pongo:cache:lru');
36+
});
37+
38+
it('defaults to in-memory when no config provided', async () => {
39+
const cache = pongoCache(undefined);
40+
41+
await cache.set('db:col:1', { _id: '1', name: 'Bob' });
42+
const result = await cache.get('db:col:1');
43+
expect(result).toEqual({ _id: '1', name: 'Bob' });
44+
});
45+
46+
it('in-memory LRU evicts when over max', async () => {
47+
const cache = pongoCache({ type: 'in-memory', max: 3 });
48+
49+
for (let i = 0; i < 4; i++) {
50+
await cache.set(`db:col:${i}`, { _id: String(i) });
51+
}
52+
53+
// Oldest entry should be evicted
54+
const first = await cache.get('db:col:0');
55+
expect(first).toBeNull();
56+
});
57+
});
58+
59+
describe("'disabled'", () => {
60+
it('returns noop cache when disabled', async () => {
61+
const cache = pongoCache('disabled');
62+
63+
await cache.set('db:col:1', { _id: '1', name: 'Carol' });
64+
const result = await cache.get('db:col:1');
65+
expect(result).toBeNull();
66+
});
67+
});
68+
69+
describe('pre-built PongoCache passthrough', () => {
70+
it('passes through a pre-built PongoCache instance', () => {
71+
const raw = identityMapCache();
72+
const cache = pongoCache(raw);
73+
expect(cache).toBe(raw);
74+
});
75+
});
76+
});

src/packages/pongo/src/core/cache/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export type CacheHooks = {
4545
onError?(error: unknown, operation: string): void;
4646
};
4747

48-
export type CacheType = 'in-memory';
48+
export type CacheType = 'in-memory' | 'identity-map';
4949

5050
export type CacheSettings = {
5151
type: CacheType;

src/packages/pongo/src/core/collection/filters/filters.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ export const idFromFilter = <T>(
2121
return typeof f['_id'] === 'string' ? f['_id'] : null;
2222
};
2323

24-
export const idsFromInFilter = <T>(
24+
export const getIdsFromIdOnlyFilter = <T>(
2525
filter: PongoFilter<T> | SQL | undefined,
2626
): string[] | null => {
2727
const f = asPlainObjectWithSingleKey(filter, '_id');
2828
if (!f) return null;
2929
const idVal = f['_id'];
30+
if (typeof idVal === 'string') return [idVal];
3031
if (!idVal || typeof idVal !== 'object' || !('$in' in idVal)) return null;
3132
const ids = (idVal as PlainObject)['$in'];
3233
if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string'))

0 commit comments

Comments
 (0)