@exulu/backend 1.68.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,13 @@ type DocumentProcessorConfig = {
40
40
  * vertex_ai) can be switched without code changes.
41
41
  */
42
42
  model?: string
43
+ /**
44
+ * Maximum pages per OCR request for the "mistral" processor.
45
+ * Vertex AI OCR rejects documents over 30 pages; the PDF is split into
46
+ * chunks of this size and each chunk is OCR'd independently.
47
+ * Defaults to 25 (safely under the Vertex AI 30-page limit).
48
+ */
49
+ maxPagesPerChunk?: number
43
50
  }
44
51
  /**
45
52
  * Optional cost-attribution context, forwarded to LiteLLM as spend tags
@@ -816,17 +823,61 @@ async function processPdf(
816
823
  ...config.attribution,
817
824
  });
818
825
 
819
- // Wait a randomn time between 1 and 5 seconds to prevent rate limiting
820
- await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 4000) + 1000));
826
+ // Split the PDF into ≤ N-page chunks before sending to OCR.
827
+ // Vertex AI (and some other providers) reject documents over 30 pages.
828
+ // We use PyMuPDF via a Python helper because it handles edge cases that
829
+ // trip up JS PDF libraries — in particular "phantom password" PDFs that
830
+ // are technically encrypted with an empty string (the OS opens them
831
+ // transparently, but libraries throw without the empty-string fallback).
832
+ const maxPagesPerChunk = config.processor.maxPagesPerChunk ?? 25;
833
+ const chunksDir = path.join(path.dirname(paths.json), 'ocr_chunks');
834
+
835
+ const splitResult = await executePythonScript({
836
+ scriptPath: 'ee/python/documents/processing/split_pdf.py',
837
+ args: [paths.source, chunksDir, '--chunk-size', String(maxPagesPerChunk)],
838
+ timeout: 5 * 60 * 1000,
839
+ });
840
+
841
+ const pdfChunks: Array<{ path: string; start_page: number; end_page: number }> =
842
+ JSON.parse(splitResult.stdout);
843
+
844
+ console.log(`[EXULU] PDF split into ${pdfChunks.length} chunk(s) for OCR (max ${maxPagesPerChunk} pages each)`);
845
+
846
+ // Process chunks in parallel with a concurrency cap to respect rate limits.
847
+ // Each chunk gets a small random jitter to spread out requests.
848
+ const chunkLimit = pLimit(3);
821
849
 
822
- const base64Pdf = buffer.toString('base64');
850
+ const chunkResults = await Promise.all(
851
+ pdfChunks.map((chunk, i) =>
852
+ chunkLimit(async () => {
853
+ // Yield to the event loop so BullMQ can renew job locks during long runs
854
+ await new Promise(resolve => setImmediate(resolve));
855
+ await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 1000) + 200));
823
856
 
824
- const ocrResponse = await withRetry(async () => {
825
- return await resolved.ocr({
826
- type: "document_url",
827
- document_url: "data:application/pdf;base64," + base64Pdf,
828
- }, { includeImageBase64: false });
829
- }, 10);
857
+ console.log(`[EXULU] OCR chunk ${i + 1}/${pdfChunks.length}: pages ${chunk.start_page}–${chunk.end_page - 1}`);
858
+
859
+ const chunkBuffer = await fs.promises.readFile(chunk.path);
860
+ const chunkBase64 = chunkBuffer.toString('base64');
861
+
862
+ const chunkResponse = await withRetry(async () => {
863
+ return await resolved.ocr({
864
+ type: "document_url",
865
+ document_url: "data:application/pdf;base64," + chunkBase64,
866
+ }, { includeImageBase64: false });
867
+ }, 10);
868
+
869
+ return { pages: chunkResponse.pages, offset: chunk.start_page };
870
+ })
871
+ )
872
+ );
873
+
874
+ // Merge all chunk pages in document order, offsetting indices to their
875
+ // original positions in the full document.
876
+ const mergedPages = chunkResults
877
+ .sort((a, b) => a.offset - b.offset)
878
+ .flatMap(({ pages, offset }) =>
879
+ pages.map(p => ({ ...p, index: p.index + offset }))
880
+ );
830
881
 
831
882
  const parser = new LiteParse();
832
883
  const screenshots = await parser.screenshot(paths.source, undefined);
@@ -841,7 +892,7 @@ async function processPdf(
841
892
  screenshot.imagePath = path.join(paths.images, `${screenshot.pageNum}.png`);
842
893
  }
843
894
 
844
- json = ocrResponse.pages.map(page => ({
895
+ json = mergedPages.map(page => ({
845
896
  page: page.index + 1,
846
897
  content: page.markdown,
847
898
  image: screenshots.find(s => s.pageNum === page.index + 1)?.imagePath,
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PDF Splitter — splits a PDF into fixed-size page chunks using PyMuPDF.
4
+
5
+ Outputs a JSON array to stdout, each element:
6
+ { "path": "<absolute-path>", "start_page": <int>, "end_page": <int> }
7
+
8
+ start_page is 0-indexed, end_page is exclusive (Python-slice convention).
9
+ If the document fits within chunk_size, a single entry pointing to the
10
+ original file is returned (no copy made).
11
+
12
+ Progress and diagnostics go to stderr so stdout stays clean JSON.
13
+
14
+ Usage:
15
+ split_pdf.py <input_pdf> <output_dir> [--chunk-size N]
16
+ """
17
+
18
+ import sys
19
+ import os
20
+ import json
21
+ import argparse
22
+
23
+ import fitz # PyMuPDF — installed as a docling transitive dependency
24
+
25
+
26
+ def split_pdf(input_path: str, output_dir: str, chunk_size: int) -> list[dict]:
27
+ doc = fitz.open(input_path)
28
+
29
+ # Some PDFs are saved with an empty owner/user password by certain writers
30
+ # (e.g. older Adobe Acrobat exports). The OS opens them transparently by
31
+ # trying "" first, but most libraries raise immediately. We replicate that
32
+ # OS-level behaviour here.
33
+ if doc.needs_pass:
34
+ authenticated = doc.authenticate("")
35
+ if not authenticated:
36
+ raise ValueError(
37
+ "PDF requires a non-empty password and cannot be opened automatically."
38
+ )
39
+ print("[split_pdf] Authenticated with empty password (phantom-password PDF)", file=sys.stderr)
40
+
41
+ total_pages = len(doc)
42
+ print(f"[split_pdf] Total pages: {total_pages}, chunk size: {chunk_size}", file=sys.stderr)
43
+
44
+ if total_pages <= chunk_size:
45
+ print("[split_pdf] No split needed — returning original path", file=sys.stderr)
46
+ doc.close()
47
+ return [{
48
+ "path": os.path.abspath(input_path),
49
+ "start_page": 0,
50
+ "end_page": total_pages,
51
+ }]
52
+
53
+ os.makedirs(output_dir, exist_ok=True)
54
+
55
+ chunks = []
56
+ for start_page in range(0, total_pages, chunk_size):
57
+ end_page = min(start_page + chunk_size, total_pages)
58
+ chunk_filename = f"chunk_{start_page}_{end_page - 1}.pdf"
59
+ chunk_path = os.path.join(output_dir, chunk_filename)
60
+
61
+ chunk_doc = fitz.open()
62
+ chunk_doc.insert_pdf(doc, from_page=start_page, to_page=end_page - 1)
63
+ chunk_doc.save(chunk_path)
64
+ chunk_doc.close()
65
+
66
+ chunks.append({
67
+ "path": os.path.abspath(chunk_path),
68
+ "start_page": start_page,
69
+ "end_page": end_page,
70
+ })
71
+ print(
72
+ f"[split_pdf] Chunk {len(chunks)}: pages {start_page}–{end_page - 1} → {chunk_filename}",
73
+ file=sys.stderr,
74
+ )
75
+
76
+ doc.close()
77
+ return chunks
78
+
79
+
80
+ if __name__ == "__main__":
81
+ parser = argparse.ArgumentParser(description="Split a PDF into fixed-size page chunks.")
82
+ parser.add_argument("input_pdf", help="Path to the input PDF")
83
+ parser.add_argument("output_dir", help="Directory to write chunk PDFs into")
84
+ parser.add_argument(
85
+ "--chunk-size",
86
+ type=int,
87
+ default=25,
88
+ help="Maximum pages per chunk (default: 25)",
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ try:
93
+ chunks = split_pdf(args.input_pdf, args.output_dir, args.chunk_size)
94
+ print(json.dumps(chunks))
95
+ except Exception as e:
96
+ print(f"[split_pdf] ERROR: {e}", file=sys.stderr)
97
+ sys.exit(1)
@@ -1,5 +1,6 @@
1
1
  import { Queue } from "bullmq";
2
2
  import { redisServer } from "./server";
3
+ import { guardRedisStartup, logRedisErrors } from "./redis-startup";
3
4
  import { BullMQOtel } from "bullmq-otel";
4
5
  import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config";
5
6
  import { checkLicense } from "@EE/entitlements";
@@ -115,6 +116,15 @@ class ExuluQueues {
115
116
  },
116
117
  telemetry: new BullMQOtel("simple-guide"),
117
118
  });
119
+ // Surface connection errors and FAIL FAST instead of hanging silently when Redis is down:
120
+ // wait for the connection to be ready (bounded by REDIS_STARTUP_TIMEOUT_MS) before using it.
121
+ logRedisErrors(newQueue, `queue "${name}"`);
122
+ try {
123
+ await guardRedisStartup(`queue "${name}"`, () => newQueue.waitUntilReady(), newQueue);
124
+ } catch (err) {
125
+ void newQueue.close().catch(() => { /* best-effort cleanup; we are aborting startup anyway */ });
126
+ throw err;
127
+ }
118
128
  await newQueue.setGlobalConcurrency(queueConcurrency);
