UNPKG

futoin-asyncsteps

Version:

Mimic traditional threads in single threaded event loop

663 lines (553 loc) 18.5 kB
"use strict"; /** * @file Module's entry point and AsyncSteps class itself * @author Andrey Galkin <andrey@futoin.org> * * * Copyright 2014-2017 FutoIn Project (https://futoin.org) * Copyright 2014-2017 Andrey Galkin <andrey@futoin.org> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @module futoin-asyncsteps */ const AsyncTool = require( './lib/AsyncTool' ); const { InternalError } = require( './Errors' ); const AsyncStepProtector = require( './lib/AsyncStepProtector' ); const ParallelStep = require( './lib/ParallelStep' ); const { isProduction, checkFunc, checkOnError, noop, loop, repeat, forEach, as_await, EMPTY_ARRAY, newExecStack, } = require( './lib/common' ); const sanityCheck = isProduction ? noop : ( as ) => { if ( as._stack.length > 0 ) { as.error( InternalError, "Top level add in execution" ); } }; const sanityCheckAdd = isProduction ? noop : ( as, func, onerror ) => { sanityCheck( as ); checkFunc( as, func ); checkOnError( as, onerror ); }; // This small trick has a huge speedup result (75-90%) const EXEC_BURST = 100; let g_curr_burst = EXEC_BURST; // avoid another AsyncSteps instance continuation let g_burst_owner = null; const post_execute_cb = ( asi ) => { asi._post_exec = noop; asi._execute(); }; /** * Root AsyncStep implementation */ class AsyncSteps { constructor( state = null, async_tool = AsyncTool ) { if ( state === null ) { state = function() { return this.state; }; } this.state = state; this._queue = []; this._stack = []; this._exec_stack = newExecStack(); this._in_exec = false; this._post_exec = noop; this._exec_event = null; this._next_args = EMPTY_ARRAY; this._async_tool = async_tool; // --- const { callImmediate } = async_tool; const event_execute_cb = () => { g_curr_burst = EXEC_BURST; g_burst_owner = this; this._exec_event = null; this._execute(); }; this._scheduleExecute = () => { if ( --g_curr_burst <= 0 || !this._in_exec || ( g_burst_owner !== this ) ) { this._exec_event = callImmediate( event_execute_cb ); } else if ( this._in_exec ) { this._post_exec = post_execute_cb; } }; } /** * Add sub-step. Can be called multiple times. * @param {ExecFunc} func - function defining non-blocking step execution * @param {ErrorFunc=} onerror - Optional, provide error handler * @returns {AsyncSteps} self * @alias AsyncSteps#add */ add( func, onerror ) { sanityCheckAdd( this, func, onerror ); this._queue.push( [ func, onerror ] ); return this; } /** * Creates a step internally and returns specialized AsyncSteps interfaces all steps * of which are executed in quasi-parallel. * @param {ErrorFunc=} onerror - Optional, provide error handler * @returns {AsyncSteps} interface for parallel step adding * @alias AsyncSteps#parallel */ parallel( onerror ) { sanityCheck( this ); checkOnError( this, onerror ); const p = new ParallelStep( this, this ); this._queue.push( [ ( as ) => { p.executeParallel( as ); }, onerror, ] ); return p; } /* globals ISync */ /** * Add sub-step with synchronization against supplied object. * @param {ISync} object - Mutex, Throttle or other type of synchronization implementation. * @param {ExecFunc} func - function defining non-blocking step execution * @param {ErrorFunc=} onerror - Optional, provide error handler * @returns {AsyncSteps} self * @alias AsyncSteps#sync */ sync( object, func, onerror ) { sanityCheckAdd( this, func, onerror ); object.sync( this, func, onerror ); return this; } /** * Set error and throw to abort execution. * * **NOTE: If called outside of AsyncSteps stack (e.g. by external event), make sure you catch the exception** * @param {string} name - error message, expected to be identifier "InternalError" * @param {string=} error_info - optional descriptive message assigned to as.state.error_info * @throws {Error} * @alias AsyncSteps#error */ error( name, error_info ) { this.state.error_info = error_info; const e = new Error( name ); if ( !this._in_exec ) { this.state.last_exception = e; this._handle_error( name ); } throw e; } /** * Copy steps and not yet defined state variables from "model" AsyncSteps instance * @param {AsyncSteps} other - model instance, which must get be executed * @returns {AsyncSteps} self * @alias AsyncSteps#copyFrom */ copyFrom( other ) { this._queue.push.apply( this._queue, other._queue ); const os = other.state; const s = this.state; for ( let k in os ) { if ( !( k in s ) ) { s[k] = os[k]; } } return this; } /** * @private * @param {Array} [args] List of success() args */ _handle_success( args = EMPTY_ARRAY ) { const stack = this._stack; if ( !stack.length ) { this.error( InternalError, 'Invalid success completion' ); } this._next_args = args; for ( let asp = stack[ stack.length - 1 ];; ) { const limit_event = asp._limit_event; if ( limit_event ) { this._async_tool.cancelCall( limit_event ); asp._limit_event = null; } asp._cleanup(); // aid GC stack.pop(); // --- if ( !stack.length ) { break; } asp = stack[ stack.length - 1 ]; if ( asp._queue.length ) { break; } } if ( stack.length || this._queue.length ) { this._scheduleExecute(); } } /** * @private * @param {string} [name] Error to handle */ _handle_error( name ) { if ( this._exec_event ) { this.cancel(); return; } this._next_args = EMPTY_ARRAY; const stack = this._stack; const exec_stack = this._exec_stack; this.state.async_stack = exec_stack; const orig_in_exec = this._in_exec; let cleanup = true; while ( stack.length ) { const asp = stack[ stack.length - 1 ]; const limit_event = asp._limit_event; const on_cancel = asp._on_cancel; const on_error = asp._on_error; if ( limit_event ) { this._async_tool.cancelCall( limit_event ); asp._limit_event = null; } if ( on_cancel ) { on_cancel.call( null, asp ); asp._on_cancel = null; } if ( on_error ) { const slen = stack.length; asp._queue = null; // suppress non-empty queue for success() in onerror asp._on_error = null; // do no repeat exec_stack.push( on_error ); try { this._in_exec = true; on_error.call( null, asp, name ); if ( slen !== stack.length ) { cleanup = false; break; // override with success() } if ( asp._queue !== null ) { cleanup = false; this._scheduleExecute(); break; } } catch ( e ) { this.state.last_exception = e; name = e.message; } finally { this._in_exec = orig_in_exec; } } asp._cleanup(); // aid GC stack.pop(); } if ( cleanup ) { // Clear queue on finish this._queue = []; } else if ( !orig_in_exec ) { this._post_exec( this ); } } /** * Use only on root AsyncSteps instance. Abort execution of AsyncSteps instance in progress. * @returns {AsyncSteps} self * @alias AsyncSteps#cancel */ cancel() { this._next_args = EMPTY_ARRAY; const exec_event = this._exec_event; if ( exec_event ) { this._async_tool.cancelImmediate( exec_event ); this._exec_event = null; } const stack = this._stack; const async_tool = this._async_tool; while ( stack.length ) { const asp = stack.pop(); const limit_event = asp._limit_event; const on_cancel = asp._on_cancel; if ( limit_event ) { async_tool.cancelCall( limit_event ); asp._limit_event = null; } if ( on_cancel ) { on_cancel.call( null, asp ); asp._on_cancel = null; } asp._cleanup(); // aid GC } // Clear queue on finish this._queue = []; return this; } /** * Start execution of AsyncSteps using AsyncTool * * It must not be called more than once until cancel/complete (instance can be re-used) * @returns {AsyncSteps} self * @alias AsyncSteps#execute */ execute() { const prev_owner = g_burst_owner; g_burst_owner = this; this._execute(); g_burst_owner = prev_owner; return this; } _execute() { const stack = this._stack; let q; if ( stack.length ) { q = stack[ stack.length - 1 ]._queue; } else { q = this._queue; } if ( !q.length ) { return; } const curr = q.shift(); const func = curr[0]; this._exec_stack.push( func ); const next_args = this._next_args; const na_len = next_args.length; const asp = new AsyncStepProtector( this, curr[1], next_args ); stack.push( asp ); try { const oc = stack.length; this._in_exec = true; if ( !na_len ) { func( asp ); } else { this._next_args = EMPTY_ARRAY; func( asp, ...next_args ); } if ( oc === stack.length ) { if ( asp._queue !== null ) { this._scheduleExecute(); } else if ( !asp._on_cancel && !asp._limit_event ) { // Implicit success this._handle_success( this._next_args ); } } } catch ( e ) { this.state.last_exception = e; this._handle_error( e.message ); } finally { this._in_exec = false; } this._post_exec( this ); } /** * Optimized success() which performs burst execution * @param {Array} [args] List of success() args * @private */ _burst_success( args = EMPTY_ARRAY ) { try { this._in_exec = true; g_burst_owner = this; this._handle_success( args ); } catch ( e ) { this.state.last_exception = e; this._handle_error( e.message ); } finally { this._in_exec = false; } this._post_exec( this ); } /** * It is just a subset of *ExecFunc* * @callback LoopFunc * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @alias loop_callback * @see ExecFunc */ /** * Execute loop until *as.break()* or *as.error()* is called * @param {LoopFunc} func - loop body * @param {string=} label - optional label to use for *as.break()* and *as.continue()* in inner loops * @returns {AsyncSteps} self * @alias AsyncSteps#loop */ loop( func, label ) { sanityCheckAdd( this, func ); loop( this, this, func, label ); return this; } /** * It is just a subset of *ExecFunc* * @callback RepeatFunc * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @param {number} i - current iteration starting from 0 * @alias repeat_callback * @see ExecFunc */ /** * Call *func(as, i)* for *count* times * @param {number} count - how many times to call the *func* * @param {RepeatFunc} func - loop body * @param {string=} label - optional label to use for *as.break()* and *as.continue()* in inner loops * @returns {AsyncSteps} self * @alias AsyncSteps#repeat */ repeat( count, func, label ) { sanityCheckAdd( this, func ); repeat( this, this, count, func, label ); return this; } /** * It is just a subset of *ExecFunc* * @callback ForEachFunc * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @param {number|string} key - key ID or name * @param {*} value - value associated with key * @alias foreach_callback * @see ExecFunc */ /** * For each *map* or *list* element call *func( as, key, value )* * @param {number} map_or_list - map or list to iterate over * @param {ForEachFunc} func - loop body * @param {string=} label - optional label to use for *as.break()* and *as.continue()* in inner loops * @returns {AsyncSteps} self * @alias AsyncSteps#forEach */ forEach( map_or_list, func, label ) { sanityCheckAdd( this, func ); forEach( this, this, map_or_list, func, label ); return this; } /** * Shortcut for `this.add( ( as ) => as.success( ...args ) )` * @param {...any} [args] - argument to pass, if any * @alias AsyncSteps#successStep * @returns {AsyncSteps} self */ successStep( ...args ) { sanityCheck( this ); const queue = this._queue; if ( queue.length > 0 ) { queue.push( [ () => { this._handle_success( args ); }, undefined ] ); } else { this._next_args = args; } return this; } /** * Integrate a promise as a step. * @param {Promise} promise - promise to add as a step * @param {Function} [onerror] error handler to check * @alias AsyncSteps#await * @returns {AsyncSteps} self */ await( promise, onerror ) { sanityCheck( this ); as_await( this, this, promise, onerror ); return this; } /** * Execute AsyncSteps with Promise interface * @alias AsyncSteps#promise * @returns {Promise} - promise wrapper for AsyncSteps */ promise() { sanityCheck( this ); return new Promise( ( resolve, reject ) => { const q = this._queue; this._queue = [ [ ( as ) => { as._queue = q; }, ( as, err ) => { reject( new Error( err ) ); }, ], [ ( as, res ) => { resolve( res ); }, undefined, ], ]; g_burst_owner = this; this._execute(); } ); } /** * Create a new instance of AsyncSteps for independent execution * @alias AsyncSteps#newInstance * @returns {AsyncSteps} new instance */ newInstance() { return new AsyncSteps( null, this._async_tool ); } /** * Not standard API for assertion with multiple instances of the module. * @private * @returns {boolean} true */ isAsyncSteps() { return true; } } /** * **execute_callback** as defined in **FTN12: FutoIn AsyncSteps** specification. Function must have * non-blocking body calling: *as.success()* or *as.error()* or *as.add()/as.parallel()*. * @callback ExecFunc * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @param {...*} [val] - any result values passed to the previous as.success() call * @alias execute_callback */ /** * **error_callback** as defined in **FTN12: FutoIn AsyncSteps** specification. * Function can: * * - do nothing, * - override error message with `as.error( new_error )`, * - continue execution with `as.success()`. * @callback ErrorFunc * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @param {string} err - error message * @alias error_callback */ /** * **cancel_callback** as defined in **FTN12: FutoIn AsyncSteps** specification. * @callback CancelFunc * It must be used to cancel any external processing to avoid invalidated AsyncSteps object use. * @param {AsyncSteps} as - the only valid reference to AsyncSteps with required level of protection * @alias cancel_callback */ /** * Get AsyncSteps state object. * * **Note: There is a JS-specific improvement: as.state === as.state()** * * The are the following pre-defined state variables: * * - **error_info** - error description, if provided to *as.error()* * - **last_exception** - the last exception caught * - **async_stack** - array of references to executed step handlers in current stack * @returns {object} * @alias AsyncSteps#state */ module.exports = AsyncSteps;