require('ts-node/register/transpile-only'); const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); const { QueryTypes, Sequelize } = require('sequelize'); const { defineSchemaMigrationModel, } = require('../../back/data/schemaMigration'); const { RUN_ATTEMPT_TABLE, RUN_TABLE, runSchemaMigration, } = require('../../back/migrations/0002-run-schema'); const { COMPLETION_RECEIPT_JOURNAL_TABLE, completionReceiptJournalMigration, } = require('../../back/migrations/0007-completion-receipt-journal'); const { LOCAL_ARTIFACT_RETENTION_TABLE, localArtifactRetentionMigration, } = require('../../back/migrations/0015-local-artifact-retention'); const { LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE, localArtifactMaintenanceCursorMigration, } = require('../../back/migrations/0016-local-artifact-maintenance-cursor'); const { runMigrations } = require('../../back/migrations/runner'); const { LocalArtifactFileRetirementStore, UnsafeLocalArtifactRetirementError, } = require('../../back/runtime/adapters/fs/localArtifactFileRetirementStore'); const { LegacySequelizeLocalArtifactRetentionRepository, } = require('../../back/runtime/adapters/legacy-sequelize/localArtifactRetentionRepository'); const { LegacySequelizeLocalArtifactRetentionCheckpointStore, } = require('../../back/runtime/adapters/legacy-sequelize/localArtifactRetentionCheckpointStore'); const { LocalArtifactRetentionService, } = require('../../back/runtime/application/localArtifactRetentionService'); const { localExecutionArtifactId, } = require('../../back/runtime/domain/localExecutionArtifact'); const { encodeLocalArtifactTruncationFact, } = require('../../back/runtime/domain/localArtifactTruncation'); const DAY_MS = 24 * 60 * 60_000; const OBSERVED_AT_MS = 1_800_000_000_000; function identity(index) { const suffix = String(index).padStart(12, '0'); return { runId: `019f7400-0000-7000-8000-${suffix}`, attemptId: `019f7401-0000-7000-8000-${suffix}`, }; } function artifactId(index) { const ids = identity(index); return localExecutionArtifactId({ ...ids, projectId: 'default', taskId: `task-${index}`, taskRevision: 'revision-1', executorType: 'local_process', priority: 0, queuedAtMs: 1, attemptCreatedAtMs: 1, }); } async function setup(t) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-retention-')); const artifacts = path.join(root, 'artifacts'); await fs.mkdir(artifacts, { recursive: true, mode: 0o700 }); const database = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false, }); t.after(() => database.close()); t.after(() => fs.rm(root, { recursive: true, force: true })); await runMigrations({ database, migrationModel: defineSchemaMigrationModel(database), migrations: [ runSchemaMigration, completionReceiptJournalMigration, localArtifactRetentionMigration, localArtifactMaintenanceCursorMigration, ], logger: { info() {} }, }); return { root, artifacts, database, repository: new LegacySequelizeLocalArtifactRetentionRepository(database), checkpoints: new LegacySequelizeLocalArtifactRetentionCheckpointStore( database, ), files: new LocalArtifactFileRetirementStore(artifacts), }; } async function seedAttempt( context, index, { finishedAtMs = OBSERVED_AT_MS - 10 * DAY_MS, runStatus = 'succeeded', attemptStatus = 'succeeded', executionOwner = 'runtime', executorType = 'local_process', logArtifactId = artifactId(index), } = {}, ) { const ids = identity(index); const query = context.database.getQueryInterface(); await query.bulkInsert(RUN_TABLE, [ { id: ids.runId, project_id: 'default', task_id: `task-${index}`, task_revision: 'revision-1', trigger_type: 'manual', execution_origin: 'manual', execution_owner: executionOwner, status: runStatus, version: 1, event_sequence: 1, priority: 0, created_at_ms: finishedAtMs - 1_000, finished_at_ms: runStatus === 'running' ? null : finishedAtMs, }, ]); await query.bulkInsert(RUN_ATTEMPT_TABLE, [ { id: ids.attemptId, run_id: ids.runId, attempt: 1, status: attemptStatus, executor_type: executorType, log_artifact_id: logArtifactId, callback_sequence: 0, created_at_ms: finishedAtMs - 1_000, finished_at_ms: attemptStatus === 'running' ? null : finishedAtMs, }, ]); return { ...ids, logArtifactId, finishedAtMs }; } async function writeArtifact(context, logArtifactId, value = 'artifact-value') { const directory = path.join(context.artifacts, logArtifactId.slice(6, 8)); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); const target = path.join(directory, `${logArtifactId}.log`); await fs.writeFile(target, value, { mode: 0o600 }); return { directory, target, bytes: Buffer.byteLength(value) }; } function retentionService(context, overrides = {}) { return new LocalArtifactRetentionService( overrides.repository ?? context.repository, overrides.files ?? context.files, overrides.capacity ?? { async inspect() { return { availableBytes: BigInt(1024 * 1024), totalBytes: BigInt(2 * 1024 * 1024), }; }, }, { normalRetentionMs: overrides.normalRetentionMs ?? 7 * DAY_MS, pressureRetentionMs: overrides.pressureRetentionMs ?? DAY_MS, minimumFreeBytes: overrides.minimumFreeBytes ?? 64 * 1024, pageSize: overrides.pageSize ?? 16, maximumDeletions: overrides.maximumDeletions ?? 8, clock: { now: () => OBSERVED_AT_MS }, }, ); } test('selects only settled runtime-owned terminal local Artifacts', async (t) => { const context = await setup(t); const eligible = await seedAttempt(context, 1); await seedAttempt(context, 2, { runStatus: 'running' }); await seedAttempt(context, 3, { attemptStatus: 'running' }); await seedAttempt(context, 4, { executionOwner: 'legacy' }); await seedAttempt(context, 5, { executorType: 'docker' }); await seedAttempt(context, 6, { logArtifactId: 'legacy-log-not-local' }); const receiptBlocked = await seedAttempt(context, 7); await seedAttempt(context, 8, { runStatus: 'lost', attemptStatus: 'lost', }); await context.database .getQueryInterface() .bulkInsert(COMPLETION_RECEIPT_JOURNAL_TABLE, [ { attempt_id: receiptBlocked.attemptId, run_id: receiptBlocked.runId, state: 'pending', registered_at_ms: 1, updated_at_ms: 1, }, ]); const page = await context.repository.list({ cutoffMs: OBSERVED_AT_MS - 7 * DAY_MS, limit: 16, }); assert.deepEqual(page.candidates, [ { attemptId: eligible.attemptId, logArtifactId: eligible.logArtifactId, finishedAtMs: eligible.finishedAtMs, }, ]); await context.database .getQueryInterface() .bulkDelete(COMPLETION_RECEIPT_JOURNAL_TABLE, { attempt_id: receiptBlocked.attemptId, }); assert.equal( (await context.repository.list({ cutoffMs: OBSERVED_AT_MS, limit: 16 })) .candidates.length, 2, ); }); test('uses pressure retention, deletes durably, and records an immutable tombstone', async (t) => { const context = await setup(t); const candidate = await seedAttempt(context, 1, { finishedAtMs: OBSERVED_AT_MS - 2 * DAY_MS, }); const artifact = await writeArtifact(context, candidate.logArtifactId); const normal = await retentionService(context).sweep(); assert.equal(normal.pressure, false); assert.equal(normal.candidatesScanned, 0); assert.equal((await fs.stat(artifact.target)).size, artifact.bytes); const pressure = retentionService(context, { capacity: { async inspect() { return { availableBytes: BigInt(1), totalBytes: BigInt(1024) }; }, }, }); const result = await pressure.sweep(); assert.deepEqual( { status: result.status, pressure: result.pressure, retentionMs: result.retentionMs, recordsWritten: result.recordsWritten, bytesReclaimed: result.bytesReclaimed, }, { status: 'complete', pressure: true, retentionMs: DAY_MS, recordsWritten: 1, bytesReclaimed: artifact.bytes, }, ); await assert.rejects(fs.lstat(artifact.target), /ENOENT/); const tombstone = await context.database .getQueryInterface() .select(null, LOCAL_ARTIFACT_RETENTION_TABLE, { where: { attempt_id: candidate.attemptId }, plain: true, }); assert.equal(tombstone.disposition, 'deleted'); assert.equal(Number(tombstone.bytes_reclaimed), artifact.bytes); assert.equal((await pressure.sweep()).candidatesScanned, 0); }); test('recovers a crash between file deletion and tombstone persistence', async (t) => { const context = await setup(t); const candidate = await seedAttempt(context, 1); const artifact = await writeArtifact(context, candidate.logArtifactId); let recordCalls = 0; const crashing = retentionService(context, { repository: { list: (options) => context.repository.list(options), async record() { recordCalls += 1; throw new Error('simulated database outage'); }, }, }); const failed = await crashing.sweep(); assert.equal(failed.entries[0].outcome, 'record_failed'); assert.equal(failed.entries[0].bytesReclaimed, artifact.bytes); assert.equal(recordCalls, 1); await assert.rejects(fs.lstat(artifact.target), /ENOENT/); const recovered = await retentionService(context).sweep(); assert.equal(recovered.entries[0].outcome, 'already_absent'); assert.equal(recovered.recordsWritten, 1); assert.equal( await context.database .getQueryInterface() .rawSelect( LOCAL_ARTIFACT_RETENTION_TABLE, { where: { attempt_id: candidate.attemptId } }, ['disposition'], ), 'already_absent', ); }); test('enforces a deletion budget and resumes from a stable cursor', async (t) => { const context = await setup(t); for (let index = 1; index <= 3; index += 1) { const candidate = await seedAttempt(context, index); await writeArtifact(context, candidate.logArtifactId, `artifact-${index}`); } const service = retentionService(context, { pageSize: 3, maximumDeletions: 2, }); const first = await service.sweep(); assert.equal(first.status, 'deletion_budget_exhausted'); assert.equal(first.recordsWritten, 2); assert.deepEqual(first.nextCursor, { finishedAtMs: OBSERVED_AT_MS - 10 * DAY_MS, attemptId: identity(2).attemptId, }); const second = await service.sweep(first.nextCursor); assert.equal(second.status, 'complete'); assert.equal(second.recordsWritten, 1); const [count] = await context.database.query( `SELECT COUNT(*) AS count FROM "${LOCAL_ARTIFACT_RETENTION_TABLE}"`, { type: QueryTypes.SELECT }, ); assert.equal(count.count, 3); }); test('removes a stale quota FIFO but refuses symlink or non-file targets', async (t) => { const context = await setup(t); const logArtifactId = artifactId(1); const artifact = await writeArtifact(context, logArtifactId); const fifo = path.join(artifact.directory, `.${logArtifactId}.log.fifo`); await new Promise((resolve, reject) => { require('node:child_process').execFile( 'mkfifo', ['-m', '600', fifo], (error) => (error ? reject(error) : resolve()), ); }); const truncation = path.join( artifact.directory, `.${logArtifactId}.log.truncated.json`, ); const truncationTemporary = path.join( artifact.directory, `.${logArtifactId}.log.truncated.tmp`, ); await fs.writeFile( truncation, encodeLocalArtifactTruncationFact({ schemaVersion: 1, ...identity(1), logArtifactId, maximumBytes: 64 * 1024, quotaReached: true, observedAtMs: OBSERVED_AT_MS, }), { mode: 0o600 }, ); await fs.writeFile(truncationTemporary, 'partial', { mode: 0o600 }); assert.equal( (await context.files.retire(logArtifactId)).disposition, 'deleted', ); await assert.rejects(fs.lstat(fifo), /ENOENT/); await assert.rejects(fs.lstat(truncation), /ENOENT/); await assert.rejects(fs.lstat(truncationTemporary), /ENOENT/); const outside = path.join(context.root, 'outside'); await fs.writeFile(outside, 'outside'); await fs.writeFile(artifact.target, 'replacement'); await fs.unlink(artifact.target); await fs.symlink(outside, artifact.target); await assert.rejects( context.files.retire(logArtifactId), UnsafeLocalArtifactRetirementError, ); assert.equal(await fs.readFile(outside, 'utf8'), 'outside'); }); test('treats a missing Artifact shard as already absent', async (t) => { const context = await setup(t); assert.deepEqual(await context.files.retire(artifactId(1)), { disposition: 'already_absent', bytesReclaimed: 0, }); }); test('rejects invalid capacity and pages before touching Artifact files', async (t) => { const context = await setup(t); const candidate = await seedAttempt(context, 1); const artifact = await writeArtifact(context, candidate.logArtifactId); let retireCalls = 0; const files = { async retire() { retireCalls += 1; return { disposition: 'deleted', bytesReclaimed: artifact.bytes }; }, }; await assert.rejects( retentionService(context, { files, capacity: { async inspect() { return { availableBytes: BigInt(2), totalBytes: BigInt(1) }; }, }, }).sweep(), /capacity snapshot is invalid/, ); assert.equal(retireCalls, 0); const invalidPage = retentionService(context, { files, repository: { async list() { return { candidates: [candidate], truncated: true, }; }, async record() { throw new Error('record must remain unreachable'); }, }, }); await assert.rejects(invalidPage.sweep(), /resume cursor is inconsistent/); assert.equal(retireCalls, 0); assert.equal((await fs.stat(artifact.target)).size, artifact.bytes); }); test('persists and fences the retention resume cursor without idle rewrites', async (t) => { const context = await setup(t); const cursor = { finishedAtMs: OBSERVED_AT_MS - DAY_MS, attemptId: identity(1).attemptId, }; assert.deepEqual(await context.checkpoints.load(), { version: 0 }); assert.equal( await context.checkpoints.compareAndSet({ expectedVersion: 0, cursor, updatedAtMs: OBSERVED_AT_MS, }), true, ); assert.deepEqual(await context.checkpoints.load(), { version: 1, cursor, }); assert.equal( await context.checkpoints.compareAndSet({ expectedVersion: 0, updatedAtMs: OBSERVED_AT_MS, }), false, ); assert.equal( await context.checkpoints.compareAndSet({ expectedVersion: 1, updatedAtMs: OBSERVED_AT_MS + 1, }), true, ); assert.deepEqual(await context.checkpoints.load(), { version: 2 }); assert.equal( await context.database .getQueryInterface() .rawSelect( LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE, { where: { scope: 'retention' } }, ['version'], ), 2, ); }); test('serializes concurrent initial cursor claims', async (t) => { const context = await setup(t); const competing = new LegacySequelizeLocalArtifactRetentionCheckpointStore( context.database, ); const cursor = { finishedAtMs: OBSERVED_AT_MS - DAY_MS, attemptId: identity(1).attemptId, }; const results = await Promise.all([ context.checkpoints.compareAndSet({ expectedVersion: 0, cursor, updatedAtMs: OBSERVED_AT_MS, }), competing.compareAndSet({ expectedVersion: 0, cursor, updatedAtMs: OBSERVED_AT_MS, }), ]); assert.deepEqual(results.sort(), [false, true]); assert.deepEqual(await context.checkpoints.load(), { version: 1, cursor }); }); test('refuses a symlink truncation fact before deleting its Artifact', async (t) => { const context = await setup(t); const logArtifactId = artifactId(1); const artifact = await writeArtifact(context, logArtifactId); const outside = path.join(context.root, 'outside-truncation'); await fs.writeFile(outside, 'outside'); const truncation = path.join( artifact.directory, `.${logArtifactId}.log.truncated.json`, ); await fs.symlink(outside, truncation); await assert.rejects( context.files.retire(logArtifactId), UnsafeLocalArtifactRetirementError, ); assert.equal(await fs.readFile(outside, 'utf8'), 'outside'); assert.equal((await fs.stat(artifact.target)).size, artifact.bytes); }); test('preserves the truncation fact when log deletion fails', async (t) => { const context = await setup(t); const logArtifactId = artifactId(1); const artifact = await writeArtifact(context, logArtifactId); const truncation = path.join( artifact.directory, `.${logArtifactId}.log.truncated.json`, ); await fs.writeFile( truncation, encodeLocalArtifactTruncationFact({ schemaVersion: 1, ...identity(1), logArtifactId, maximumBytes: 64 * 1024, quotaReached: true, observedAtMs: OBSERVED_AT_MS, }), { mode: 0o600 }, ); await fs.chmod(artifact.directory, 0o500); try { await assert.rejects(context.files.retire(logArtifactId)); } finally { await fs.chmod(artifact.directory, 0o700); } assert.equal((await fs.stat(artifact.target)).size, artifact.bytes); assert.ok((await fs.stat(truncation)).isFile()); });