119
129
  this.queues.push({
120
130
  queue: newQueue,
@@ -0,0 +1,121 @@
1
+ import { redisServer } from "./server";
2
+
3
+ /**
4
+ * Loud, bounded Redis startup helpers.
5
+ *
6
+ * Without these, a Redis-down boot hangs SILENTLY: the BullMQ queue/worker connections retry
7
+ * forever with no error listener and no timeout, so `exulu()` never returns and nothing is logged
8
+ * (you just see repeated `connect ETIMEDOUT 127.0.0.1:6379` from the socket layer, if anything).
9
+ *
10
+ * These helpers make a Redis-dependent startup step:
11
+ * 1. announce the target host:port it is connecting to,
12
+ * 2. surface the (otherwise swallowed) connection errors with address + code,
13
+ * 3. warn every few seconds while it is still blocked, and
14
+ * 4. FAIL FAST with a clear error after REDIS_STARTUP_TIMEOUT_MS instead of hanging forever.
15
+ */
16
+
17
+ /** Hard ceiling on a single Redis-dependent startup step before we abort instead of hanging. */
18
+ export const REDIS_STARTUP_TIMEOUT_MS = 60_000;
19
+ /** How often, while still blocked, to remind the operator that startup is stuck on Redis. */
20
+ const WATCHDOG_INTERVAL_MS = 10_000;
21
+ /** Throttle for the permanent per-connection error logger so a retry storm can't flood the log. */
22
+ const ERROR_LOG_THROTTLE_MS = 30_000;
23
+
24
+ const log = (line: string): void => console.log(`[EXULU-REDIS] ${line}`);
25
+ const warn = (line: string): void => console.warn(`[EXULU-REDIS] ${line}`);
26
+ const errorLog = (line: string): void => console.error(`[EXULU-REDIS] ${line}`);
27
+
28
+ /** The configured Redis target as `host:port` (with `(unset)` placeholders) for log/error messages. */
29
+ export const redisAddress = (): string =>
30
+ `${redisServer.host || "(unset)"}:${redisServer.port || "(unset)"}`;
31
+
32
+ /** One-line, human-readable description of a Redis/socket error (code first, message head, no stack). */
33
+ const describeError = (e: unknown): string => {
34
+ const any = e as any;
35
+ const head = any?.message ? String(any.message).split("\n")[0] : undefined;
36
+ if (any?.code) return head && head !== any.code ? `${any.code} (${head})` : `${any.code}`;
37
+ return head ?? String(e);
38
+ };
39
+
40
+ /** Minimal structural shape shared by ioredis, node-redis clients, and BullMQ Queue/Worker. */
41
+ type RedisErrorSource = {
42
+ on(event: "error", cb: (err: unknown) => void): unknown;
43
+ off?(event: "error", cb: (err: unknown) => void): unknown;
44
+ };
45
+
46
+ /**
47
+ * Attach a PERMANENT `error` listener that logs connection errors with the target address (the first
48
+ * immediately, then at most once per throttle window). Also prevents node-redis/ioredis from treating
49
+ * an `error` event as unhandled. Safe to call once per long-lived connection.
50
+ */
51
+ export function logRedisErrors(source: RedisErrorSource, label: string): void {
52
+ let count = 0;
53
+ let lastLoggedAt = 0;
54
+ source.on("error", (err) => {
55
+ count += 1;
56
+ const now = Date.now();
57
+ if (count === 1 || now - lastLoggedAt >= ERROR_LOG_THROTTLE_MS) {
58
+ errorLog(`${label} connection error (${redisAddress()}): ${describeError(err)}${count > 1 ? ` (x${count})` : ""}`);
59
+ lastLoggedAt = now;
60
+ }
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Run a Redis-dependent startup step with loud logging + a hard timeout. Transparent on success
66
+ * (returns `run()`'s value). While `run()` is pending it warns every WATCHDOG_INTERVAL_MS that startup
67
+ * is blocked; if it does not settle within REDIS_STARTUP_TIMEOUT_MS it REJECTS with a clear error
68
+ * (citing the address + last surfaced connection error) so the caller can fail the boot instead of
69
+ * hanging forever. `source`, if given, is observed only to capture the latest error for that message.
70
+ */
71
+ export async function guardRedisStartup<T>(
72
+ label: string,
73
+ run: () => Promise<T>,
74
+ source?: RedisErrorSource,
75
+ ): Promise<T> {
76
+ const addr = redisAddress();
77
+ log(`Connecting to Redis (${addr}) for ${label}…`);
78
+ const startedAt = Date.now();
79
+
80
+ let lastError: unknown;
81
+ const onError = (err: unknown): void => { lastError = err; };
82
+ source?.on("error", onError);
83
+
84
+ const watchdog = setInterval(() => {
85
+ const secs = Math.round((Date.now() - startedAt) / 1000);
86
+ warn(
87
+ `⚠ Still waiting for Redis at ${addr} after ${secs}s — ${label} startup is blocked. ` +
88
+ `Is Redis running? (aborting at ${REDIS_STARTUP_TIMEOUT_MS / 1000}s)`,
89
+ );
90
+ }, WATCHDOG_INTERVAL_MS);
91
+ // Don't let the watchdog timer keep the event loop alive on its own; the timeout below holds it.
92
+ (watchdog as { unref?: () => void }).unref?.();
93
+
94
+ let timer: ReturnType<typeof setTimeout> | undefined;
95
+ const timeout = new Promise<never>((_resolve, reject) => {
96
+ timer = setTimeout(() => {
97
+ reject(
98
+ new Error(
99
+ `[EXULU-REDIS] Redis unreachable at ${addr} after ${REDIS_STARTUP_TIMEOUT_MS / 1000}s — aborting ${label} startup. ` +
100
+ `Last error: ${lastError ? describeError(lastError) : "none surfaced"}. ` +
101
+ `Check REDIS_HOST/REDIS_PORT and that a Redis server is reachable at ${addr}.`,
102
+ ),
103
+ );
104
+ }, REDIS_STARTUP_TIMEOUT_MS);
105
+ });
106
+
107
+ // Mark the work promise handled so a rejection that lands AFTER the timeout already won the race
108
+ // does not surface as an unhandledRejection.
109
+ const runPromise = Promise.resolve().then(run);
110
+ runPromise.catch(() => { /* handled via the race below or intentionally ignored post-timeout */ });
111
+
112
+ try {
113
+ const result = await Promise.race([runPromise, timeout]);
114
+ log(`Redis ready; ${label} initialized (${addr}, ${((Date.now() - startedAt) / 1000).toFixed(1)}s).`);
115
+ return result as T;
116
+ } finally {
117
+ clearInterval(watchdog);
118
+ if (timer) clearTimeout(timer);
119
+ source?.off?.("error", onError);
120
+ }
121
+ }
package/ee/workers.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import IORedis from "ioredis";
2
2
  import { redisServer } from "@EE/queues/server.ts";
3
+ import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
3
4
  import { Job, Worker, type JobState } from "bullmq";
4
5
  import { bullmq } from "@SRC/validators/bullmq.ts";
5
6
  import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
@@ -158,6 +159,11 @@ export const createWorkers = async (
158
159
  },
159
160
  maxRetriesPerRequest: null,
160
161
  });
162
+ // Surface connection errors and FAIL FAST instead of hanging silently when Redis is down:
163
+ // confirm the connection is actually reachable (bounded by REDIS_STARTUP_TIMEOUT_MS) before
164
+ // handing it to the workers, which would otherwise block on their first operation forever.
165
+ logRedisErrors(redisConnection, "worker");
166
+ await guardRedisStartup("workers", () => redisConnection.ping().then(() => undefined), redisConnection);
161
167
  }
162
168
 
163
169
  const workers = queues.map((queue) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "1.68.0",
4
+ "version": "1.69.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {