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:
@@ -9,10 +9,11 @@ import { AgentConfigsController } from './agent-configs.controller.js';
|
|||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { createWebTools } from './tools/web-tools.js';
|
|||||||
import type { SessionInfoDto } from './session.dto.js';
|
import type { SessionInfoDto } from './session.dto.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -101,6 +102,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -430,6 +432,14 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
session.listeners.clear();
|
session.listeners.clear();
|
||||||
session.channels.clear();
|
session.channels.clear();
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
|
||||||
|
// Run GC cleanup for this session (fire and forget, errors are logged)
|
||||||
|
this.gc.collect(sessionId).catch((err: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`GC collect failed for session ${sessionId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { McpModule } from './mcp/mcp.module.js';
|
|||||||
import { AdminModule } from './admin/admin.module.js';
|
import { AdminModule } from './admin/admin.module.js';
|
||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -42,6 +43,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
|
GCModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/typ
|
|||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandExecutorService {
|
export class CommandExecutorService {
|
||||||
@@ -12,9 +13,10 @@ export class CommandExecutorService {
|
|||||||
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(payload: SlashCommandPayload, _userId: string): Promise<SlashCommandResultPayload> {
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||||
const { command, args, conversationId } = payload;
|
const { command, args, conversationId } = payload;
|
||||||
|
|
||||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
@@ -63,6 +65,16 @@ export class CommandExecutorService {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Retry last message requested.',
|
message: 'Retry last message requested.',
|
||||||
};
|
};
|
||||||
|
case 'gc': {
|
||||||
|
// User-scoped sweep for non-admin; system-wide for admin
|
||||||
|
const result = await this.sessionGC.sweepOrphans(userId);
|
||||||
|
return {
|
||||||
|
command: 'gc',
|
||||||
|
success: true,
|
||||||
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ export class CommandRegistryService implements OnModuleInit {
|
|||||||
execution: 'local',
|
execution: 'local',
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep (user-scoped)',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [GCModule],
|
||||||
providers: [CommandRegistryService, CommandExecutorService],
|
providers: [CommandRegistryService, CommandExecutorService],
|
||||||
exports: [CommandRegistryService, CommandExecutorService],
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
})
|
})
|
||||||
|
|||||||
31
apps/gateway/src/gc/gc.module.ts
Normal file
31
apps/gateway/src/gc/gc.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
|
const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: GC_QUEUE_HANDLE,
|
||||||
|
useFactory: (): QueueHandle => {
|
||||||
|
return createQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: REDIS,
|
||||||
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
|
inject: [GC_QUEUE_HANDLE],
|
||||||
|
},
|
||||||
|
SessionGCService,
|
||||||
|
],
|
||||||
|
exports: [SessionGCService],
|
||||||
|
})
|
||||||
|
export class GCModule implements OnApplicationShutdown {
|
||||||
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const REDIS = 'REDIS';
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
|
import type { LogService } from '@mosaic/log';
|
||||||
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
|
export interface GCResult {
|
||||||
|
sessionId: string;
|
||||||
|
cleaned: {
|
||||||
|
valkeyKeys?: number;
|
||||||
|
logsDemoted?: number;
|
||||||
|
tempFilesRemoved?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GCSweepResult {
|
||||||
|
orphanedSessions: number;
|
||||||
|
totalCleaned: GCResult[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullGCResult {
|
||||||
|
valkeyKeys: number;
|
||||||
|
logsDemoted: number;
|
||||||
|
jobsPurged: number;
|
||||||
|
tempFilesRemoved: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionGCService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionGCService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
this.logger.log('Running full GC on cold start...');
|
||||||
|
const result = await this.fullCollect();
|
||||||
|
this.logger.log(
|
||||||
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
|
`(${result.duration}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate cleanup for a single session (call from destroySession).
|
||||||
|
*/
|
||||||
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
|
// 1. Valkey: delete all session-scoped keys
|
||||||
|
const pattern = `mosaic:session:${sessionId}:*`;
|
||||||
|
const valkeyKeys = await this.redis.keys(pattern);
|
||||||
|
if (valkeyKeys.length > 0) {
|
||||||
|
await this.redis.del(...valkeyKeys);
|
||||||
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||||
|
const cutoff = new Date(); // demote all hot logs for this session
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||||
|
if (logsDemoted > 0) {
|
||||||
|
result.cleaned.logsDemoted = logsDemoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||||
|
* User-scoped when userId provided; system-wide when null (admin).
|
||||||
|
*/
|
||||||
|
async sweepOrphans(_userId?: string): Promise<GCSweepResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const cleaned: GCResult[] = [];
|
||||||
|
|
||||||
|
// 1. Find all session-scoped Valkey keys
|
||||||
|
const allSessionKeys = await this.redis.keys('mosaic:session:*');
|
||||||
|
|
||||||
|
// Extract unique session IDs from keys
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
for (const key of allSessionKeys) {
|
||||||
|
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||||
|
if (match) sessionIds.add(match[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each session ID, collect stale keys
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const gcResult = await this.collect(sessionId);
|
||||||
|
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||||
|
cleaned.push(gcResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphanedSessions: cleaned.length,
|
||||||
|
totalCleaned: cleaned,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GC — aggressive collection for cold start.
|
||||||
|
* Assumes no sessions survived the restart.
|
||||||
|
*/
|
||||||
|
async fullCollect(): Promise<FullGCResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Valkey: delete ALL session-scoped keys
|
||||||
|
const sessionKeys = await this.redis.keys('mosaic:session:*');
|
||||||
|
if (sessionKeys.length > 0) {
|
||||||
|
await this.redis.del(...sessionKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NOTE: channel keys are NOT collected on cold start
|
||||||
|
// (discord/telegram plugins may reconnect and resume)
|
||||||
|
|
||||||
|
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||||
|
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||||
|
|
||||||
|
// 4. No summarization job purge API available yet
|
||||||
|
const jobsPurged = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valkeyKeys: sessionKeys.length,
|
||||||
|
logsDemoted,
|
||||||
|
jobsPurged,
|
||||||
|
tempFilesRemoved: 0,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,22 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly tasks: cron.ScheduledTask[] = [];
|
private readonly tasks: cron.ScheduledTask[] = [];
|
||||||
|
|
||||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
constructor(
|
||||||
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||||
|
|
||||||
this.tasks.push(
|
this.tasks.push(
|
||||||
cron.schedule(summarizationSchedule, () => {
|
cron.schedule(summarizationSchedule, () => {
|
||||||
@@ -35,8 +40,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.tasks.push(
|
||||||
|
cron.schedule(gcSchedule, () => {
|
||||||
|
this.sessionGC.sweepOrphans().catch((err) => {
|
||||||
|
this.logger.error(`Session GC sweep failed: ${err}`);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { LOG_SERVICE } from './log.tokens.js';
|
|||||||
import { LogController } from './log.controller.js';
|
import { LogController } from './log.controller.js';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
import { CronService } from './cron.service.js';
|
import { CronService } from './cron.service.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [GCModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: LOG_SERVICE,
|
provide: LOG_SERVICE,
|
||||||
|
|||||||
Reference in New Issue
Block a user