Automatically remove unused game-file versions
This commit is contained in:
@@ -6,8 +6,8 @@ import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
|
||||
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
|
||||
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
|
||||
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
|
||||
import { heartbeat, createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
|
||||
import { cleanupControllerLibraries, reconcileHostLibraryCleanup, createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
|
||||
import { serveGameFile } from '../../src/lib/server/game-files/http.js';
|
||||
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
|
||||
|
||||
@@ -179,3 +179,97 @@ describe('verified host distribution', () => {
|
||||
expect(manifestRoute({ request: authorized, params: { id: version.id } }).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('obsolete game-file cleanup', () => {
|
||||
beforeEach(() => {
|
||||
state.handle!.exec('CREATE TABLE native_instances (id TEXT, host_id TEXT, library_id TEXT, retired_at TEXT)');
|
||||
});
|
||||
async function obsolete() {
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
const latest = await published();
|
||||
return { old, latest };
|
||||
}
|
||||
it('retains history and the latest files while removing unreferenced old bytes', async () => {
|
||||
const { old, latest } = await obsolete();
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)).toMatchObject({ state: 'ready', filesRemoved: true, files: [file] });
|
||||
await expect(readFile(join(old.storageLocation, file.path))).rejects.toThrow();
|
||||
expect(await readFile(join(latest.storageLocation, file.path), 'utf8')).toBe(data);
|
||||
expect(() => queueLibraryDownload(hostId, old.id)).toThrow('removed');
|
||||
expect((await serveGameFile(new Request('http://local/file'), old.id, file.path)).status).toBe(410);
|
||||
await cleanupControllerLibraries();
|
||||
expect(listLibraries()[1].versions).toHaveLength(2);
|
||||
});
|
||||
it('resumes a reserved controller deletion after restart and preserves the manifest', async () => {
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
updateHostLibraryCaches(hostId, [{ id: old.id, status: 'ready', manifestSha256: old.manifestSha256 }]);
|
||||
await published();
|
||||
updateHostLibraryCaches(hostId, [], { snapshot: true });
|
||||
state.handle!.prepare('INSERT INTO game_file_cleanup(version_id,started_at) VALUES(?,?)').run(old.id, new Date().toISOString());
|
||||
await rm(old.storageLocation, { recursive: true });
|
||||
expect(getLibraryVersion(old.id)).toMatchObject({ filesRemoving: true, filesRemoved: false });
|
||||
expect(() => queueLibraryDownload(hostId, old.id)).toThrow('removed');
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)).toMatchObject({ filesRemoving: false, filesRemoved: true, files: [file] });
|
||||
});
|
||||
it('does not send cleanup to agents without the capability or erase their cached files', async () => {
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
updateHostLibraryCaches(hostId, [{ id: old.id, status: 'ready', manifestSha256: old.manifestSha256 }]);
|
||||
await published();
|
||||
reconcileHostLibraryCleanup(hostId);
|
||||
expect(state.handle!.prepare("SELECT * FROM ec_host_operations WHERE kind='library.remove'").all()).toHaveLength(0);
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(false);
|
||||
});
|
||||
it('pins files for stopped or provisioning instances until retirement', async () => {
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
state.handle!.prepare('INSERT INTO native_instances VALUES(?,?,?,NULL)').run('instance', hostId, old.id);
|
||||
await published();
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(false);
|
||||
state.handle!.exec("UPDATE native_instances SET retired_at='now'");
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(true);
|
||||
});
|
||||
it('waits for offline hosts, partial copies, and unresolved downloads', async () => {
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
updateHostLibraryCaches(hostId, [{ id: old.id, status: 'missing', bytesDownloaded: 1 }]);
|
||||
await published();
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(false);
|
||||
const operation = queueLibraryDownload(hostId, old.id);
|
||||
updateHostLibraryCaches(hostId, [], { snapshot: true });
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(false);
|
||||
reportHostOperation(hostId, operation.id, { state: 'failed', error: 'interrupted' });
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(true);
|
||||
});
|
||||
it('queues host removal only without instance references and waits for absence before controller cleanup', async () => {
|
||||
heartbeat(hostId, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, capabilities: ['library.remove'], storageAvailable: 1_000_000_000_000 });
|
||||
const old = await published();
|
||||
reportHostOperation(hostId, old.operationId!, { state: 'completed', result: {} });
|
||||
updateHostLibraryCaches(hostId, [{ id: old.id, status: 'ready', manifestSha256: old.manifestSha256 }]);
|
||||
state.handle!.prepare('INSERT INTO native_instances VALUES(?,?,?,NULL)').run('instance', hostId, old.id);
|
||||
await published();
|
||||
reconcileHostLibraryCleanup(hostId);
|
||||
expect(state.handle!.prepare("SELECT * FROM ec_host_operations WHERE kind='library.remove'").all()).toHaveLength(0);
|
||||
state.handle!.exec("UPDATE native_instances SET retired_at='now'");
|
||||
reconcileHostLibraryCleanup(hostId);
|
||||
reconcileHostLibraryCleanup(hostId);
|
||||
const removals = state.handle!.prepare("SELECT id FROM ec_host_operations WHERE kind='library.remove'").all() as { id: string }[];
|
||||
expect(removals).toHaveLength(1);
|
||||
expect(() => queueLibraryDownload(hostId, old.id)).toThrow('being removed');
|
||||
reportHostOperation(hostId, removals[0].id, { state: 'completed', result: { removed: true } });
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(false);
|
||||
updateHostLibraryCaches(hostId, [], { snapshot: true });
|
||||
await cleanupControllerLibraries();
|
||||
expect(getLibraryVersion(old.id)?.filesRemoved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
vi.mock('../../src/lib/server/pipeline/sweep.js', () => ({ sweep: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/pipeline/beta-runner.js', () => ({ betaTick: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/maintenance/index.js', () => ({ runMaintenance: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/game-files/index.js', () => ({ cleanupControllerLibraries: vi.fn(async () => {}) }));
|
||||
vi.mock('../../src/lib/server/provisioning/worker.js', () => ({ processProvisioning: vi.fn(async () => {}) }));
|
||||
|
||||
function deferred() {
|
||||
|
||||
Reference in New Issue
Block a user