Improve application responsiveness and background processing
This commit is contained in:
@@ -18,14 +18,14 @@ type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState
|
||||
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id,
|
||||
...(row.bytes_verified == null ? {} : { bytesVerified: row.bytes_verified }),
|
||||
...(row.transfer_progress ? { transfer: JSON.parse(row.transfer_progress) as LibraryTransferProgress } : {}) });
|
||||
function versionFromRow(row: VersionRow): GameLibraryVersion {
|
||||
const files = JSON.parse(row.manifest) as GameFileEntry[];
|
||||
function versionFromRow(row: VersionRow & { file_count?: number }, summaryCaches?: Map<string, HostLibraryCache[]>): GameLibraryVersion {
|
||||
const files = summaryCaches ? [] : JSON.parse(row.manifest) as GameFileEntry[];
|
||||
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
|
||||
description: row.description, state: row.state, sizeBytes: row.size_bytes,
|
||||
files, fileCount: files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count, manifestSha256: row.manifest_sha256, createdAt: row.created_at,
|
||||
files, fileCount: row.file_count ?? (files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count), manifestSha256: row.manifest_sha256, createdAt: row.created_at,
|
||||
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id || null,
|
||||
sourceVolume: row.source_volume || null, error: row.error, operationId: row.operation_id,
|
||||
caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
|
||||
caches: summaryCaches ? summaryCaches.get(row.id) ?? [] : (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
|
||||
}
|
||||
export function getLibraryVersion(id: string): GameLibraryVersion | null {
|
||||
const row = database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE v.id=?').get(id) as VersionRow | undefined;
|
||||
@@ -52,12 +52,19 @@ export function latestPublishedLibraries() {
|
||||
return version ? [{ ...version, name: game.name }] : [];
|
||||
});
|
||||
}
|
||||
export function listLibraries(): GameLibrary[] {
|
||||
export function listLibraries(summary = false): GameLibrary[] {
|
||||
reconcileLibraryOperations();
|
||||
let summaryCaches: Map<string, HostLibraryCache[]> | undefined;
|
||||
if (summary) {
|
||||
summaryCaches = new Map();
|
||||
for (const row of database().prepare('SELECT * FROM host_library_caches').all() as CacheRow[]) {
|
||||
const items = summaryCaches.get(row.version_id) ?? []; items.push(cacheFromRow(row)); summaryCaches.set(row.version_id, items);
|
||||
}
|
||||
}
|
||||
const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id]));
|
||||
return GAME_FILE_GAMES.map(game => {
|
||||
const versions = (database().prepare(`SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
|
||||
WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(versionFromRow);
|
||||
const versions = (database().prepare(`SELECT ${summary ? `v.id, v.library_id, v.version, v.description, v.dlc, v.state, v.size_bytes, v.manifest_sha256, v.created_at, v.published_at, v.storage_location, v.source_host_id, v.source_volume, v.error, v.operation_id, CASE WHEN json_array_length(v.manifest)>0 THEN json_array_length(v.manifest) ELSE (SELECT COUNT(*) FROM game_file_upload_entries e WHERE e.version_id=v.id) END AS file_count` : 'v.*'}, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
|
||||
WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(row => versionFromRow(row, summaryCaches));
|
||||
const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined;
|
||||
return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions };
|
||||
});
|
||||
@@ -392,9 +399,9 @@ export function reconcileLibraryOperations(): void {
|
||||
/** Lightweight list used in launch forms; large manifests stay on the detail endpoint. */
|
||||
export function libraryOverview() {
|
||||
const hosts = listHosts();
|
||||
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
|
||||
return { libraries: listLibraries(true).map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
|
||||
...version, caches: hosts.map(host => {
|
||||
const cached = getHostLibraryCache(host.id, version.id);
|
||||
const cached = version.caches.find(cache => cache.hostId === host.id);
|
||||
if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status };
|
||||
return {
|
||||
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
|
||||
|
||||
@@ -44,20 +44,50 @@ export function validateUploadPlan(input: unknown): UploadFilePlan[] {
|
||||
return manifest.map(file => ({ path: file.path, size: file.size, lastModified: modified.get(file.path)! }));
|
||||
}
|
||||
|
||||
const uploadLocks = new Map<string, Promise<void>>();
|
||||
interface UploadLocks { barrier: Promise<void>; writers: number; files: Map<string, Promise<void>>; active: Set<Promise<void>> }
|
||||
const uploadLocks = new Map<string, UploadLocks>();
|
||||
function locksFor(key: string): UploadLocks {
|
||||
let locks = uploadLocks.get(key);
|
||||
if (!locks) { locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() }; uploadLocks.set(key, locks); }
|
||||
return locks;
|
||||
}
|
||||
function releaseLocks(key: string, locks: UploadLocks) {
|
||||
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks) uploadLocks.delete(key);
|
||||
}
|
||||
|
||||
/** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */
|
||||
export async function withUploadLock<T>(root: string, callback: () => Promise<T> | T): Promise<T> {
|
||||
const key = resolve(root);
|
||||
const previous = uploadLocks.get(key) ?? Promise.resolve();
|
||||
const locks = locksFor(key);
|
||||
// Capture readers admitted before publication, then prevent later readers
|
||||
// from starting until publication finishes (including failed publication).
|
||||
const previous = Promise.all([locks.barrier, ...locks.active]);
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>(done => { release = done; });
|
||||
uploadLocks.set(key, current);
|
||||
locks.barrier = current;
|
||||
locks.writers++;
|
||||
await previous;
|
||||
try { return await callback(); }
|
||||
finally {
|
||||
release();
|
||||
if (uploadLocks.get(key) === current) uploadLocks.delete(key);
|
||||
locks.writers--;
|
||||
releaseLocks(key, locks);
|
||||
}
|
||||
}
|
||||
|
||||
/** Different files may transfer together; offsets for the same file remain serial. */
|
||||
export async function withUploadFileLock<T>(root: string, file: string, callback: () => Promise<T>): Promise<T> {
|
||||
const key = resolve(root), locks = locksFor(key);
|
||||
const previous = Promise.all([locks.barrier, locks.files.get(file)]);
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>(done => { release = done; });
|
||||
locks.files.set(file, current); locks.active.add(current);
|
||||
await previous;
|
||||
try { return await callback(); }
|
||||
finally {
|
||||
release(); locks.active.delete(current);
|
||||
if (locks.files.get(file) === current) locks.files.delete(file);
|
||||
releaseLocks(key, locks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +125,7 @@ export async function receiveUploadChunk(
|
||||
}
|
||||
if (!body && chunk.size !== 0) throw new Error('A file chunk body is required.');
|
||||
|
||||
return withUploadLock(root, async () => {
|
||||
return withUploadFileLock(root, file.path, async () => {
|
||||
await beforeWrite?.();
|
||||
const progress = await getUploadFileProgress(root, file);
|
||||
if (chunk.offset !== progress.receivedBytes) throw new UploadOffsetError(progress.receivedBytes);
|
||||
|
||||
Reference in New Issue
Block a user