UNPKG

@decaf-ts/utils

Version:

module management utils for decaf-ts

409 lines 17 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ConsumerRunner = exports.reportingComparer = exports.defaultComparer = void 0; const node_child_process_1 = require("node:child_process"); const node_path_1 = require("node:path"); const logging_1 = require("@decaf-ts/logging"); const TestReporter_1 = require("./TestReporter.cjs"); /** * @description Parses a log string into a ParsedLog object. * @summary Splits the log string by " - " and extracts timestamp, child, and action. * @param {string} data - The log string to parse. * @return {ParsedLog} The parsed log object. * @function parseData * @memberOf module:utils */ const parseData = (data) => { const [timestamp, , child, action] = data.split(" - "); return { timestamp: parseInt(timestamp, 10), child, action, }; }; /** * @description Default comparer function for consumer and producer logs. * @summary Sorts and compares consumer and producer logs to ensure they match. * @param {LogStore} consumerData - The consumer logs. * @param {LogStore} producerData - The producer logs. * @return {Promise<ComparerResult>} The comparison result. * @function defaultComparer * @memberOf module:utils */ const defaultComparer = async (consumerData, producerData) => { const sortedConsumerData = Object.keys(consumerData) .reduce((accum, key) => { const identifier = Number(key); const entries = consumerData[identifier] ?? []; accum.push(...entries.map((entry) => parseData(entry))); return accum; }, []) .sort((a, b) => a.timestamp - b.timestamp); const sortedProducerData = Object.keys(producerData) .reduce((accum, key) => { const identifier = Number(key); const entries = producerData[identifier] ?? []; accum.push(...entries.map((entry) => parseData(entry))); return accum; }, []) .sort((a, b) => a.timestamp - b.timestamp); if (sortedProducerData.length !== sortedConsumerData.length) { throw new Error("Producer data and consumer data does not match in length"); } let counter = -1; const isMatching = sortedProducerData.every((producer, index) => { counter = index; const consumer = sortedConsumerData[index]; return (producer.child === consumer.child && producer.action === consumer.action); }); if (!isMatching) { const errorLines = [ `Producer data and consumer data do not sort the same way as of record ${counter}:`, " | CONSUMER | PRODUCER |", " | id | action | timestamp | id | action | timestamp |", ]; sortedProducerData.forEach((producer, index) => { if (index < counter || index > counter + 15) { return; } const consumer = sortedConsumerData[index]; errorLines.push(` ${index < 10 ? `0${index}` : index}| ${consumer.child} | ${consumer.action} | ${consumer.timestamp} | ${producer.child} | ${producer.action} | ${producer.timestamp} |`); }); throw new Error(errorLines.join("\n")); } return { consumer: sortedConsumerData, producer: sortedProducerData, }; }; exports.defaultComparer = defaultComparer; /** * @description Formats a timestamp into an ISO string. * @summary Converts a numeric timestamp to an ISO 8601 string. * @param {number} value - The timestamp to format. * @return {string} The formatted date string. * @function formatTimestamp * @memberOf module:utils */ const formatTimestamp = (value) => new Date(value).toISOString(); /** * @description Comparer function that reports results using TestReporter. * @summary Compares logs and generates a report with tables and messages. * @param {LogStore} consumerData - The consumer logs. * @param {LogStore} producerData - The producer logs. * @param {ReportingComparerOptions} [options] - Options for reporting. * @return {Promise<ComparerResult>} The comparison result. * @function reportingComparer * @memberOf module:utils */ const reportingComparer = async (consumerData, producerData, options) => { const reporter = options?.reporter ?? new TestReporter_1.TestReporter(options?.testCase ?? "consumer-producer"); const referencePrefix = options?.referencePrefix ?? "consumer-producer"; try { const comparison = await (0, exports.defaultComparer)(consumerData, producerData); const rows = comparison.consumer.map((consumerEntry, index) => { const producerEntry = comparison.producer[index]; return { Index: `${index}`, "Consumer Child": consumerEntry.child, "Consumer Action": consumerEntry.action, "Consumer Timestamp": formatTimestamp(consumerEntry.timestamp), "Producer Child": producerEntry?.child ?? "N/A", "Producer Action": producerEntry?.action ?? "N/A", "Producer Timestamp": producerEntry ? formatTimestamp(producerEntry.timestamp) : "N/A", }; }); await Promise.allSettled([ reporter.reportMessage(`${referencePrefix}-comparison`, `Consumer and producer logs matched (${comparison.consumer.length} entries).`), reporter.reportTable(`${referencePrefix}-logs`, { headers: [ "Index", "Consumer Child", "Consumer Action", "Consumer Timestamp", "Producer Child", "Producer Action", "Producer Timestamp", ], rows, }), ]); return comparison; } catch (error) { const message = error instanceof Error ? error.message : String(error ?? "Unknown error"); await Promise.allSettled([ reporter.reportMessage(`${referencePrefix}-mismatch`, message), reporter.reportObject(`${referencePrefix}-consumer`, consumerData), reporter.reportObject(`${referencePrefix}-producer`, producerData), ]); throw error; } }; exports.reportingComparer = reportingComparer; /** * @class ConsumerRunner * @description Runs a consumer process and manages producer child processes. * @summary Orchestrates the execution of consumer and producer processes, collects logs, and compares results. * @param {string} action - The action name. * @param {ConsumerHandler} consumerHandler - The handler function for the consumer. * @param {Comparer} [compareHandler] - Optional custom comparer function. * @memberOf module:utils */ class ConsumerRunner extends logging_1.LoggedClass { constructor(action, consumerHandler, compareHandler) { super(); this.forkedCache = []; this.consumerResults = {}; this.producerResults = {}; this.childExitPromises = []; this.completionTriggered = false; this.producerCompletion = 0; this.consumerCompletion = 0; this.expectedIterations = 0; this.activeHandlers = 0; this.action = action; this.handler = consumerHandler; this.comparerHandle = compareHandler ?? exports.defaultComparer; this.reset(); } reset() { this.forkedCache = []; this.consumerResults = {}; this.producerResults = {}; this.completionTriggered = false; this.childExitPromises = []; this.activeHandlers = 0; this.producerCompletion = 0; this.consumerCompletion = 0; this.expectedIterations = 0; } waitForChildExit() { if (!this.childExitPromises?.length) { return Promise.resolve(); } const exits = [...this.childExitPromises]; this.childExitPromises = []; return Promise.allSettled(exits).then(() => void 0); } store(identifier, action, timeout, times, count, random) { const logParts = [ Date.now(), "PRODUCER", identifier, action, ]; if (timeout) { logParts.push(timeout); } if (times && count) { logParts.push(`${count}/${times}`, random ?? false); } const log = logParts.join(" - "); if (!this.producerResults[identifier]) { this.producerResults[identifier] = []; } const logs = this.producerResults[identifier]; logs.push(log); const totalTimes = times ?? this.expectedIterations; if (totalTimes > 0 && logs.length === totalTimes) { this.producerCompletion += 1; } } recordConsumer(identifier, times) { const logParts = [ Date.now(), "CONSUMER", identifier, this.action, ]; const log = logParts.join(" - "); if (!this.consumerResults[identifier]) { this.consumerResults[identifier] = []; } const logs = this.consumerResults[identifier]; logs.push(log); const totalTimes = times ?? this.expectedIterations; if (totalTimes > 0 && logs.length === totalTimes) { this.consumerCompletion += 1; } } isProducerComplete(count) { return this.producerCompletion >= count; } isConsumerComplete(count) { return this.consumerCompletion >= count; } terminateChildren(forceKill = false) { if (!this.forkedCache) { return this.waitForChildExit(); } const cached = this.forkedCache; this.forkedCache = undefined; cached.forEach((forked, index) => { if (!forked.connected && !forceKill) { return; } try { forked.send({ identifier: index, terminate: true, }); } catch { // IPC channel already closed; nothing else to do. } if (forceKill && !forked.killed) { forked.kill(); } }); return this.waitForChildExit(); } /** * @description Runs the consumer and producer processes. * @summary Starts the producer child processes and the consumer handler, then waits for completion and compares results. * @param {number} count - The number of producers. * @param {number} [timeout] - The timeout for producers. * @param {number} times - The number of times to repeat. * @param {boolean} [random] - Whether to use random timeouts. * @return {Promise<ComparerResult>} The comparison result. * @mermaid * sequenceDiagram * participant Runner as ConsumerRunner * participant Child as ProducerChild * participant Handler as ConsumerHandler * participant Comparer as Comparer * Runner->>Runner: reset() * loop For each count * Runner->>Child: fork() * Runner->>Runner: Store child process * end * Runner->>Child: send(start message) * loop For each message from Child * Child->>Runner: message(action) * Runner->>Runner: store producer log * Runner->>Handler: call handler * Handler-->>Runner: return * Runner->>Runner: record consumer log * Runner->>Runner: finalizeIfComplete() * end * alt Complete * Runner->>Comparer: compare logs * Comparer-->>Runner: return result * Runner-->>Caller: resolve(result) * end */ async run(count, timeout, times, random) { this.reset(); this.expectedIterations = times; const childPath = (0, node_path_1.join)(__dirname, "ProducerChildProcess.cjs"); return new Promise((resolve, reject) => { const snapshotState = () => { const summarize = (records) => Object.keys(records).reduce((acc, key) => { acc[key] = records[Number(key)]?.length ?? 0; return acc; }, {}); return { producers: summarize(this.producerResults), consumers: summarize(this.consumerResults), activeHandlers: this.activeHandlers, }; }; const handleError = (error) => { if (this.completionTriggered) { return; } this.completionTriggered = true; Promise.resolve(this.terminateChildren(true)).finally(() => reject(error)); }; const finalizeIfComplete = () => { if (this.completionTriggered) { return; } if (!this.isProducerComplete(count) || !this.isConsumerComplete(count) || this.activeHandlers > 0) { return; } this.completionTriggered = true; if (process.env.DEBUG_CONSUMER_RUNNER === "1") { console.debug("ConsumerRunner finalize state", snapshotState()); } try { const comparisonPromise = Promise.resolve(this.comparerHandle(this.consumerResults, this.producerResults)); Promise.all([comparisonPromise, this.waitForChildExit()]) .then(async ([comparison]) => { await new Promise((resolveDelay) => setImmediate(resolveDelay)); resolve(comparison); }) .catch(reject); } catch (error) { reject(error); } }; for (let identifier = 1; identifier < count + 1; identifier += 1) { const forked = (0, node_child_process_1.fork)(childPath); this.forkedCache?.push(forked); this.childExitPromises?.push(new Promise((resolveChild) => { forked.once("exit", () => resolveChild()); })); forked.on("error", handleError); forked.on("message", async (message) => { if (this.completionTriggered) { return; } const { identifier: childId, args, action, timeout: childTimeout, times: childTimes, random: childRandom, } = message; this.activeHandlers += 1; let handlerFailed = false; if (process.env.DEBUG_CONSUMER_RUNNER === "1") { console.debug("ConsumerRunner message:start", { childId, producerCount: this.producerResults[childId]?.length ?? 0, consumerCount: this.consumerResults[childId]?.length ?? 0, activeHandlers: this.activeHandlers, }); } try { this.store(childId, action, childTimeout, childTimes, count, childRandom); const handlerArgs = Array.isArray(args) ? args : []; await Promise.resolve(this.handler(childId, ...handlerArgs)); this.recordConsumer(childId, childTimes ?? times); if (process.env.DEBUG_CONSUMER_RUNNER === "1") { console.debug("ConsumerRunner message:complete", { childId, producerCount: this.producerResults[childId]?.length ?? 0, consumerCount: this.consumerResults[childId]?.length ?? 0, activeHandlers: this.activeHandlers, }); } } catch (error) { handlerFailed = true; handleError(error); } finally { this.activeHandlers = Math.max(0, this.activeHandlers - 1); if (!handlerFailed) { finalizeIfComplete(); } } }); } this.forkedCache?.forEach((forked, index) => { forked.send({ identifier: index, action: this.action, timeout, times, random, }); }); }); } } exports.ConsumerRunner = ConsumerRunner; //# sourceMappingURL=Consumer.js.map