Add lru-cache dependency and export cache management functions

- Added `lru-cache` as a dependency in the access-controls package.
- Exported new cache management functions from `chats-cached` module, including `canUserAccessChatCached`, `getCacheStats`, `resetCacheStats`, `clearCache`, `invalidateAccess`, `invalidateUserAccess`, and `invalidateChatAccess`.
This commit is contained in:
dal 2025-07-02 01:15:15 -06:00
parent d44277d1a1
commit 2c92d70dca
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
7 changed files with 559 additions and 3 deletions

View File

@ -2,8 +2,6 @@
push:
branches:
- mastra-braintrust
paths:
- apps/api/**
name: Deploy to mastra-braintrust-api
jobs:
porter-deploy:

View File

@ -24,9 +24,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@buster/database": "workspace:*",
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@buster/database": "workspace:*",
"lru-cache": "^11.1.0",
"zod": "catalog:"
}
}

View File

@ -0,0 +1,106 @@
import { LRUCache } from 'lru-cache';
import { canUserAccessChat } from './chats';
// Cache configuration
const cache = new LRUCache<string, boolean>({
max: 10000, // Maximum 10k entries
ttl: 30 * 1000, // 30 seconds
updateAgeOnGet: true, // Refresh TTL on access
});
// Metrics
let cacheHits = 0;
let cacheMisses = 0;
/**
* Cached version of canUserAccessChat
* Caches the boolean result for userId:chatId for 30 seconds
* TTL is refreshed on each access
*/
export async function canUserAccessChatCached({
userId,
chatId,
}: {
userId: string;
chatId: string;
}): Promise<boolean> {
const cacheKey = `${userId}:${chatId}`;
// Check cache
const cached = cache.get(cacheKey);
if (cached !== undefined) {
cacheHits++;
return cached;
}
// Cache miss - call the original function
cacheMisses++;
const hasAccess = await canUserAccessChat({ userId, chatId });
// Store the boolean result
cache.set(cacheKey, hasAccess);
return hasAccess;
}
/**
* Get cache statistics
*/
export function getCacheStats() {
const total = cacheHits + cacheMisses;
const hitRate = total > 0 ? (cacheHits / total) * 100 : 0;
return {
hits: cacheHits,
misses: cacheMisses,
total,
hitRate: `${hitRate.toFixed(2)}%`,
size: cache.size,
maxSize: cache.max,
};
}
/**
* Clear cache statistics (useful for testing)
*/
export function resetCacheStats() {
cacheHits = 0;
cacheMisses = 0;
}
/**
* Clear the entire cache (useful for testing)
*/
export function clearCache() {
cache.clear();
}
/**
* Invalidate a specific user:chat combination
*/
export function invalidateAccess(userId: string, chatId: string) {
const cacheKey = `${userId}:${chatId}`;
cache.delete(cacheKey);
}
/**
* Invalidate all cached entries for a specific user
*/
export function invalidateUserAccess(userId: string) {
for (const key of cache.keys()) {
if (key.startsWith(`${userId}:`)) {
cache.delete(key);
}
}
}
/**
* Invalidate all cached entries for a specific chat
*/
export function invalidateChatAccess(chatId: string) {
for (const key of cache.keys()) {
if (key.endsWith(`:${chatId}`)) {
cache.delete(key);
}
}
}

View File

