fix modeltester attachment replay and add proxy file retention
This commit is contained in:
@@ -110,6 +110,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
|
||||
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
codexHeaderDefaults: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
||||
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
@@ -406,7 +407,9 @@ try {
|
||||
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||
startProxyFileRetentionService();
|
||||
app.addHook('onClose', async () => {
|
||||
stopProxyFileRetentionService();
|
||||
stopProxyLogRetentionService();
|
||||
await stopOAuthLoopbackCallbackServers();
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user