fix modeltester attachment replay and add proxy file retention

This commit is contained in:
cita
2026-03-21 00:04:06 +08:00
parent 94b6cac871
commit 95bf8df499
11 changed files with 676 additions and 43 deletions
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../db/index.js');
type StoreModule = typeof import('./proxyFileStore.js');
type RetentionModule = typeof import('./proxyFileRetentionService.js');
type ConfigModule = typeof import('../config.js');
describe('proxyFileRetentionService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let store: StoreModule;
let retention: RetentionModule;
let config: ConfigModule['config'];
let dataDir = '';
let originalRetentionDays = 0;
let originalPruneIntervalMinutes = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const storeModule = await import('./proxyFileStore.js');
const retentionModule = await import('./proxyFileRetentionService.js');
const configModule = await import('../config.js');
db = dbModule.db;
schema = dbModule.schema;
store = storeModule;
retention = retentionModule;
config = configModule.config;
originalRetentionDays = config.proxyFileRetentionDays;
originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes;
});
beforeEach(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
await db.delete(schema.proxyFiles).run();
});
afterAll(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
const dbModule = await import('../db/index.js');
await dbModule.closeDbConnections();
delete process.env.DATA_DIR;
});
it('returns disabled when proxy file retention is turned off', async () => {
config.proxyFileRetentionDays = 0;
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: false,
retentionDays: 0,
cutoffUtc: null,
deleted: 0,
});
});
it('purges files older than the configured retention window', async () => {
config.proxyFileRetentionDays = 7;
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-10 00:00:00',
updatedAt: '2026-03-10 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-18 00:00:00',
updatedAt: '2026-03-18 00:00:00',
deletedAt: null,
},
]).run();
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: true,
retentionDays: 7,
cutoffUtc: '2026-03-13 00:00:00',
deleted: 1,
});
const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']);
});
});
@@ -0,0 +1,63 @@
import { config } from '../config.js';
import { getLogCleanupCutoffUtc } from './logCleanupService.js';
import { purgeExpiredProxyFiles } from './proxyFileStore.js';
let retentionTimer: ReturnType<typeof setInterval> | null = null;
export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null {
const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
if (days <= 0) return null;
return getLogCleanupCutoffUtc(days, nowMs);
}
export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{
enabled: boolean;
retentionDays: number;
cutoffUtc: string | null;
deleted: number;
}> {
const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs);
if (!cutoffUtc) {
return {
enabled: false,
retentionDays,
cutoffUtc: null,
deleted: 0,
};
}
const deleted = await purgeExpiredProxyFiles(cutoffUtc);
return {
enabled: true,
retentionDays,
cutoffUtc,
deleted,
};
}
export function startProxyFileRetentionService(): void {
if (retentionTimer) return;
const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes));
const intervalMs = intervalMinutes * 60 * 1000;
const runCleanup = async () => {
try {
const result = await cleanupExpiredProxyFiles();
if (!result.enabled || result.deleted <= 0) return;
console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`);
} catch (error) {
console.warn('[proxy-file-retention] cleanup failed', error);
}
};
void runCleanup();
retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs);
retentionTimer.unref?.();
}
export function stopProxyFileRetentionService(): void {
if (!retentionTimer) return;
clearInterval(retentionTimer);
retentionTimer = null;
}
@@ -112,4 +112,45 @@ describe('proxyFileStore', () => {
});
expect(loaded).toBeNull();
});
it('purges expired proxy files before a cutoff to control database growth', async () => {
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-01 00:00:00',
updatedAt: '2026-03-01 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-19 00:00:00',
updatedAt: '2026-03-19 00:00:00',
deletedAt: null,
},
]).run();
const purgeExpiredProxyFiles = (store as Record<string, any>).purgeExpiredProxyFiles;
const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00');
expect(deleted).toBe(1);
const remainingRows = await db.select().from(schema.proxyFiles).all();
expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']);
});
});
+15 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, isNull, or } from 'drizzle-orm';
import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { db, schema } from '../db/index.js';
import type { ProxyResourceOwner } from '../middleware/auth.js';
@@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
.run();
return Number(result?.changes || 0) > 0;
}
export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise<number> {
const normalizedCutoff = cutoffUtc.trim();
if (!normalizedCutoff) return 0;
const result = await db.delete(schema.proxyFiles)
.where(or(
lt(schema.proxyFiles.createdAt, normalizedCutoff),
and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)),
))
.run();
return Number(result?.changes || 0);
}