UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

126 lines (125 loc) • 5.74 kB
import path from 'node:path'; import async from 'async'; import { Mutex, Semaphore } from 'async-mutex'; import Defaults from '../globals/defaults.js'; import FsPoly from '../polyfill/fsPoly.js'; import File from '../types/files/file.js'; import ElasticSemaphore from './elasticSemaphore.js'; /** * Wrapper for an `async-mutex` {@link Semaphore} that limits how many files can be processed at * once per hard drive. */ export default class DriveSemaphore { driveSemaphores = new Map(); driveSemaphoresMutex = new Mutex(); threadsSemaphore; constructor(threads) { this.threadsSemaphore = new Semaphore(threads); } /** * Run a {@link runnable} exclusively for the given {@link file}. */ async runExclusive(file, runnable) { // Get the drive-specific semaphore const filePathDisk = DriveSemaphore.getDiskForFile(file); const driveSemaphore = await this.driveSemaphoresMutex.runExclusive(() => { let semaphore = this.driveSemaphores.get(filePathDisk); if (semaphore === undefined) { // WARN(cemmer): there is an undocumented semaphore max value that can be used, the full // 4,700,372,992 bytes of a DVD+R will cause runExclusive() to never run or return. let maxKilobytes = Defaults.MAX_READ_WRITE_CONCURRENT_KILOBYTES; if (FsPoly.isSamba(filePathDisk)) { // Forcefully limit the number of files to be processed concurrently from a single // Samba network share maxKilobytes = 1; } semaphore = new ElasticSemaphore(maxKilobytes); this.driveSemaphores.set(filePathDisk, semaphore); } return semaphore; }); const fileSizeKilobytes = (file instanceof File && file.getSize() > 0 ? file.getSize() : await FsPoly.size(file instanceof File ? file.getFilePath() : file)) / 1024; // First, limit the number of threads per drive, which will better balance the processing of // files on different drives vs. processing files sequentially return driveSemaphore.runExclusive( // Second, limit the overall number of threads async () => this.threadsSemaphore.runExclusive(async () => runnable()), fileSizeKilobytes); } /** * Run some {@link runnable} for every value in {@link files}. */ async map(files, runnable) { return DriveSemaphore.balanceAcrossDisks(files, async (filesWithIndex) => // Limit the number of ongoing threads to something reasonable async.mapLimit(filesWithIndex, Defaults.MAX_FS_THREADS, async ([file, idx]) => { try { const val = await this.runExclusive(file, async () => runnable(file)); return [val, idx]; } catch (error) { if (error instanceof Error) { throw error; } else if (typeof error === 'string') { throw new Error(error); } else { throw new Error('failed to execute runnable'); } } })); } static async balanceAcrossDisks(files, callback) { // Sort the files by their path, to put files on the same disk together const disksAndFiles = files // Remember the original ordering of the files by its index .map((file, idx) => [file, idx]) .sort(([a], [b]) => { const aPath = a instanceof File ? a.getFilePath() : a.toString(); const bPath = b instanceof File ? b.getFilePath() : b.toString(); return aPath.localeCompare(bPath); }); const disksToFiles = disksAndFiles.reduce((map, [file, idx]) => { const key = DriveSemaphore.getDiskForFile(file); if (map.has(key)) { map.get(key)?.push([file, idx]); } else { map.set(key, [[file, idx]]); } return map; }, new Map()); if (disksToFiles.size <= 1) { // Everything is on the same disk, we don't need to do any extra work return (await callback(disksAndFiles)).map((pair) => pair[0]); } // "Stripe" the files by their disk path for fair processing among disks const maxFilesOnAnyDisk = [...disksToFiles.values()].reduce((max, filesForDisk) => Math.max(max, filesForDisk.length), 0); let filesStriped = []; const chunkSize = 5; for (let i = 0; i < maxFilesOnAnyDisk; i += chunkSize) { const batch = [...disksToFiles.values()].flatMap((filesForDisk) => filesForDisk.splice(0, chunkSize)); filesStriped = [...filesStriped, ...batch]; } const results = await callback(filesStriped); // Put the values back in order return results.sort(([, aIdx], [, bIdx]) => aIdx - bIdx).map(([result]) => result); } static getDiskForFile(file) { const filePath = file instanceof File ? file.getFilePath() : file; const filePathNormalized = filePath.replaceAll(/[\\/]/g, path.sep); // Try to get the path of the drive this file is on const filePathDisk = FsPoly.diskResolved(filePathNormalized); if (filePathDisk !== undefined) { return filePathDisk; } // If a drive couldn't be found, try to parse a samba server name const sambaMatches = /^([\\/]{2}[^\\/]+)/.exec(filePathNormalized); if (sambaMatches !== null) { return sambaMatches[1]; } return ''; } }