diff --git a/server/src/modules/book/delivery-artifact.service.test.ts b/server/src/modules/book/delivery-artifact.service.test.ts index 403160af1..777be42a2 100644 --- a/server/src/modules/book/delivery-artifact.service.test.ts +++ b/server/src/modules/book/delivery-artifact.service.test.ts @@ -139,6 +139,37 @@ describe('DeliveryArtifactService', () => { await expect(service.prepare(source, 'original', original.sourceRevision)).rejects.toThrow(); await original.release(); }); + it('reuses a verified source hash while the file is unchanged and rehashes it after a write', async () => { + const path = join(directory, 'source.epub'); + await fixture(path, false); + const source = { id: 9, absolutePath: path, format: 'epub' }; + const first = await service.prepare(source, 'original'); + await first.release(); + const actual = await vi.importActual('fs/promises'); + let fullReads = 0; + vi.mocked(open).mockImplementation(async (...args: Parameters) => { + const handle = await actual.open(...args); + const createReadStream = handle.createReadStream.bind(handle); + vi.spyOn(handle, 'createReadStream').mockImplementation((options) => { + fullReads++; + return createReadStream(options); + }); + return handle; + }); + try { + const again = await service.prepare(source, 'original', first.sourceRevision); + expect(again.sourceRevision).toBe(first.sourceRevision); + await again.release(); + expect(fullReads).toBe(0); + + await writeFile(path, 'rewritten in place'); + await expect(service.prepare(source, 'original', first.sourceRevision)).rejects.toThrow(); + expect(fullReads).toBe(1); + } finally { + vi.mocked(open).mockImplementation(actual.open); + } + }); + it('detects external replacement from the recorded source stat', async () => { const path = join(directory, 'source.epub'); await fixture(path, false); diff --git a/server/src/modules/book/delivery-artifact.service.ts b/server/src/modules/book/delivery-artifact.service.ts index 325186def..7245fca57 100644 --- a/server/src/modules/book/delivery-artifact.service.ts +++ b/server/src/modules/book/delivery-artifact.service.ts @@ -16,6 +16,7 @@ import { inspectDeliveryEpub } from './epub-delivery-validation'; const DELIVERY_DETECTOR_VERSION = 'overlay-inspection-v2'; export const DELIVERY_TRANSFORM_VERSION = 'text-only-v2'; +const MAX_REMEMBERED_REVISIONS = 4096; export interface DeliverySource { id: number; absolutePath: string; @@ -44,6 +45,10 @@ export class DeliveryArtifactService { private readonly root: string; private readonly appRoot: string; private readonly builds = new Map>>(); + // A source's SHA-256 keyed by its file signature. Every Kobo sync that converts a position resolves + // the delivered artifact again, and re-reading a multi-gigabyte read-along each time outlasted the + // device's request timeout. Any write moves the signature's change time, so a stale entry never matches. + private readonly revisions = new Map(); private readonly leased = new Map(); private publication: Promise = Promise.resolve(); private active = 0; @@ -109,13 +114,16 @@ export class DeliveryArtifactService { (source.ino !== undefined && source.ino !== (await handle.stat({ bigint: true })).ino)) ) throw new ConflictException({ code: 'DELIVERY_SOURCE_CHANGED' }); - const sourceRevision = await digestHandle(handle); + const revisionKey = `${source.id}:${resolvedSource}:${deliveryFileSignature(before)}`; + const knownRevision = this.revisions.get(revisionKey); + const sourceRevision = knownRevision ?? (await digestHandle(handle)); if ( deliveryFileSignature(before) !== deliveryFileSignature(await handle.stat()) || deliveryFileSignature(before) !== deliveryFileSignature(await stat(source.absolutePath)) ) { throw new ConflictException({ code: 'DELIVERY_SOURCE_CHANGED' }); } + this.rememberRevision(revisionKey, sourceRevision); if (expectedRevision && sourceRevision !== expectedRevision) throw new ConflictException({ code: 'DELIVERY_SOURCE_CHANGED' }); const format = source.format?.toLowerCase() ?? 'unknown'; const transform = @@ -176,6 +184,12 @@ export class DeliveryArtifactService { } } + private rememberRevision(key: string, revision: string): void { + this.revisions.delete(key); + this.revisions.set(key, revision); + if (this.revisions.size > MAX_REMEMBERED_REVISIONS) this.revisions.delete(this.revisions.keys().next().value!); + } + private async build( source: DeliverySource, sourceRevision: string,