This commit is contained in:
Bastian Wagner
2026-07-17 15:54:26 +02:00
parent 5973658582
commit 90d1561bbc
8 changed files with 278 additions and 1 deletions

View File

@@ -17,6 +17,7 @@
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^2.4.3",

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module';
@@ -19,6 +20,7 @@ import { ApplicationInfoLogModule } from './application-info-log/application-inf
isGlobal: true,
envFilePath: ['.env', '../../.env'],
}),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([
{
ttl: 60_000,

View File

@@ -5,12 +5,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationInfoLog } from './application-info-log.entity';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
import { ApplicationInfoInterceptor } from './application-info.interceptor';
import { ApplicationLogRetentionService } from './application-log-retention.service';
@Global()
@Module({
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationInfoLog])],
providers: [
ApplicationInfoLoggerService,
ApplicationLogRetentionService,
{ provide: APP_INTERCEPTOR, useClass: ApplicationInfoInterceptor },
],
exports: [ApplicationInfoLoggerService],

View File

@@ -0,0 +1,87 @@
import { Logger } from '@nestjs/common';
import { ApplicationLogRetentionService } from './application-log-retention.service';
describe('ApplicationLogRetentionService', () => {
function createService(options: { lock?: boolean; queryError?: Error; enabled?: string } = {}) {
let infoBatch = 0;
const query = jest.fn(async (sql: string, _parameters?: unknown[]) => {
if (options.queryError && sql.startsWith('DELETE')) {
throw options.queryError;
}
if (sql.includes('GET_LOCK')) {
return [{ acquired: options.lock === false ? 0 : 1 }];
}
if (sql.startsWith('DELETE FROM application_info_logs')) {
infoBatch += 1;
return { affectedRows: infoBatch === 1 ? 100 : 10 };
}
if (sql.startsWith('DELETE FROM application_error_logs')) {
return { affectedRows: 5 };
}
return [];
});
const queryRunner = {
connect: jest.fn().mockResolvedValue(undefined),
query,
release: jest.fn().mockResolvedValue(undefined),
isReleased: false,
};
const dataSource = { createQueryRunner: jest.fn().mockReturnValue(queryRunner) };
const values: Record<string, string> = {
APPLICATION_LOG_RETENTION_ENABLED: options.enabled ?? 'true',
APPLICATION_LOG_RETENTION_DAYS: '30',
APPLICATION_LOG_CLEANUP_BATCH_SIZE: '100',
APPLICATION_LOG_CLEANUP_MAX_BATCHES: '3',
};
const config = { get: jest.fn((key: string) => values[key]) };
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const service = new ApplicationLogRetentionService(dataSource as any, config as any, infoLogger as any);
return { service, dataSource, queryRunner, query, infoLogger };
}
it('deletes expired info and error logs in bounded batches', async () => {
const { service, query, queryRunner, infoLogger } = createService();
const before = Date.now() - 30 * 24 * 60 * 60_000;
await service.removeExpiredLogs();
const infoDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_info_logs'));
const errorDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_error_logs'));
expect(infoDeletes).toHaveLength(2);
expect(errorDeletes).toHaveLength(1);
const firstParameters = infoDeletes[0]?.[1];
expect(firstParameters?.[1]).toBe(100);
expect((firstParameters?.[0] as Date).getTime()).toBeGreaterThanOrEqual(before - 1_000);
expect(queryRunner.release).toHaveBeenCalled();
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'maintenance.application_log_cleanup_completed',
context: expect.objectContaining({ infoLogsDeleted: 110, errorLogsDeleted: 5, retentionDays: 30 }),
}));
});
it('skips cleanup when another instance owns the database lock', async () => {
const { service, query, infoLogger } = createService({ lock: false });
await service.removeExpiredLogs();
expect(query.mock.calls.some(([sql]) => sql.startsWith('DELETE'))).toBe(false);
expect(infoLogger.log).not.toHaveBeenCalled();
});
it('can be disabled through configuration', async () => {
const { service, dataSource } = createService({ enabled: 'false' });
await service.removeExpiredLogs();
expect(dataSource.createQueryRunner).not.toHaveBeenCalled();
});
it('does not throw when cleanup persistence fails', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation();
const { service } = createService({ queryError: new Error('database unavailable') });
await expect(service.removeExpiredLogs()).resolves.toBeUndefined();
expect(Logger.prototype.error).toHaveBeenCalled();
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { DataSource, QueryRunner } from 'typeorm';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
interface CleanupResult {
infoLogsDeleted: number;
errorLogsDeleted: number;
}
@Injectable()
export class ApplicationLogRetentionService {
private readonly logger = new Logger(ApplicationLogRetentionService.name);
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly infoLogger: ApplicationInfoLoggerService,
) {}
@Cron('0 15 3 * * *', { name: 'application-log-retention', waitForCompletion: true })
async removeExpiredLogs(): Promise<void> {
if (this.config.get<string>('APPLICATION_LOG_RETENTION_ENABLED') === 'false') {
return;
}
const retentionDays = this.numberConfig('APPLICATION_LOG_RETENTION_DAYS', 180, 1, 3_650);
const batchSize = this.numberConfig('APPLICATION_LOG_CLEANUP_BATCH_SIZE', 1_000, 100, 10_000);
const maxBatches = this.numberConfig('APPLICATION_LOG_CLEANUP_MAX_BATCHES', 20, 1, 1_000);
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60_000);
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
if (!(await this.acquireLock(queryRunner))) {
this.logger.log('Application log cleanup skipped because another instance holds the lock');
return;
}
const result: CleanupResult = {
infoLogsDeleted: await this.deleteBatches(
queryRunner,
'application_info_logs',
cutoff,
batchSize,
maxBatches,
),
errorLogsDeleted: await this.deleteBatches(
queryRunner,
'application_error_logs',
cutoff,
batchSize,
maxBatches,
),
};
this.logger.log(
`Application log cleanup completed: info=${result.infoLogsDeleted}, errors=${result.errorLogsDeleted}, retentionDays=${retentionDays}`,
);
await this.infoLogger.log({
action: 'maintenance.application_log_cleanup_completed',
category: 'MAINTENANCE',
outcome: 'SUCCESS',
actorType: 'SYSTEM',
actorId: 'application-log-retention',
backendModule: 'ApplicationInfoLogModule',
service: ApplicationLogRetentionService.name,
operation: 'removeExpiredLogs',
context: { ...result, retentionDays, cutoff: cutoff.toISOString() },
});
} catch (error) {
this.logger.error(
`Application log cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
} finally {
if (queryRunner.isReleased === false) {
await this.releaseLock(queryRunner);
await queryRunner.release();
}
}
}
private async deleteBatches(
queryRunner: QueryRunner,
tableName: 'application_info_logs' | 'application_error_logs',
cutoff: Date,
batchSize: number,
maxBatches: number,
): Promise<number> {
let deleted = 0;
for (let batch = 0; batch < maxBatches; batch += 1) {
const result = (await queryRunner.query(
`DELETE FROM ${tableName} WHERE createdAt < ? ORDER BY createdAt ASC LIMIT ?`,
[cutoff, batchSize],
)) as { affectedRows?: number };
const affectedRows = Number(result.affectedRows ?? 0);
deleted += affectedRows;
if (affectedRows < batchSize) {
break;
}
}
return deleted;
}
private async acquireLock(queryRunner: QueryRunner): Promise<boolean> {
const rows = (await queryRunner.query(
"SELECT GET_LOCK('ldap_portal_application_log_cleanup', 0) AS acquired",
)) as Array<{ acquired?: number | string }>;
return Number(rows[0]?.acquired) === 1;
}
private async releaseLock(queryRunner: QueryRunner): Promise<void> {
try {
await queryRunner.query("SELECT RELEASE_LOCK('ldap_portal_application_log_cleanup')");
} catch (error) {
this.logger.warn(
`Application log cleanup lock release failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private numberConfig(key: string, fallback: number, minimum: number, maximum: number): number {
const value = Number(this.config.get<string>(key) ?? fallback);
return Number.isInteger(value) && value >= minimum && value <= maximum ? value : fallback;
}
}