@exulu/backend 1.67.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.
@@ -2,7 +2,7 @@ import { generateText, stepCountIs, tool } from "ai";
2
2
  import type { LanguageModel, Tool as AITool, ModelMessage } from "ai";
3
3
  import { z } from "zod";
4
4
  import { withRetry } from "@SRC/utils/with-retry";
5
- import type { ExuluReranker } from "@SRC/exulu/reranker";
5
+ import type { ResolvedReranker } from "@SRC/exulu/resolve-reranker";
6
6
  import type { AgenticRetrievalOutput, ChunkResult, ClassificationResult } from "./types";
7
7
  import type { StrategyConfig } from "./strategies";
8
8
  import { createDynamicTools } from "./dynamic-tools";
@@ -69,7 +69,7 @@ export async function* runAgentLoop(params: {
69
69
  strategy: StrategyConfig;
70
70
  tools: Record<string, AITool>;
71
71
  model: LanguageModel;
72
- reranker?: ExuluReranker;
72
+ reranker?: ResolvedReranker;
73
73
  contextGuidance?: string;
74
74
  customInstructions?: string;
75
75
  classification: ClassificationResult;
@@ -171,8 +171,8 @@ export async function* runAgentLoop(params: {
171
171
 
172
172
  // Rerank if reranker is available
173
173
  if (reranker && stepChunks.length > 0) {
174
- console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.name}`);
175
- stepChunks = await reranker.run(query, stepChunks as any);
174
+ console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.model}`);
175
+ stepChunks = await reranker.rerank(query, stepChunks);
176
176
  }
177
177
 
178
178
  // Create dynamic tools (browse adjacent pages, load specific pages)
@@ -2,7 +2,8 @@ import { z } from "zod";
2
2
  import { createBashTool } from "bash-tool";
3
3
  import type { LanguageModel, Tool } from "ai";
4
4
  import type { ExuluContext } from "@SRC/exulu/context";
5
- import type { ExuluReranker } from "@SRC/exulu/reranker";
5
+ import { resolveReranker } from "@SRC/exulu/resolve-reranker";
6
+ import type { ResolvedReranker } from "@SRC/exulu/resolve-reranker";
6
7
  import { ExuluTool } from "@SRC/exulu/tool";
7
8
  import type { User } from "@EXULU_TYPES/models/user";
8
9
  import { checkLicense } from "@EE/entitlements";
@@ -34,7 +35,7 @@ async function* executeV3({
34
35
  }: {
35
36
  query: string;
36
37
  contexts: ExuluContext[];
37
- reranker?: ExuluReranker;
38
+ reranker?: ResolvedReranker;
38
39
  toolVariablesConfig?: Record<string, any>;
39
40
  model: LanguageModel;
40
41
  user?: User;
@@ -189,7 +190,6 @@ async function* executeV3({
189
190
  export function createAgenticRetrievalToolV3({
190
191
  contexts,
191
192
  instructions: adminInstructions,
192
- rerankers,
193
193
  user,
194
194
  role,
195
195
  model,
@@ -197,7 +197,6 @@ export function createAgenticRetrievalToolV3({
197
197
  memoryItems
198
198
  }: {
199
199
  contexts: ExuluContext[];
200
- rerankers: ExuluReranker[];
201
200
  user?: User;
202
201
  role?: string;
203
202
  model?: LanguageModel;
@@ -355,7 +354,7 @@ export function createAgenticRetrievalToolV3({
355
354
  }
356
355
 
357
356
  let activeContexts = contexts;
358
- let configuredReranker: ExuluReranker | undefined;
357
+ let configuredReranker: ResolvedReranker | undefined;
359
358
  let configInstructions = "";
360
359
  let logTrajectory = false;
361
360
  let requiresPreselectedContexts = false;
@@ -382,7 +381,22 @@ export function createAgenticRetrievalToolV3({
382
381
  const rerankerId = toolVariablesConfig["reranker"];
383
382
 
384
383
  if (rerankerId && rerankerId !== "none") {
385
- configuredReranker = rerankers.find((r) => r.id === rerankerId);
384
+ // rerankerId is a LiteLLM model_name from config.litellm.yaml
385
+ // (model_info.type: reranker). Resolution is best-effort: a
386
+ // misconfigured model or an unready proxy must not break retrieval —
387
+ // it just runs unreranked, matching the old find()→undefined path.
388
+ try {
389
+ configuredReranker = await resolveReranker({
390
+ model: rerankerId,
391
+ user,
392
+ roleId: role,
393
+ });
394
+ } catch (err) {
395
+ console.warn(
396
+ `[EXULU] v3 — could not resolve reranker "${rerankerId}", continuing without reranking:`,
397
+ err,
398
+ );
399
+ }
386
400
  }
387
401
  }
388
402
 
@@ -14,17 +14,48 @@ import { checkLicense } from '@EE/entitlements';
14
14
  import { executePythonScript } from '@SRC/utils/python-executor';
15
15
  import { setupPythonEnvironment, validatePythonEnvironment } from '@SRC/utils/python-setup';
16
16
  import { LiteParse } from '@llamaindex/liteparse';
17
- import { Mistral } from '@mistralai/mistralai';
18
- import { ExuluVariables } from '@SRC/index';
17
+ import { resolveOcr } from '@SRC/exulu/resolve-ocr';
18
+ import type { ResolveOcrInput } from '@SRC/exulu/resolve-ocr';
19
+ import { resolveModel } from '@SRC/exulu/resolve-model';
19
20
 
20
21
  type DocumentProcessorConfig = {
21
22
  vlm?: {
22
- model: LanguageModel;
23
+ /**
24
+ * LiteLLM model_name for the VLM page-validation pass (declared in
25
+ * config.litellm.yaml, e.g. "vertex-gemini-2.5-flash"). Resolved via
26
+ * resolveModel() so the VLM pass shares the same tag-based cost controls
27
+ * and provider-switching as chat / embeddings / OCR, and the underlying
28
+ * provider can be swapped without code changes.
29
+ */
30
+ model: string;
23
31
  concurrency: number;
24
32
  },
25
33
  processor: {
26
34
  name: "docling" | "liteparse" | "mistral" | "officeparser"
35
+ /**
36
+ * LiteLLM model_name for the "mistral" OCR processor (declared in
37
+ * config.litellm.yaml). Defaults to "mistral-ocr". OCR is routed through
38
+ * the LiteLLM proxy so it shares the same tag-based cost controls as chat
39
+ * and embeddings, and the underlying provider (mistral / azure_ai /
40
+ * vertex_ai) can be switched without code changes.
41
+ */
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
27
50
  }
51
+ /**
52
+ * Optional cost-attribution context, forwarded to LiteLLM as spend tags
53
+ * (user / role / project / context) for both the OCR pass (resolveOcr) and
54
+ * the VLM page-validation pass (resolveModel). Not yet populated by callers;
55
+ * the wiring is in place so per-user/per-context budgets work the moment
56
+ * attribution is threaded through.
57
+ */
58
+ attribution?: Omit<ResolveOcrInput, "model">
28
59
  debugging?: {
29
60
  deleteTempFiles?: boolean;
30
61
  }
@@ -94,6 +125,38 @@ async function processWord(file: Buffer): Promise<ProcessorOutput> {
94
125
  }
95
126
  }
96
127
 
128
+ /**
129
+ * Resolve the dev-supplied VLM `model` string (a LiteLLM model_name from
130
+ * config.litellm.yaml, e.g. "vertex-gemini-2.5-flash") into an `ai` SDK
131
+ * LanguageModel via resolveModel. This routes the VLM page-validation pass
132
+ * through the LiteLLM proxy — same tag-based cost controls and provider
133
+ * switching as chat / embeddings / OCR — and keeps the internal VLM helpers
134
+ * (validateWithVLM / validatePageWithVLM) working with a LanguageModel.
135
+ *
136
+ * Returns undefined when no VLM model is configured. Attribution (user /
137
+ * project / agent / routine) is forwarded for spend tagging when callers
138
+ * populate config.attribution; rbacBypass is set because this is a background
139
+ * package call where model-level access control is delegated to LiteLLM.
140
+ */
141
+ async function resolveVlmModel(
142
+ config?: DocumentProcessorConfig,
143
+ ): Promise<LanguageModel | undefined> {
144
+ const modelId = config?.vlm?.model;
145
+ if (!modelId) return undefined;
146
+
147
+ const { languageModel } = await resolveModel({
148
+ modelId,
149
+ providers: [], // unused in LiteLLM mode; resolveModel ignores it there
150
+ user: config?.attribution?.user,
151
+ project: config?.attribution?.project,
152
+ agent: config?.attribution?.agent,
153
+ routine: config?.attribution?.routine,
154
+ rbacBypass: true,
155
+ });
156
+
157
+ return languageModel;
158
+ }
159
+
97
160
  /**
98
161
  * Processes a standalone image file by optionally extracting content using VLM
99
162
  */
@@ -122,14 +185,15 @@ async function processImage(
122
185
  }];
123
186
 
124
187
  // If VLM is enabled, use it to extract content from the image
125
- if (config?.vlm?.model) {
188
+ const vlmModel = await resolveVlmModel(config);
189
+ if (vlmModel) {
126
190
  console.log('[EXULU] Extracting content from image using VLM...');
127
191
 
128
192
  json = await validateWithVLM(
129
193
  json,
130
- config.vlm.model,
194
+ vlmModel,
131
195
  verbose,
132
- config.vlm.concurrency
196
+ config!.vlm!.concurrency
133
197
  );
134
198
 
135
199
  // Save the processed result
@@ -679,15 +743,6 @@ async function processDocument(
679
743
  };
680
744
  }
681
745
 
682
- const getMistralApiKey = async () => {
683
- if (process.env.MISTRAL_API_KEY) {
684
- return process.env.MISTRAL_API_KEY;
685
- } else {
686
- const variable = await ExuluVariables.get("MISTRAL_API_KEY");
687
- return variable;
688
- }
689
- }
690
-
691
746
  async function processPdf(
692
747
  buffer: Buffer,
693
748
  paths: ProcessingPaths,
@@ -759,29 +814,70 @@ async function processPdf(
759
814
 
760
815
  } else if (config?.processor.name === "mistral") {
761
816
 
762
- const MISTRAL_API_KEY = await getMistralApiKey();
763
- if (!MISTRAL_API_KEY) {
764
- throw new Error('[EXULU] MISTRAL_API_KEY is not set, please set it in the environment variable via process.env or via an Exulu variable named "MISTRAL_API_KEY".');
765
- }
817
+ // OCR is routed through the LiteLLM proxy's Mistral-compatible /v1/ocr
818
+ // endpoint (see resolveOcr) rather than the Mistral SDK directly. This
819
+ // gives us tag-based cost control and lets us switch the OCR provider
820
+ // (mistral / azure_ai / vertex_ai) from config.litellm.yaml.
821
+ const resolved = await resolveOcr({
822
+ model: config.processor.model ?? "mistral-ocr",
823
+ ...config.attribution,
824
+ });
766
825
 
767
- // Wait a randomn time between 1 and 5 seconds to prevent rate limiting
768
- 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
+ });
769
840
 
770
- const base64Pdf = buffer.toString('base64');
771
- const client = new Mistral({ apiKey: MISTRAL_API_KEY });
841
+ const pdfChunks: Array<{ path: string; start_page: number; end_page: number }> =
842
+ JSON.parse(splitResult.stdout);
772
843
 
773
- const ocrResponse = await withRetry(async () => {
774
- type MistralOCRResponse = Awaited<ReturnType<typeof client.ocr.process>>;
775
- const ocrResponse: MistralOCRResponse = await client.ocr.process({
776
- document: {
777
- type: "document_url",
778
- documentUrl: "data:application/pdf;base64," + base64Pdf
779
- },
780
- model: "mistral-ocr-latest",
781
- includeImageBase64: false
782
- });
783
- return ocrResponse;
784
- }, 10);
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);
849
+
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));
856
+
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
+ );
785
881
 
786
882
  const parser = new LiteParse();
787
883
  const screenshots = await parser.screenshot(paths.source, undefined);
@@ -796,7 +892,7 @@ async function processPdf(
796
892
  screenshot.imagePath = path.join(paths.images, `${screenshot.pageNum}.png`);
797
893
  }
798
894
 
799
- json = ocrResponse.pages.map(page => ({
895
+ json = mergedPages.map(page => ({
800
896
  page: page.index + 1,
801
897
  content: page.markdown,
802
898
  image: screenshots.find(s => s.pageNum === page.index + 1)?.imagePath,
@@ -838,13 +934,14 @@ async function processPdf(
838
934
  }
839
935
 
840
936
  // Apply VLM validation if enabled
841
- if (config?.vlm?.model && json.length > 0) {
937
+ const vlmModel = config?.vlm?.model ? await resolveVlmModel(config) : undefined;
938
+ if (vlmModel && json.length > 0) {
842
939
 
843
940
  json = await validateWithVLM(
844
941
  json,
845
- config.vlm.model,
942
+ vlmModel,
846
943
  verbose,
847
- config.vlm.concurrency
944
+ config!.vlm!.concurrency
848
945
  );
849
946
 
850
947
  console.log('[EXULU] \n📊 Processing Summary:');
@@ -1046,7 +1143,6 @@ export async function documentProcessor({
1046
1143
  } catch (error) {
1047
1144
  console.error('Error during chunking:', error);
1048
1145
  throw error;
1049
-
1050
1146
  } finally {
1051
1147
  if (config?.debugging?.deleteTempFiles !== false) {
1052
1148
  // Delete the temp directory using the local array to avoid race conditions
@@ -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
+ }