|
1 | | -import { queue, QueueObject } from 'async'; |
| 1 | +import { EventEmitter } from 'node:events'; |
| 2 | + |
| 3 | +import WorkerPool from '../lib/workerPool'; |
| 4 | +import { isNodeError, verbose } from '../utils'; |
2 | 5 | import FileTooLargeError from './fileTooLargeError'; |
3 | | -import Fingerprinter from './fingerprinter'; |
4 | | -import { isNodeError } from '../utils'; |
| 6 | +import { |
| 7 | + FINGERPRINT_WORKER_FILE, |
| 8 | + type FingerprintTask, |
| 9 | + type FingerprintTaskResult, |
| 10 | +} from './fingerprintWorker'; |
| 11 | +import type { FingerprintEvent } from './fingerprinter'; |
5 | 12 |
|
6 | | -export default class FingerprintQueue { |
7 | | - public handler: Fingerprinter; |
8 | | - public failOnError = true; |
| 13 | +/** |
| 14 | + * FingerprintQueue manages parallel processing of AppMap files using a worker pool. |
| 15 | + * |
| 16 | + * Each AppMap file is processed by a worker thread which: |
| 17 | + * 1. Reads and validates the AppMap file |
| 18 | + * 2. Generates a fingerprint (canonicalized representation) |
| 19 | + * 3. Saves the fingerprint to the index directory |
| 20 | + * |
| 21 | + * Error handling: |
| 22 | + * - FileTooLargeError: Logs a warning and skips the file |
| 23 | + * - ENOENT (file not found): Logs a warning and skips the file |
| 24 | + * - Other errors: Stores the error and continues processing other files |
| 25 | + * The error will be thrown when process() is called |
| 26 | + * |
| 27 | + * Events: |
| 28 | + * - 'index': Emitted when an AppMap is successfully fingerprinted |
| 29 | + * Payload: FingerprintEvent containing numEvents and metadata |
| 30 | + * |
| 31 | + * Example: |
| 32 | + * ```typescript |
| 33 | + * const queue = new FingerprintQueue(4); // 4 worker threads |
| 34 | + * queue.on('index', ({ numEvents, metadata }) => { |
| 35 | + * console.log(`Indexed AppMap with ${numEvents} events`); |
| 36 | + * }); |
| 37 | + * queue.push('path/to/appmap.json'); |
| 38 | + * await queue.process(); // Wait for all files to be processed |
| 39 | + * ``` |
| 40 | + * |
| 41 | + * @emits index - Emitted when an AppMap is successfully fingerprinted |
| 42 | + */ |
| 43 | +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging |
| 44 | +class FingerprintQueue extends EventEmitter { |
9 | 45 | private lastError: unknown; |
10 | | - private queue: QueueObject<string>; |
11 | 46 | private pending = new Set<string>(); |
| 47 | + private pool: WorkerPool; |
12 | 48 |
|
13 | | - constructor(private size = 2) { |
14 | | - // eslint-disable-next-line no-use-before-define |
15 | | - this.handler = new Fingerprinter(); |
16 | | - this.queue = queue(async (appmapFileName) => { |
17 | | - try { |
18 | | - await this.handler.fingerprint(appmapFileName); |
19 | | - } catch (e) { |
20 | | - console.warn(`Error fingerprinting ${appmapFileName}: ${e}`); |
21 | | - } |
22 | | - this.pending.delete(appmapFileName); |
23 | | - }, this.size); |
24 | | - this.queue.drain(() => (this.handler.checkVersion = false)); |
25 | | - this.queue.error((error) => { |
26 | | - if (error instanceof FileTooLargeError) { |
| 49 | + constructor(numThreads = 2) { |
| 50 | + super(); |
| 51 | + this.pool = new WorkerPool(FINGERPRINT_WORKER_FILE, numThreads); |
| 52 | + } |
| 53 | + |
| 54 | + #processed(err: Error | null, { error, result, appmapFile }: FingerprintTaskResult) { |
| 55 | + this.pending.delete(appmapFile); |
| 56 | + if (err) { |
| 57 | + console.warn(`Error fingerprinting ${appmapFile}: ${String(err)}`); |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + if (error) { |
| 62 | + // eslint-disable-next-line no-param-reassign |
| 63 | + error = WorkerPool.recoverError(error); |
| 64 | + // Note errors transported over IPC won't have the same prototype. |
| 65 | + if (error.name === 'FileTooLargeError') { |
27 | 66 | console.warn( |
28 | 67 | [ |
29 | 68 | `Skipped: ${error.message}`, |
30 | 69 | 'Tip: consider recording a shorter interaction or removing some classes from appmap.yml.', |
31 | 70 | ].join('\n') |
32 | 71 | ); |
33 | 72 | } else if (isNodeError(error, 'ENOENT')) { |
34 | | - console.warn(`Skipped: ${error.path}\nThe file does not exist.`); |
35 | | - } else if (this.failOnError) { |
36 | | - this.lastError = error; |
| 73 | + console.warn(`Skipped: ${appmapFile}\nThe file does not exist.`); |
37 | 74 | } else { |
38 | | - console.warn(`Skipped: ${error}`); |
| 75 | + console.warn(`Skipped: ${String(error)}`); |
| 76 | + this.lastError = err ?? error; |
39 | 77 | } |
40 | | - }); |
| 78 | + } |
| 79 | + if (result) this.emit('index', result); |
41 | 80 | } |
42 | 81 |
|
43 | 82 | async process() { |
44 | | - if (!this.queue.idle()) await this.queue.drain(); |
| 83 | + await this.pool.drain(); |
45 | 84 | if (this.lastError) throw this.lastError; |
46 | 85 | } |
47 | 86 |
|
48 | 87 | push(job: string) { |
49 | 88 | if (this.pending.has(job)) return; |
50 | 89 | this.pending.add(job); |
51 | | - this.queue.push(job); |
| 90 | + this.pool.runTask(makeTask(job), this.#processed.bind(this)); |
52 | 91 | } |
53 | 92 | } |
| 93 | + |
| 94 | +function makeTask(appmapFile: string): FingerprintTask { |
| 95 | + return { |
| 96 | + verbose: verbose(), |
| 97 | + appmapFile, |
| 98 | + }; |
| 99 | +} |
| 100 | + |
| 101 | +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging |
| 102 | +interface FingerprintQueue { |
| 103 | + on(event: 'index', listener: (data: FingerprintEvent) => void): this; |
| 104 | + emit(event: 'index', data: FingerprintEvent): boolean; |
| 105 | +} |
| 106 | + |
| 107 | +export default FingerprintQueue; |
0 commit comments