feat(storage): reserve capacity before writes

This commit is contained in:
Sucukdeluxe
2026-08-12 02:09:21 +02:00
parent ebfd98226b
commit 51b422fa10
5 changed files with 829 additions and 42 deletions
+177
View File
@@ -0,0 +1,177 @@
import fs from "node:fs";
import path from "node:path";
export type DiskReservationPhase = "download" | "extract" | "remux";
export type DiskVolumeStats = {
path: string;
volumeKey: string;
freeBytes: number;
totalBytes: number;
};
export type DiskReservationRequest = {
phase: DiskReservationPhase;
ownerId: string;
targetPath: string;
requiredBytes: number | null;
alreadyPresentBytes?: number;
};
export type DiskWaitEvent = {
phase: DiskReservationPhase;
ownerId: string;
volumeKey: string;
requiredBytes: number;
availableBytes: number;
deficitBytes: number;
safetyBytes: number;
retryAt: number;
};
export class DiskCapacityError extends Error {
public readonly event: DiskWaitEvent;
public constructor(event: DiskWaitEvent) {
super("Insufficient disk capacity");
this.name = "DiskCapacityError";
this.event = event;
}
}
type DiskReservationCoordinatorOptions = {
safetyBytes?: number;
retryDelayMs?: number;
now?: () => number;
statVolume?: (targetPath: string) => Promise<DiskVolumeStats>;
};
type DiskReservationUpdate = Pick<DiskReservationRequest, "requiredBytes" | "alreadyPresentBytes">;
export type DiskReservationLease = {
readonly volumeKey: string | null;
readonly released: boolean;
readonly reservedBytes: number;
update(update: DiskReservationUpdate): Promise<void>;
release(): void;
};
export function calculateRemainingReservationBytes(requiredBytes: number | null, alreadyPresentBytes = 0): number | null {
if (!Number.isFinite(requiredBytes) || requiredBytes === null || requiredBytes < 0) return null;
return Math.max(0, Math.floor(requiredBytes) - Math.max(0, Math.floor(alreadyPresentBytes)));
}
export function calculateExtractionReservationBytes(archiveBytes: Array<number | null | undefined>): number | null {
const known = archiveBytes.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
if (known.length === 0) return null;
return known.reduce((total, value) => total + Math.floor(value), 0);
}
async function defaultStatVolume(targetPath: string): Promise<DiskVolumeStats> {
let candidate = path.resolve(targetPath);
while (true) {
try {
const stat = await fs.promises.stat(candidate);
const directory = stat.isDirectory() ? candidate : path.dirname(candidate);
const info = await fs.promises.statfs(directory);
const freeBytes = Number(info.bavail) * Number(info.bsize);
const totalBytes = Number(info.blocks) * Number(info.bsize);
return { path: directory, volumeKey: path.parse(directory).root.toLowerCase(), freeBytes, totalBytes };
} catch {
const parent = path.dirname(candidate);
if (parent === candidate) throw new Error(`Unable to resolve disk volume for ${targetPath}`);
candidate = parent;
}
}
}
export class DiskReservationCoordinator {
private readonly safetyBytes: number;
private readonly retryDelayMs: number;
private readonly now: () => number;
private readonly statVolume: (targetPath: string) => Promise<DiskVolumeStats>;
private readonly reservedByVolume = new Map<string, number>();
private readonly leases = new Map<string, { volumeKey: string; reservedBytes: number; targetPath: string }>();
private queue = Promise.resolve();
public constructor(options: DiskReservationCoordinatorOptions = {}) {
this.safetyBytes = Math.max(0, Math.floor(options.safetyBytes ?? 256 * 1024 * 1024));
this.retryDelayMs = Math.max(1000, Math.floor(options.retryDelayMs ?? 30000));
this.now = options.now ?? Date.now;
this.statVolume = options.statVolume ?? defaultStatVolume;
}
public async reserve(request: DiskReservationRequest): Promise<DiskReservationLease> {
return this.enqueue(async () => {
const requiredBytes = calculateRemainingReservationBytes(request.requiredBytes, request.alreadyPresentBytes ?? 0);
if (requiredBytes === null) return this.createLease(request.ownerId, request.targetPath, null, 0);
const volume = await this.statVolume(request.targetPath);
const reserved = this.reservedByVolume.get(volume.volumeKey) ?? 0;
const availableBytes = Math.max(0, Math.floor(volume.freeBytes) - reserved - this.safetyBytes);
if (requiredBytes > availableBytes) {
throw new DiskCapacityError({
phase: request.phase,
ownerId: request.ownerId,
volumeKey: volume.volumeKey,
requiredBytes,
availableBytes,
deficitBytes: requiredBytes - availableBytes,
safetyBytes: this.safetyBytes,
retryAt: this.now() + this.retryDelayMs
});
}
return this.createLease(request.ownerId, request.targetPath, volume.volumeKey, requiredBytes);
});
}
public getReservedBytesByVolume(): ReadonlyMap<string, number> {
return new Map(this.reservedByVolume);
}
private createLease(ownerId: string, targetPath: string, volumeKey: string | null, reservedBytes: number): DiskReservationLease {
const leaseId = `${ownerId}:${Math.random().toString(16).slice(2)}`;
const coordinator = this;
if (volumeKey) {
this.leases.set(leaseId, { volumeKey, reservedBytes, targetPath });
this.reservedByVolume.set(volumeKey, (this.reservedByVolume.get(volumeKey) ?? 0) + reservedBytes);
}
let released = false;
return {
get volumeKey() { return volumeKey; },
get released() { return released; },
get reservedBytes() { return coordinator.leases.get(leaseId)?.reservedBytes ?? 0; },
update: async (update) => {
await coordinator.enqueue(async () => {
if (released || !volumeKey) return;
const lease = coordinator.leases.get(leaseId);
if (!lease) return;
const nextBytes = calculateRemainingReservationBytes(update.requiredBytes, update.alreadyPresentBytes ?? 0);
if (nextBytes === null) return;
const delta = nextBytes - lease.reservedBytes;
if (delta > 0) {
const volume = await coordinator.statVolume(lease.targetPath);
const available = Math.max(0, Math.floor(volume.freeBytes) - (coordinator.reservedByVolume.get(volumeKey) ?? 0) - coordinator.safetyBytes);
if (delta > available) throw new DiskCapacityError({ phase: "download", ownerId, volumeKey, requiredBytes: nextBytes, availableBytes: available, deficitBytes: delta - available, safetyBytes: coordinator.safetyBytes, retryAt: coordinator.now() + coordinator.retryDelayMs });
}
lease.reservedBytes = nextBytes;
coordinator.reservedByVolume.set(volumeKey, Math.max(0, (coordinator.reservedByVolume.get(volumeKey) ?? 0) + delta));
});
},
release: () => {
if (released) return;
released = true;
const lease = coordinator.leases.get(leaseId);
coordinator.leases.delete(leaseId);
if (!lease) return;
const next = Math.max(0, (coordinator.reservedByVolume.get(lease.volumeKey) ?? 0) - lease.reservedBytes);
coordinator.reservedByVolume.set(lease.volumeKey, next);
}
};
}
private async enqueue<T>(operation: () => Promise<T>): Promise<T> {
const next = this.queue.then(operation, operation);
this.queue = next.then(() => undefined, () => undefined);
return next;
}
}
+130 -30
View File
@@ -71,6 +71,7 @@ import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerificat
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
import { mergeKnownTotalBytes } from "./download-size";
import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space";
import { createRendererState } from "./renderer-state";
type ActiveTask = {
@@ -1857,7 +1858,15 @@ export class DownloadManager extends EventEmitter {
private lastGlobalProgressAt = 0;
private retryAfterByItem = new Map<string, number>();
private retryAfterByItem = new Map<string, number>();
private packageDiskRetryAfterByPackage = new Map<string, number>();
private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = [];
private diskReservations = new DiskReservationCoordinator();
private diskLeasesByOwner = new Map<string, DiskReservationLease>();
private retryStateByItem = new Map<string, {
freshRetryUsed: boolean;
@@ -2503,8 +2512,9 @@ export class DownloadManager extends EventEmitter {
canStop: this.session.running,
canPause: this.session.running,
clipboardActive: this.settings.clipboardWatch,
reconnectSeconds: Math.ceil(reconnectMs / 1000),
packageSpeedBps: !this.session.running || paused
reconnectSeconds: Math.ceil(reconnectMs / 1000),
diskWaitEvents: this.diskWaitEvents,
packageSpeedBps: !this.session.running || paused
? EMPTY_PACKAGE_SPEED_BPS
: (() => {
const out: Record<string, number> = {};
@@ -4192,19 +4202,46 @@ export class DownloadManager extends EventEmitter {
let processed = 0;
let failed = 0;
for (const sourcePath of targets) {
if (shouldAbort?.() || signal?.aborted) {
return processed;
}
const sourceName = path.basename(sourcePath);
let result: VideoProcessResult;
try {
result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal });
} catch (error) {
result = { action: "error", reason: "exception", error: compactErrorText(error) };
}
if (result.action === "aborted") {
return processed;
}
if (shouldAbort?.() || signal?.aborted) {
return processed;
}
const sourceName = path.basename(sourcePath);
let result: VideoProcessResult | null = null;
let remuxLease: DiskReservationLease | null = null;
try {
try {
const sourceBytes = (await fs.promises.stat(sourcePath)).size;
remuxLease = await this.diskReservations.reserve({
phase: "remux",
ownerId: pkg?.id || sourcePath,
targetPath: sourcePath,
requiredBytes: sourceBytes
});
} catch (error) {
if (error instanceof DiskCapacityError) {
this.diskWaitEvents = [{
...error.event,
...(pkg ? { packageId: pkg.id } : {})
}];
result = { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux" };
} else {
throw error;
}
}
if (!result) {
result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal });
}
} catch (error) {
result = { action: "error", reason: "exception", error: compactErrorText(error) };
} finally {
remuxLease?.release();
}
if (!result) {
result = { action: "error", reason: "exception", error: "Unbekannter Remux-Status" };
}
if (result.action === "aborted") {
return processed;
}
const langs = (result.audioLanguages || []).join(",");
if (pkg) {
const level = result.action === "error" ? "WARN" : "INFO";
@@ -9027,8 +9064,10 @@ export class DownloadManager extends EventEmitter {
void this.processItem(active).catch((err) => {
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
}).finally(() => {
if (!this.retryAfterByItem.has(item.id)) {
}).finally(() => {
this.diskLeasesByOwner.get(itemId)?.release();
this.diskLeasesByOwner.delete(itemId);
if (!this.retryAfterByItem.has(item.id)) {
this.releaseTargetPath(item.id);
}
if (active.nonResumableCounted) {
@@ -9206,8 +9245,33 @@ export class DownloadManager extends EventEmitter {
const preferredTargetPath = canReuseExistingTarget
? existingTargetPath
: path.join(pkg.outputDir, item.fileName);
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
try {
const diskLease = await this.diskReservations.reserve({
phase: "download",
ownerId: item.id,
targetPath: item.targetPath,
requiredBytes: item.totalBytes,
alreadyPresentBytes: item.downloadedBytes
});
this.diskLeasesByOwner.get(item.id)?.release();
this.diskLeasesByOwner.set(item.id, diskLease);
} catch (error) {
if (error instanceof DiskCapacityError) {
this.diskWaitEvents = [{
...error.event,
itemId: item.id,
packageId: pkg.id
}];
this.releaseTargetPath(item.id);
this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte");
this.persistSoon();
this.emitState();
return;
}
throw error;
}
item.status = "downloading";
const pLabel = unrestricted.providerLabel;
item.fullStatus = "Starte...";
@@ -9345,10 +9409,12 @@ export class DownloadManager extends EventEmitter {
: `Fertig (${humanSize(item.downloadedBytes)})`;
item.progressPercent = 100;
item.speedBps = 0;
item.updatedAt = completedAt;
this.notePackageDownloadCompleted(pkg, completedAt);
pkg.updatedAt = completedAt;
this.recordRunOutcome(item.id, "completed");
item.updatedAt = completedAt;
this.notePackageDownloadCompleted(pkg, completedAt);
pkg.updatedAt = completedAt;
this.diskLeasesByOwner.get(item.id)?.release();
this.diskLeasesByOwner.delete(item.id);
this.recordRunOutcome(item.id, "completed");
logger.info(`Download fertig: ${item.fileName} (${humanSize(item.downloadedBytes)}), pkg=${pkg.name}`);
this.logPackageForItem(item, "INFO", "Download abgeschlossen", {
downloadedBytes: item.downloadedBytes,
@@ -12169,13 +12235,47 @@ export class DownloadManager extends EventEmitter {
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
const fullExtractItemIds = new Set<string>();
for (const archivePath of fullArchiveSet) {
const archiveItems = resolveArchiveItems(path.basename(archivePath));
for (const entry of archiveItems) {
fullExtractItemIds.add(entry.id);
}
}
const pendingAt = nowMs();
for (const archivePath of fullArchiveSet) {
const archiveItems = resolveArchiveItems(path.basename(archivePath));
for (const entry of archiveItems) {
fullExtractItemIds.add(entry.id);
}
}
const archiveSizes = await Promise.all([...fullArchiveSet].map(async (archivePath) => {
try {
return (await fs.promises.stat(archivePath)).size;
} catch {
return null;
}
}));
try {
const diskLease = await this.diskReservations.reserve({
phase: "extract",
ownerId: packageId,
targetPath: pkg.extractDir,
requiredBytes: archiveSizes.every((size) => size === null) ? null : archiveSizes.reduce<number>((total, size) => total + Math.max(0, size || 0), 0)
});
diskLease.release();
} catch (error) {
if (error instanceof DiskCapacityError) {
this.diskWaitEvents = [{ ...error.event, packageId }];
const retryAt = error.event.retryAt;
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
for (const entry of completedItems) {
entry.fullStatus = "Warte auf Festplatte";
entry.lastError = "Zu wenig Speicherplatz";
entry.updatedAt = nowMs();
}
pkg.postProcessLabel = undefined;
pkg.status = "queued";
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
}
throw error;
}
const pendingAt = nowMs();
for (const entry of completedItems) {
if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) {
continue;
+13 -2
View File
@@ -469,8 +469,19 @@ export interface UiSnapshot {
canPause: boolean;
clipboardActive: boolean;
reconnectSeconds: number;
packageSpeedBps: Record<string, number>;
payloadKind?: "full" | "delta";
packageSpeedBps: Record<string, number>;
diskWaitEvents?: Array<{
phase: "download" | "extract" | "remux";
ownerId: string;
itemId?: string;
packageId?: string;
volumeKey: string;
requiredBytes: number;
availableBytes: number;
deficitBytes: number;
retryAt: number;
}>;
payloadKind?: "full" | "delta";
removedItemIds?: string[];
removedPackageIds?: string[];
rotationEvents?: RotationEvent[];