@ -19,5 +19,16 @@ export {
export { canUserAccessChat } from './chats';
// Export cached version and cache management functions
export {
canUserAccessChatCached,
getCacheStats,
resetCacheStats,
clearCache,
invalidateAccess,
invalidateUserAccess,
invalidateChatAccess,
} from './chats-cached';
// Export utility functions
export { formatPermissionName, buildAccessQuery } from './utils';

View File

@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { canUserAccessChat } from '../../src/chats';
import {
canUserAccessChatCached,
clearCache,
getCacheStats,
resetCacheStats,
} from '../../src/chats-cached';
// We'll use real function but spy on it to count calls
vi.mock('../../src/chats', async () => {
const actual = await vi.importActual<typeof import('../../src/chats')>('../../src/chats');
return {
canUserAccessChat: vi.fn(actual.canUserAccessChat),
};
});
describe('canUserAccessChatCached Integration Tests', () => {
let spiedCanUserAccessChat: any;
beforeEach(async () => {
vi.clearAllMocks();
clearCache();
resetCacheStats();
const chatsModule = await import('../../src/chats');
spiedCanUserAccessChat = vi.mocked(chatsModule.canUserAccessChat);
});
afterEach(() => {
vi.useRealTimers();
});
test('should cache results and reduce database calls', async () => {
// Use non-existent IDs so we get consistent false results
const userId = '00000000-0000-0000-0000-000000000001';
const chatId = '00000000-0000-0000-0000-000000000002';
// First call - should hit database
const result1 = await canUserAccessChatCached({ userId, chatId });
expect(result1).toBe(false);
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Make 10 more calls - all should hit cache
for (let i = 0; i < 10; i++) {
const result = await canUserAccessChatCached({ userId, chatId });
expect(result).toBe(false);
}
// Should still only have called database once
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Check stats
const stats = getCacheStats();
expect(stats.hits).toBe(10);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe('90.91%');
});
test('should handle multiple concurrent requests efficiently', async () => {
const userId = '00000000-0000-0000-0000-000000000003';
const chatId = '00000000-0000-0000-0000-000000000004';
// Make 5 concurrent requests
const promises = Array(5)
.fill(null)
.map(() => canUserAccessChatCached({ userId, chatId }));
const results = await Promise.all(promises);
// All should return false
for (const result of results) {
expect(result).toBe(false);
}
// Should only call database once even with concurrent requests
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Stats might vary due to race conditions, but we should have some hits
const stats = getCacheStats();
expect(stats.total).toBe(5);
expect(stats.misses).toBeGreaterThanOrEqual(1);
expect(stats.hits).toBeLessThanOrEqual(4);
});
test('should simulate high-frequency burst scenario', async () => {
// Simulate 100 requests across 3 users and 3 chats
const users = [
'10000000-0000-0000-0000-000000000001',
'20000000-0000-0000-0000-000000000002',
'30000000-0000-0000-0000-000000000003',
];
const chats = [
'40000000-0000-0000-0000-000000000001',
'50000000-0000-0000-0000-000000000002',
'60000000-0000-0000-0000-000000000003',
];
const startTime = Date.now();
let totalRequests = 0;
// Simulate requests
for (let i = 0; i < 100; i++) {
const userId = users[i % users.length];
const chatId = chats[Math.floor(i / 33) % chats.length];
await canUserAccessChatCached({ userId, chatId });
totalRequests++;
}
const duration = Date.now() - startTime;
// Should complete quickly due to caching
expect(duration).toBeLessThan(500); // 500ms for 100 requests
// Check cache effectiveness
const stats = getCacheStats();
expect(stats.total).toBe(100);
// Should have at most 9 unique combinations (3 users × 3 chats)
expect(spiedCanUserAccessChat.mock.calls.length).toBeLessThanOrEqual(9);
// Cache hit rate should be high
const hitRate = Number.parseFloat(stats.hitRate);
expect(hitRate).toBeGreaterThan(80); // At least 80% hit rate
});
test('should expire cache entries after TTL', async () => {
// This test verifies TTL behavior by using fake timers
vi.useFakeTimers();
const userId = '00000000-0000-0000-0000-000000000005';
const chatId = '00000000-0000-0000-0000-000000000006';
// Initial call
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Call again immediately - should use cache
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Advance time by 20 seconds - should still use cache
vi.advanceTimersByTime(20 * 1000);
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// Advance time to just over 30 seconds total - cache should expire
vi.advanceTimersByTime(11 * 1000); // Total: 31 seconds
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(2);
// Next call should use new cache entry
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(2);
});
test('should refresh TTL on access', async () => {
// This test verifies that TTL is refreshed when updateAgeOnGet is true
vi.useFakeTimers();
const userId = '00000000-0000-0000-0000-000000000007';
const chatId = '00000000-0000-0000-0000-000000000008';
// Initial call
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1);
// After 25 seconds, access the cache
vi.advanceTimersByTime(25 * 1000);
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1); // Still cached
// After another 25 seconds (total 50 seconds), should still be cached
// because the TTL was refreshed at 25 seconds
vi.advanceTimersByTime(25 * 1000);
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(1); // Still cached
// After another 31 seconds (total 81 seconds), should expire
// because last access was at 50 seconds, so 50 + 31 > 30 second TTL
vi.advanceTimersByTime(31 * 1000);
await canUserAccessChatCached({ userId, chatId });
expect(spiedCanUserAccessChat).toHaveBeenCalledTimes(2); // Expired, fetched again
});
});

