๐ src/downloader/orchestrator.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library โ The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15// EnsurePdf / EnsurePageImage โ single-flight pipeline (ยง5.5).
16import { matchPage, matchPdf, deletePdf } from './cache-store';
17import { pageKey } from './keys';
18import { ConcurrentProcessor } from './queue';
19import {
20 _setFileState,
21 _setRenderPage,
22} from './state';
23import type { Thread } from './client/worker-client';
24import type { WorkerMessage } from './worker-protocol';
25
26const MAX_RENDER_RETRIES = 2;
27const DEFAULT_PDF_CONCURRENCY = 3;
28
29interface OrchestratorOptions {
30 muWorkerTimeout?: number; // ms; default 60_000
31 pdfConcurrency?: number; // max PDFs downloading at once across different books; default 3
32}
33
34// Singletons set at init.
35let _fileThread: Thread | null = null;
36let _muThread: Thread | null = null;
37let _muTimeout = 60_000;
38// Bounds how many *different* books can be downloading their PDF at once โ
39// EnsurePdf's single-flight map only dedups repeat calls for the SAME txId,
40// it never capped fan-out across many different txIds (e.g. a shelf preloading
41// covers, or a page grid that triggers EnsurePdf per page).
42let _pdfProcessor: ConcurrentProcessor<void> | null = null;
43
44// Single-flight maps.
45const pendingPdf: Map<string, Promise<void>> = new Map();
46const pendingPage: Map<string, Promise<Response>> = new Map();
47
48export function initOrchestrator(
49 fileThread: Thread,
50 muThread: Thread,
51 opts: OrchestratorOptions = {},
52): void {
53 _fileThread = fileThread;
54 _muThread = muThread;
55 _muTimeout = opts.muWorkerTimeout ?? 60_000;
56 _pdfProcessor = new ConcurrentProcessor<void>(opts.pdfConcurrency ?? DEFAULT_PDF_CONCURRENCY);
57
58 // Route render page-level messages into state. File-worker messages
59 // (progress/finished/error/prune) are owned by the single listener in
60 // client/files.ts โ registering them here too would double-write state.
61 muThread.listen(handleMuWorkerMessage);
62}
63
64function handleMuWorkerMessage(msg: WorkerMessage): void {
65 if (msg.type === 'MuPageDone') {
66 _setRenderPage(msg.txId, msg.page, 'done');
67 } else if (msg.type === 'MuPageError') {
68 _setRenderPage(msg.txId, msg.page, 'error');
69 }
70}
71
72// Download-only (no render). Single-flight by txId.
73export function EnsurePdf(txId: string): Promise<void> {
74 const cached = pendingPdf.get(txId);
75 if (cached) return cached;
76
77 const p = _ensurePdfInner(txId).finally(() => pendingPdf.delete(txId));
78 pendingPdf.set(txId, p);
79 return p;
80}
81
82async function _ensurePdfInner(txId: string): Promise<void> {
83 const hit = await matchPdf(txId);
84 if (hit) return;
85
86 _setFileState(txId, { phase: 'queued' });
87
88 // Gated by _pdfProcessor so at most `pdfConcurrency` different books
89 // download simultaneously โ unrelated txIds queue instead of all firing
90 // at once (this is on top of, not instead of, EnsurePdf's per-txId
91 // single-flight de-dup above).
92 await _pdfProcessor!.add(txId, async () => {
93 const result = await _fileThread!.rpc({ type: 'GetFile', txId, mime: 'pdf' });
94 if (!result.ok) {
95 throw new Error(result.error ?? '[orchestrator] download failed');
96 }
97 });
98}
99
100// Download + render for a single page. Single-flight by pageKey.
101export function EnsurePageImage(txId: string, page: number): Promise<Response> {
102 const key = pageKey(txId, page);
103 const cached = pendingPage.get(key);
104 if (cached) return cached;
105
106 const p = _ensurePageInner(txId, page, key).finally(() => pendingPage.delete(key));
107 pendingPage.set(key, p);
108 return p;
109}
110
111async function _ensurePageInner(
112 txId: string,
113 page: number,
114 pKey: string,
115): Promise<Response> {
116 // Fast path: already in cache.
117 const hit = await matchPage(txId, page);
118 if (hit) return hit;
119
120 _setRenderPage(txId, page, 'queued');
121
122 let retries = 0;
123 while (retries <= MAX_RENDER_RETRIES) {
124 // Ensure PDF is present (may already be cached or downloading).
125 await EnsurePdf(txId);
126
127 // Request render for this single page.
128 _setRenderPage(txId, page, 'downloading');
129 const renderResult = await requestRender(txId, page);
130
131 if (renderResult === 'done') {
132 const resp = await matchPage(txId, page);
133 if (resp) return resp;
134 throw new Error(`[orchestrator] page ${page} not in cache after render`);
135 }
136
137 // MuPageError: re-run EnsurePdf in case PDF was evicted, then retry.
138 retries++;
139 if (retries > MAX_RENDER_RETRIES) {
140 _setRenderPage(txId, page, 'error');
141 throw new Error(`[orchestrator] render failed for page ${page} of ${txId}`);
142 }
143 // The cached PDF may be corrupt (render failed against good bytes too,
144 // but a bad cache entry is the common case) โ evict it so the next
145 // EnsurePdf actually re-downloads instead of matchPdf-ing the same bytes.
146 await deletePdf(txId);
147 pendingPdf.delete(txId);
148 }
149
150 throw new Error(`[orchestrator] unreachable`);
151}
152
153// Returns 'done' or 'error' after awaiting MuPageDone/MuPageError (with timeout).
154function requestRender(txId: string, page: number): Promise<'done' | 'error'> {
155 return new Promise((resolve) => {
156 const timer = setTimeout(() => {
157 cleanup();
158 resolve('error');
159 }, _muTimeout);
160
161 const cleanup = _addOneShotMuListener(txId, page, (result) => {
162 clearTimeout(timer);
163 resolve(result);
164 });
165
166 // Issue the render command.
167 _muThread!.send({ type: 'ProcessPages', txId, from: page, to: page });
168 });
169}
170
171// Register a one-shot listener on the mu-worker thread for a specific page event.
172// The returned cleanup detaches the handler so it cannot leak across renders.
173function _addOneShotMuListener(
174 txId: string,
175 page: number,
176 cb: (result: 'done' | 'error') => void,
177): () => void {
178 const thread = _muThread!;
179 let done = false;
180 const cleanup = () => { done = true; thread.unlisten(handler); };
181 const handler = (msg: WorkerMessage) => {
182 if (done) return;
183 if (msg.type === 'MuPageDone' && msg.txId === txId && msg.page === page) {
184 cleanup(); cb('done');
185 } else if (msg.type === 'MuPageError' && msg.txId === txId && msg.page === page) {
186 cleanup(); cb('error');
187 }
188 };
189 thread.listen(handler);
190 return cleanup;
191}
192