254 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			254 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * @license | ||
|  |  * Copyright Google Inc. All Rights Reserved. | ||
|  |  * | ||
|  |  * Use of this source code is governed by an MIT-style license that can be | ||
|  |  * found in the LICENSE file at https://angular.io/license
 | ||
|  |  */ | ||
|  | 
 | ||
|  | /// <reference types="node" />
 | ||
|  | 
 | ||
|  | import * as cluster from 'cluster'; | ||
|  | 
 | ||
|  | import {resolve} from '../../../../src/ngtsc/file_system'; | ||
|  | import {Logger} from '../../logging/logger'; | ||
|  | import {PackageJsonUpdater} from '../../writing/package_json_updater'; | ||
|  | import {AnalyzeEntryPointsFn, Task, TaskQueue} from '../api'; | ||
|  | import {onTaskCompleted, stringifyTask} from '../utils'; | ||
|  | 
 | ||
|  | import {MessageFromWorker, TaskCompletedMessage, UpdatePackageJsonMessage} from './api'; | ||
|  | import {Deferred, sendMessageToWorker} from './utils'; | ||
|  | 
 | ||
|  | 
 | ||
|  | /** | ||
|  |  * The cluster master is responsible for analyzing all entry-points, planning the work that needs to | ||
|  |  * be done, distributing it to worker-processes and collecting/post-processing the results. | ||
|  |  */ | ||
|  | export class ClusterMaster { | ||
|  |   private finishedDeferred = new Deferred<void>(); | ||
|  |   private taskAssignments = new Map<number, Task|null>(); | ||
|  |   private taskQueue: TaskQueue; | ||
|  | 
 | ||
|  |   constructor( | ||
|  |       private workerCount: number, private logger: Logger, | ||
|  |       private pkgJsonUpdater: PackageJsonUpdater, analyzeEntryPoints: AnalyzeEntryPointsFn) { | ||
|  |     if (!cluster.isMaster) { | ||
|  |       throw new Error('Tried to instantiate `ClusterMaster` on a worker process.'); | ||
|  |     } | ||
|  | 
 | ||
|  |     this.taskQueue = analyzeEntryPoints(); | ||
|  |   } | ||
|  | 
 | ||
|  |   run(): Promise<void> { | ||
|  |     // Set up listeners for worker events (emitted on `cluster`).
 | ||
|  |     cluster.on('online', this.wrapEventHandler(worker => this.onWorkerOnline(worker.id))); | ||
|  | 
 | ||
|  |     cluster.on( | ||
|  |         'message', this.wrapEventHandler((worker, msg) => this.onWorkerMessage(worker.id, msg))); | ||
|  | 
 | ||
|  |     cluster.on( | ||
|  |         'exit', | ||
|  |         this.wrapEventHandler((worker, code, signal) => this.onWorkerExit(worker, code, signal))); | ||
|  | 
 | ||
|  |     // Start the workers.
 | ||
|  |     for (let i = 0; i < this.workerCount; i++) { | ||
|  |       cluster.fork(); | ||
|  |     } | ||
|  | 
 | ||
|  |     return this.finishedDeferred.promise.then(() => this.stopWorkers(), err => { | ||
|  |       this.stopWorkers(); | ||
|  |       return Promise.reject(err); | ||
|  |     }); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Try to find available (idle) workers and assign them available (non-blocked) tasks. */ | ||
|  |   private maybeDistributeWork(): void { | ||
|  |     let isWorkerAvailable = false; | ||
|  | 
 | ||
|  |     // First, check whether all tasks have been completed.
 | ||
|  |     if (this.taskQueue.allTasksCompleted) { | ||
|  |       return this.finishedDeferred.resolve(); | ||
|  |     } | ||
|  | 
 | ||
|  |     // Look for available workers and available tasks to assign to them.
 | ||
|  |     for (const [workerId, assignedTask] of Array.from(this.taskAssignments)) { | ||
|  |       if (assignedTask !== null) { | ||
|  |         // This worker already has a job; check other workers.
 | ||
|  |         continue; | ||
|  |       } else { | ||
|  |         // This worker is available.
 | ||
|  |         isWorkerAvailable = true; | ||
|  |       } | ||
|  | 
 | ||
|  |       // This worker needs a job. See if any are available.
 | ||
|  |       const task = this.taskQueue.getNextTask(); | ||
|  |       if (task === null) { | ||
|  |         // No suitable work available right now.
 | ||
|  |         break; | ||
|  |       } | ||
|  | 
 | ||
|  |       // Process the next task on the worker.
 | ||
|  |       this.taskAssignments.set(workerId, task); | ||
|  |       sendMessageToWorker(workerId, {type: 'process-task', task}); | ||
|  | 
 | ||
|  |       isWorkerAvailable = false; | ||
|  |     } | ||
|  | 
 | ||
|  |     // If there are no available workers or no available tasks, log (for debugging purposes).
 | ||
|  |     if (!isWorkerAvailable) { | ||
|  |       this.logger.debug( | ||
|  |           `All ${this.taskAssignments.size} workers are currently busy and cannot take on more ` + | ||
|  |           'work.'); | ||
|  |     } else { | ||
|  |       const busyWorkers = Array.from(this.taskAssignments) | ||
|  |                               .filter(([_workerId, task]) => task !== null) | ||
|  |                               .map(([workerId]) => workerId); | ||
|  |       const totalWorkerCount = this.taskAssignments.size; | ||
|  |       const idleWorkerCount = totalWorkerCount - busyWorkers.length; | ||
|  | 
 | ||
|  |       this.logger.debug( | ||
|  |           `No assignments for ${idleWorkerCount} idle (out of ${totalWorkerCount} total) ` + | ||
|  |           `workers. Busy workers: ${busyWorkers.join(', ')}`); | ||
|  | 
 | ||
|  |       if (busyWorkers.length === 0) { | ||
|  |         // This is a bug:
 | ||
|  |         // All workers are idle (meaning no tasks are in progress) and `taskQueue.allTasksCompleted`
 | ||
|  |         // is `false`, but there is still no assignable work.
 | ||
|  |         throw new Error( | ||
|  |             'There are still unprocessed tasks in the queue and no tasks are currently in ' + | ||
|  |             `progress, yet the queue did not return any available tasks: ${this.taskQueue}`); | ||
|  |       } | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Handle a worker's exiting. (Might be intentional or not.) */ | ||
|  |   private onWorkerExit(worker: cluster.Worker, code: number|null, signal: string|null): void { | ||
|  |     // If the worker's exiting was intentional, nothing to do.
 | ||
|  |     if (worker.exitedAfterDisconnect) return; | ||
|  | 
 | ||
|  |     // The worker exited unexpectedly: Determine it's status and take an appropriate action.
 | ||
|  |     const currentTask = this.taskAssignments.get(worker.id); | ||
|  | 
 | ||
|  |     this.logger.warn( | ||
|  |         `Worker #${worker.id} exited unexpectedly (code: ${code} | signal: ${signal}).\n` + | ||
|  |         `  Current assignment: ${(currentTask == null) ? '-' : stringifyTask(currentTask)}`); | ||
|  | 
 | ||
|  |     if (currentTask == null) { | ||
|  |       // The crashed worker process was not in the middle of a task:
 | ||
|  |       // Just spawn another process.
 | ||
|  |       this.logger.debug(`Spawning another worker process to replace #${worker.id}...`); | ||
|  |       this.taskAssignments.delete(worker.id); | ||
|  |       cluster.fork(); | ||
|  |     } else { | ||
|  |       // The crashed worker process was in the middle of a task:
 | ||
|  |       // Impossible to know whether we can recover (without ending up with a corrupted entry-point).
 | ||
|  |       throw new Error( | ||
|  |           'Process unexpectedly crashed, while processing format property ' + | ||
|  |           `${currentTask.formatProperty} for entry-point '${currentTask.entryPoint.path}'.`); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Handle a message from a worker. */ | ||
|  |   private onWorkerMessage(workerId: number, msg: MessageFromWorker): void { | ||
|  |     if (!this.taskAssignments.has(workerId)) { | ||
|  |       const knownWorkers = Array.from(this.taskAssignments.keys()); | ||
|  |       throw new Error( | ||
|  |           `Received message from unknown worker #${workerId} (known workers: ` + | ||
|  |           `${knownWorkers.join(', ')}): ${JSON.stringify(msg)}`); | ||
|  |     } | ||
|  | 
 | ||
|  |     switch (msg.type) { | ||
|  |       case 'error': | ||
|  |         throw new Error(`Error on worker #${workerId}: ${msg.error}`); | ||
|  |       case 'task-completed': | ||
|  |         return this.onWorkerTaskCompleted(workerId, msg); | ||
|  |       case 'update-package-json': | ||
|  |         return this.onWorkerUpdatePackageJson(workerId, msg); | ||
|  |       default: | ||
|  |         throw new Error( | ||
|  |             `Invalid message received from worker #${workerId}: ${JSON.stringify(msg)}`); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Handle a worker's coming online. */ | ||
|  |   private onWorkerOnline(workerId: number): void { | ||
|  |     if (this.taskAssignments.has(workerId)) { | ||
|  |       throw new Error(`Invariant violated: Worker #${workerId} came online more than once.`); | ||
|  |     } | ||
|  | 
 | ||
|  |     this.taskAssignments.set(workerId, null); | ||
|  |     this.maybeDistributeWork(); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Handle a worker's having completed their assigned task. */ | ||
|  |   private onWorkerTaskCompleted(workerId: number, msg: TaskCompletedMessage): void { | ||
|  |     const task = this.taskAssignments.get(workerId) || null; | ||
|  | 
 | ||
|  |     if (task === null) { | ||
|  |       throw new Error( | ||
|  |           `Expected worker #${workerId} to have a task assigned, while handling message: ` + | ||
|  |           JSON.stringify(msg)); | ||
|  |     } | ||
|  | 
 | ||
|  |     onTaskCompleted(this.pkgJsonUpdater, task, msg.outcome); | ||
|  | 
 | ||
|  |     this.taskQueue.markTaskCompleted(task); | ||
|  |     this.taskAssignments.set(workerId, null); | ||
|  |     this.maybeDistributeWork(); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Handle a worker's request to update a `package.json` file. */ | ||
|  |   private onWorkerUpdatePackageJson(workerId: number, msg: UpdatePackageJsonMessage): void { | ||
|  |     const task = this.taskAssignments.get(workerId) || null; | ||
|  | 
 | ||
|  |     if (task === null) { | ||
|  |       throw new Error( | ||
|  |           `Expected worker #${workerId} to have a task assigned, while handling message: ` + | ||
|  |           JSON.stringify(msg)); | ||
|  |     } | ||
|  | 
 | ||
|  |     const expectedPackageJsonPath = resolve(task.entryPoint.path, 'package.json'); | ||
|  |     const parsedPackageJson = task.entryPoint.packageJson; | ||
|  | 
 | ||
|  |     if (expectedPackageJsonPath !== msg.packageJsonPath) { | ||
|  |       throw new Error( | ||
|  |           `Received '${msg.type}' message from worker #${workerId} for '${msg.packageJsonPath}', ` + | ||
|  |           `but was expecting '${expectedPackageJsonPath}' (based on task assignment).`); | ||
|  |     } | ||
|  | 
 | ||
|  |     // NOTE: Although the change in the parsed `package.json` will be reflected in tasks objects
 | ||
|  |     //       locally and thus also in future `process-task` messages sent to worker processes, any
 | ||
|  |     //       processes already running and processing a task for the same entry-point will not get
 | ||
|  |     //       the change.
 | ||
|  |     //       Do not rely on having an up-to-date `package.json` representation in worker processes.
 | ||
|  |     //       In other words, task processing should only rely on the info that was there when the
 | ||
|  |     //       file was initially parsed (during entry-point analysis) and not on the info that might
 | ||
|  |     //       be added later (during task processing).
 | ||
|  |     this.pkgJsonUpdater.writeChanges(msg.changes, msg.packageJsonPath, parsedPackageJson); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** Stop all workers and stop listening on cluster events. */ | ||
|  |   private stopWorkers(): void { | ||
|  |     const workers = Object.values(cluster.workers) as cluster.Worker[]; | ||
|  |     this.logger.debug(`Stopping ${workers.length} workers...`); | ||
|  | 
 | ||
|  |     cluster.removeAllListeners(); | ||
|  |     workers.forEach(worker => worker.kill()); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Wrap an event handler to ensure that `finishedDeferred` will be rejected on error (regardless | ||
|  |    * if the handler completes synchronously or asynchronously). | ||
|  |    */ | ||
|  |   private wrapEventHandler<Args extends unknown[]>(fn: (...args: Args) => void|Promise<void>): | ||
|  |       (...args: Args) => Promise<void> { | ||
|  |     return async(...args: Args) => { | ||
|  |       try { | ||
|  |         await fn(...args); | ||
|  |       } catch (err) { | ||
|  |         this.finishedDeferred.reject(err); | ||
|  |       } | ||
|  |     }; | ||
|  |   } | ||
|  | } |