View File

@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
canUserAccessChatCached,
getCacheStats,
resetCacheStats,
clearCache,
invalidateAccess,
invalidateUserAccess,
invalidateChatAccess,
} from '../../src/chats-cached';
// Mock the original canUserAccessChat function
vi.mock('../../src/chats', () => ({
canUserAccessChat: vi.fn(),
}));
describe('canUserAccessChatCached', () => {
let mockCanUserAccessChat: any;
beforeEach(async () => {
vi.clearAllMocks();
clearCache();
resetCacheStats();
// Get the mocked function
const chatsModule = await import('../../src/chats');
mockCanUserAccessChat = vi.mocked(chatsModule.canUserAccessChat);
});
it('should return cached result on second call', async () => {
// Setup
mockCanUserAccessChat.mockResolvedValue(true);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// First call - should hit database
const result1 = await canUserAccessChatCached({ userId, chatId });
expect(result1).toBe(true);
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1);
// Second call - should hit cache
const result2 = await canUserAccessChatCached({ userId, chatId });
expect(result2).toBe(true);
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1); // Still 1, not called again
// Check cache stats
const stats = getCacheStats();
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe('50.00%');
});
it('should cache false results', async () => {
mockCanUserAccessChat.mockResolvedValue(false);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// First call
const result1 = await canUserAccessChatCached({ userId, chatId });
expect(result1).toBe(false);
// Second call - should use cache
const result2 = await canUserAccessChatCached({ userId, chatId });
expect(result2).toBe(false);
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1);
});
it('should handle different user/chat combinations independently', async () => {
const user1 = '123e4567-e89b-12d3-a456-426614174000';
const user2 = '323e4567-e89b-12d3-a456-426614174000';
const chat1 = '423e4567-e89b-12d3-a456-426614174000';
const chat2 = '523e4567-e89b-12d3-a456-426614174000';
// Setup different responses
mockCanUserAccessChat
.mockResolvedValueOnce(true) // user1:chat1
.mockResolvedValueOnce(false) // user1:chat2
.mockResolvedValueOnce(false) // user2:chat1
.mockResolvedValueOnce(true); // user2:chat2
// Make calls
expect(await canUserAccessChatCached({ userId: user1, chatId: chat1 })).toBe(true);
expect(await canUserAccessChatCached({ userId: user1, chatId: chat2 })).toBe(false);
expect(await canUserAccessChatCached({ userId: user2, chatId: chat1 })).toBe(false);
expect(await canUserAccessChatCached({ userId: user2, chatId: chat2 })).toBe(true);
// All should be cached now
expect(await canUserAccessChatCached({ userId: user1, chatId: chat1 })).toBe(true);
expect(await canUserAccessChatCached({ userId: user1, chatId: chat2 })).toBe(false);
expect(await canUserAccessChatCached({ userId: user2, chatId: chat1 })).toBe(false);
expect(await canUserAccessChatCached({ userId: user2, chatId: chat2 })).toBe(true);
// Should have called original function 4 times (once per unique combination)
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(4);
});
it('should invalidate specific user:chat combination', async () => {
mockCanUserAccessChat.mockResolvedValue(true);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// Cache the result
await canUserAccessChatCached({ userId, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1);
// Use cached result
await canUserAccessChatCached({ userId, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1);
// Invalidate
invalidateAccess(userId, chatId);
// Should call database again
await canUserAccessChatCached({ userId, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(2);
});
it('should invalidate all entries for a user', async () => {
mockCanUserAccessChat.mockResolvedValue(true);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chat1 = '223e4567-e89b-12d3-a456-426614174000';
const chat2 = '323e4567-e89b-12d3-a456-426614174000';
const otherUserId = '423e4567-e89b-12d3-a456-426614174000';
// Cache results for multiple chats
await canUserAccessChatCached({ userId, chatId: chat1 });
await canUserAccessChatCached({ userId, chatId: chat2 });
await canUserAccessChatCached({ userId: otherUserId, chatId: chat1 });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(3);
// Use cached results
await canUserAccessChatCached({ userId, chatId: chat1 });
await canUserAccessChatCached({ userId, chatId: chat2 });
await canUserAccessChatCached({ userId: otherUserId, chatId: chat1 });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(3); // Still 3
// Invalidate all entries for userId
invalidateUserAccess(userId);
// Should call database for invalidated user
await canUserAccessChatCached({ userId, chatId: chat1 });
await canUserAccessChatCached({ userId, chatId: chat2 });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(5); // +2
// Other user should still be cached
await canUserAccessChatCached({ userId: otherUserId, chatId: chat1 });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(5); // Still 5
});
it('should invalidate all entries for a chat', async () => {
mockCanUserAccessChat.mockResolvedValue(true);
const user1 = '123e4567-e89b-12d3-a456-426614174000';
const user2 = '223e4567-e89b-12d3-a456-426614174000';
const chatId = '323e4567-e89b-12d3-a456-426614174000';
const otherChatId = '423e4567-e89b-12d3-a456-426614174000';
// Cache results for multiple users
await canUserAccessChatCached({ userId: user1, chatId });
await canUserAccessChatCached({ userId: user2, chatId });
await canUserAccessChatCached({ userId: user1, chatId: otherChatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(3);
// Invalidate all entries for chatId
invalidateChatAccess(chatId);
// Should call database for invalidated chat
await canUserAccessChatCached({ userId: user1, chatId });
await canUserAccessChatCached({ userId: user2, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(5); // +2
// Other chat should still be cached
await canUserAccessChatCached({ userId: user1, chatId: otherChatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(5); // Still 5
});
it('should clear entire cache', async () => {
mockCanUserAccessChat.mockResolvedValue(true);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// Cache a result
await canUserAccessChatCached({ userId, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(1);
// Clear cache
clearCache();
// Should call database again
await canUserAccessChatCached({ userId, chatId });
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(2);
});
it('should track cache statistics correctly', async () => {
mockCanUserAccessChat.mockResolvedValue(true);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// Initial stats
let stats = getCacheStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.total).toBe(0);
expect(stats.hitRate).toBe('0.00%');
expect(stats.size).toBe(0);
// First call - miss
await canUserAccessChatCached({ userId, chatId });
stats = getCacheStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe('0.00%');
expect(stats.size).toBe(1);
// Second call - hit
await canUserAccessChatCached({ userId, chatId });
stats = getCacheStats();
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe('50.00%');
// Third call - hit
await canUserAccessChatCached({ userId, chatId });
stats = getCacheStats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe('66.67%');
// Reset stats
resetCacheStats();
stats = getCacheStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.size).toBe(1); // Cache still has entries
});
it('should handle errors from the original function', async () => {
const error = new Error('Database error');
mockCanUserAccessChat.mockRejectedValue(error);
const userId = '123e4567-e89b-12d3-a456-426614174000';
const chatId = '223e4567-e89b-12d3-a456-426614174000';
// Should propagate the error
await expect(canUserAccessChatCached({ userId, chatId })).rejects.toThrow('Database error');
// Should not cache errors
mockCanUserAccessChat.mockResolvedValue(true);
const result = await canUserAccessChatCached({ userId, chatId });
expect(result).toBe(true);
expect(mockCanUserAccessChat).toHaveBeenCalledTimes(2); // Called again, error wasn't cached
});
});

View File

@ -606,6 +606,9 @@ importers:
'@buster/vitest-config':
specifier: workspace:*
version: link:../vitest-config
lru-cache:
specifier: ^11.1.0
version: 11.1.0
zod:
specifier: 'catalog:'
version: 3.25.67