Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m12s
CI / Build container images (pull_request) Successful in 3m28s
CI / Auth e2e pack (pull_request) Successful in 8m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m9s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
The raw input/result bytes of import/export conversion jobs were kept forever; a deleted classified page could live on inside its last export. A new daily conversion-payload-prune job nulls both once a finished (succeeded or failed) job passes conversion.payloadRetentionDays (instance setting, default 30) — the row survives for status/audit. PENDING and RUNNING rows keep their payload, so the worker's stale-lock recovery path is untouched; a hand-requeued pruned job fails finally via conversionInputOf instead of crashing the worker. The input column becomes nullable; the migration backfills by clearing payloads of jobs already finished longer ago than the default period (recent results stay downloadable until they age out). Job-count fence in system.spec: 7 -> 8 (new scheduler registration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createTestApp } from '../testing/test-app';
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
import { ConversionJobService } from './conversion-job.service';
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Conversion payload retention (issue #233): finished jobs past
|
|
* `conversion.payloadRetentionDays` lose their raw input/result bytes while
|
|
* the row survives for status; pending and stale-RUNNING rows (the worker's
|
|
* lock-recovery path) keep their payload untouched.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('conversion payload prune (e2e, issue #233)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
let ownerId: string;
|
|
const suffix = uniqueSuffix();
|
|
|
|
const bytes = () => new Uint8Array(Buffer.from(`payload-${suffix}`));
|
|
|
|
async function jobRow(
|
|
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED',
|
|
ageDays: number,
|
|
lockedAt: Date | null = null,
|
|
) {
|
|
return prisma.conversionJob.create({
|
|
data: {
|
|
ownerId,
|
|
kind: `test_prune_${suffix}`,
|
|
sourceFormat: 'markdown',
|
|
targetFormat: 'html',
|
|
input: bytes(),
|
|
status,
|
|
result: status === 'SUCCEEDED' ? bytes() : null,
|
|
resultMimeType: status === 'SUCCEEDED' ? 'text/html' : null,
|
|
errorCode: status === 'FAILED' ? 'conversion_failed' : null,
|
|
lockedAt,
|
|
updatedAt: new Date(Date.now() - ageDays * DAY),
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
// A short period so ages are unambiguous; written straight to the row
|
|
// BEFORE the app boots (the settings cache is in-process and fills on
|
|
// first read). The key is cleaned afterAll.
|
|
await prisma.instanceSetting.upsert({
|
|
where: { key: 'conversion.payloadRetentionDays' },
|
|
create: { key: 'conversion.payloadRetentionDays', value: 10 },
|
|
update: { value: 10 },
|
|
});
|
|
app = await createTestApp();
|
|
const user = await app.get(UsersService).createUser({
|
|
username: `pia-prune-${suffix}`,
|
|
email: `pia-prune-${suffix}@example.org`,
|
|
displayName: `Pia Prune ${suffix}`,
|
|
password: 'bytes verschwinden fristgerecht 1',
|
|
locale: 'en',
|
|
});
|
|
ownerId = user.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.instanceSetting.deleteMany({
|
|
where: { key: 'conversion.payloadRetentionDays' },
|
|
});
|
|
await prisma.conversionJob.deleteMany({ where: { kind: `test_prune_${suffix}` } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('prunes finished jobs past the period, keeps everything else', async () => {
|
|
const oldSucceeded = await jobRow('SUCCEEDED', 15);
|
|
const oldFailed = await jobRow('FAILED', 15);
|
|
const freshSucceeded = await jobRow('SUCCEEDED', 5);
|
|
const oldPending = await jobRow('PENDING', 15);
|
|
// A crashed run the worker's stale-lock recovery will pick up again —
|
|
// its input must survive or the retry would fail (#233 acceptance).
|
|
const oldStaleRunning = await jobRow('RUNNING', 15, new Date(Date.now() - 15 * DAY));
|
|
|
|
// >=: the shared suite database may hold other files' aged rows.
|
|
const pruned = await app.get(ConversionJobService).pruneExpiredPayloads();
|
|
expect(pruned).toBeGreaterThanOrEqual(2);
|
|
|
|
const byId = new Map(
|
|
(await prisma.conversionJob.findMany({ where: { kind: `test_prune_${suffix}` } })).map(
|
|
(job) => [job.id, job],
|
|
),
|
|
);
|
|
// The finished rows survive with status and error code, only bytes-free.
|
|
expect(byId.get(oldSucceeded.id)).toMatchObject({
|
|
status: 'SUCCEEDED',
|
|
input: null,
|
|
result: null,
|
|
resultMimeType: null,
|
|
});
|
|
expect(byId.get(oldFailed.id)).toMatchObject({
|
|
status: 'FAILED',
|
|
errorCode: 'conversion_failed',
|
|
input: null,
|
|
result: null,
|
|
});
|
|
expect(byId.get(freshSucceeded.id)!.input).not.toBeNull();
|
|
expect(byId.get(freshSucceeded.id)!.result).not.toBeNull();
|
|
expect(byId.get(oldPending.id)!.input).not.toBeNull();
|
|
expect(byId.get(oldStaleRunning.id)!.input).not.toBeNull();
|
|
});
|
|
|
|
it('is a no-op when nothing is due', async () => {
|
|
expect(await app.get(ConversionJobService).pruneExpiredPayloads()).toBe(0);
|
|
});
|
|
});
|