feat(gateway): SessionGCService three-tier GC + /gc command + cron (P8-014)
Implements three-tier garbage collection for agent sessions: - SessionGCService.collect() for immediate per-session cleanup on destroySession() - SessionGCService.sweepOrphans() for daily cron sweep of orphaned Valkey keys - SessionGCService.fullCollect() for cold-start aggressive cleanup via OnModuleInit - /gc slash command wired into CommandExecutorService + registered in CommandRegistryService - SESSION_GC_CRON (daily 4am) added to CronService - GCModule provides Valkey (ioredis via @mosaic/queue) and is imported by AgentModule, LogModule, CommandsModule, AppModule - 8 Vitest unit tests covering all three GC tiers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
|
||||
type MockRedis = {
|
||||
keys: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('SessionGCService', () => {
|
||||
let service: SessionGCService;
|
||||
let mockRedis: MockRedis;
|
||||
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
del: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
mockLogService = {
|
||||
logs: {
|
||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
|
||||
// Suppress logger output in tests
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
||||
|
||||
service = new SessionGCService(
|
||||
mockRedis as unknown as QueueHandle['redis'],
|
||||
mockLogService as unknown as LogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() deletes Valkey keys for session', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
|
||||
const result = await service.collect('abc');
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:foo',
|
||||
);
|
||||
expect(result.cleaned.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('collect() with no keys returns empty cleaned valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.collect('abc');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
});
|
||||
|
||||
it('fullCollect() deletes all session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||
const result = await service.fullCollect();
|
||||
expect(mockRedis.del).toHaveBeenCalled();
|
||||
expect(result.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.fullCollect();
|
||||
expect(result.valkeyKeys).toBe(0);
|
||||
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fullCollect() returns duration', async () => {
|
||||
const result = await service.fullCollect();
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||
mockRedis.keys.mockResolvedValue([
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:messages',
|
||||
'mosaic:session:xyz:system',
|
||||
]);
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBe(0);
|
||||
expect(result.totalCleaned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user