Name

global.RepairTaskSLAController

Description

Extension of TaskSLAController to allow for the changes necessary to repair SLAs without changing the normal SLA calculation process

Script

var RepairTaskSLAController = Class.create();

RepairTaskSLAController.prototype = Object.extendsObject(TaskSLAController, {
  initialize: function(taskGR, taskType) {
  	TaskSLAController.prototype.initialize.call(this, taskGR, taskType);
  	this.lu = new GSLog(SLARepair.LOG_PROPERTY, 'RepairTaskSLAController');
  	this.lu.includeTimestamp();

  	this.slaDefIds = null;
  },

  restrictSLADefinitions: function(slaDefIds) {
  	this.slaDefIds = slaDefIds;
  },

  queryTaskSLAs: function() {
  	var taskSLAgr = new GlideRecord('task_sla');
  	taskSLAgr.addActiveQuery();
  	taskSLAgr.addDomainQuery(this.taskGR);
  	taskSLAgr.addQuery('task', this.taskGR.sys_id);
  	if (this.slaDefIds)
  		taskSLAgr.addQuery('sla', this.slaDefIds);
  	taskSLAgr.orderBy('sla.sys_id');
  	taskSLAgr.query();
  	if (this.lu.atLevel(GSLog.DEBUG))
  		this.lu.logDebug('queryTaskSLAs: #' + taskSLAgr.getRowCount());
  	return taskSLAgr;
  },

  queryContractSLAs: function() {
  	var contractSLAgr = new GlideRecord('contract_sla');
  	contractSLAgr.addDomainQuery(this.taskGR);
  	if (this.slaDefIds)
  		contractSLAgr.addQuery('sys_id', this.slaDefIds);
  	var taskSLAJoin = contractSLAgr.addJoinQuery('task_sla', 'sys_id', 'sla');
  	taskSLAJoin.addCondition('task', this.taskGR.sys_id);
  	taskSLAJoin.addCondition('active', true);
  	contractSLAgr.orderBy('sys_id');
  	contractSLAgr.query();
  	if (this.lu.atLevel(GSLog.DEBUG))
  		this.lu.logDebug('queryContractSLAs: #' + contractSLAgr.getRowCount());
  	return contractSLAgr;
  },

  /**
  * (called after obtaining SelfCleaningMutex: '<<<--Process New SLAs Mutex ' + this.taskGR.sys_id + '-->>>')
  *
  * NB. adds to slaGR query, before executing it.
  */
  _processNewSLAs_criticalSection: function(slaGR) {
  	var newTaskSLA;
  	var newTaskSLAs = [];
  	this.fieldValuesLogged = false;
  	if (this.lu.atLevel(GSLog.INFO))
  		this.lu.logInfo('_processNewSLAs_criticalSection: ' + this.taskGR.sys_id);
  	var sw;
  	if (this.timers)
  		sw = new GlideStopWatch();

  	// Log the field values in the Task record
  	if (this.lu.atLevel(GSLog.DEBUG)) {
  		this.lu.logDebug('_processNewSLAs_criticalSection:\n' + this.slalogging.getRecordContentMsg(this.taskGR));
  		this.fieldValuesLogged = true;
  	}

  	// skip any active SLAs already (indirectly) attached to this task -- must be done inside of mutex
  	slaGR.addQuery('sys_id', 'NOT IN', this._getSLAsString(this.queryTaskSLAs()));
  	slaGR.addDomainQuery(this.taskGR);
  	slaGR.query();
  	while (slaGR.next()) {
  		var oldLogLevel = this.lu.getLevel();
  		// if enable logging has been checked on the SLA definition up the log level to "debug"
  		if (slaGR.enable_logging) {
  			this.lu.setLevel(GSLog.DEBUG);
  			if (!this.fieldValuesLogged) {
  				this.lu.logDebug('_processNewSLAs_criticalSection:\n' + this.slalogging.getRecordContentMsg(this.taskGR));
  				this.fieldValuesLogged = true;
  			}
  		}

  		newTaskSLA = this._checkNewSLA(slaGR);
  		if (newTaskSLA)
  			newTaskSLAs.push(newTaskSLA);

  		this.lu.setLevel(oldLogLevel);
  	}

  	if (newTaskSLAs.length > 0) {
  		this._adjustPauseTime(newTaskSLAs);
  		for (var i = 0; i < newTaskSLAs.length; i++) {
  			newTaskSLA = newTaskSLAs[i];
  			if (newTaskSLA.needsAdjusting && !newTaskSLA.adjusted) {
  				newTaskSLA.taskSLA.updateState(TaskSLA.STATE_IN_PROGRESS);
  				if (this.breakdownsPluginActive && sn_sla_brkdwn.SLABreakdownProcessor.hasBreakdownDefinitions(newTaskSLA.taskSLA.getGlideRecord().getValue("sla")))
  					this._processSLABreakdowns(newTaskSLA.taskSLA.getGlideRecord(), null, "insert");
  			}
  		}
  	}

  	if (this.timers)
  		sw.log('TaskSLAController._processNewSLAs_criticalSection complete');
  },

  _processNewSLAs: function() {
  	this.lu.logInfo('_processNewSLAs');
  	var sw;
  	if (this.timers)
  		sw = new GlideStopWatch();

  	var slaGR = this._getSLAsQueryCheckingContracts();

  	if (this.slaDefIds)
  		slaGR.addQuery('sys_id', this.slaDefIds);

  	this.newSLADefIds = [];
  	SelfCleaningMutex.enterCriticalSectionRecordInStats(this.MUTEX_NEW + this.taskGR.sys_id, this.MUTEX_NEW, this, this._processNewSLAs_criticalSection, slaGR);
  	// TODO: optionally attach work-notes
  	if (this.timers)
  		sw.log('TaskSLAController: Finished _processNewSLAs part 1');

  	// and active Service Offering SLA definitions
  	// (TODO: merge this contract_sla query with the previous one, to process all of them in one go)
  	if (!this._allowProcessingServiceCommitment())
  		return;

  	var socGR = new GlideRecord('service_offering_commitment');
  	if (!socGR.isValid())
  		return;

  	var commitmentFieldTest = new GlideRecord('service_commitment');
  	if (!commitmentFieldTest.isValidField("sla"))
  		return;

  	if (this.timers)
  		sw = new GlideStopWatch();
  	// (using contract_sla GlideRecord to easily avoid
  	//  those that are currently active and assigned to the task)
  	slaGR.initialize();
  	slaGR.addActiveQuery();
  	slaGR.addQuery('collection', this.taskGR.getRecordClassName());
  	if (this.slaDefIds)
  		slaGR.addQuery('sys_id', this.slaDefIds);
  	// service_commitment.type='SLA'
  	slaGR.addQuery('JOINcontract_sla.sys_id=service_commitment.sla!type=SLA');
  	// service_offering_commitment.service_offering=cmdb_ci
  	slaGR.addQuery('JOINservice_commitment.sys_id=service_offering_commitment.service_commitment!service_offering=' + this.taskGR.getValue(this.serviceOfferingField));

  	SelfCleaningMutex.enterCriticalSectionRecordInStats(this.MUTEX_NEW + this.taskGR.sys_id, this.MUTEX_NEW, this, this._processNewSLAs_criticalSection, slaGR);
  	// TODO: optionally attach more work-notes
  	if (this.timers)
  		sw.log('TaskSLAController: Finished _processNewSLAs part 2');
  },

  /**
  * Check the Attach Conditions of the specified contract_sla (or service_offering_commitment) definition If
  * (SLACondition).attach returns true then attach it to this task
  *
  * pre-conditions: by this point, we have confirmed that it isn't currently attached to the task, and we have the Mutex for
  * "Process New SLAs Mutex " + this.taskGR.sys_id) to prevent it being added by another TaskSLAController
  */
  _checkNewSLA: function(slaGR) {
  	var sw;
  	if (this.timers)
  		sw = new GlideStopWatch();
  	var slac = this._newSLACondition(slaGR, this.taskGR);
  	var startMatches = slac.attach();
  	if (this.lu.atLevel(GSLog.DEBUG))
  		this.lu.logDebug('_checkNewSLA: checking ' + slaGR.name + ', start condition matched=' + startMatches);

  	if (!startMatches)
  		return null;

  	this.newSLADefIds.push(slaGR.getValue('sys_id'));
  	if (this.lu.atLevel(GSLog.DEBUG))
  		this.lu.logDebug('_checkNewSLA newSLADefIds=[' + this.newSLADefIds.join() + ']');

  	/*
  	this object will contain properties to indicate if this new Task SLA needs retroactive pause time calculated,
  	if the adjust pause was succesful and also the TaskSLA object itself
  		*/
  	var newTaskSLA = {
  		"needsAdjusting": false,
  		"adjusted": false
  	};

  	var taskSLA;
  	// Check if this TaskSLA needs retroactive pause calculation
  	if (this._needsAdjustPause(slaGR)) {
  		newTaskSLA.needsAdjusting = true;
  		newTaskSLA.contractSLAgr = this.slaUtil.copyContractSLA(slaGR);
  		taskSLA = new RepairTaskSLA(newTaskSLA.contractSLAgr, this.taskGR, /* deferInsert */ true);
  		this.slaUtil.setSLAUpdateSourceEngineParameter(taskSLA.getGlideRecord(), this.type);
  	} else {
  		taskSLA = new RepairTaskSLA(slaGR, this.taskGR, /* deferInsert */ true);
  		this.slaUtil.setSLAUpdateSourceEngineParameter(taskSLA.getGlideRecord(), this.type);
  		taskSLA.updateState(TaskSLA.STATE_IN_PROGRESS); // adds task_sla record, initiates model state machine, starts notification workflow
  		if (this.breakdownsPluginActive && sn_sla_brkdwn.SLABreakdownProcessor.hasBreakdownDefinitions(slaGR.getUniqueValue()))
  			this._processSLABreakdowns(taskSLA.getGlideRecord(), null, "insert");
  		if (this.lu.atLevel(GSLog.INFO))
  			this.lu.logInfo('_checkNewSLA: added SLA "' + slaGR.name + '"');
  	}

  	// add the TaskSLA object to our exising newTaskSLA object
  	newTaskSLA.taskSLA = taskSLA;

  	// TODO: work-notes

  	if (this.timers)
  		sw.log('TaskSLAController: Finished _checkNewSLA');

  	return newTaskSLA;
  },

  // (called after obtaining SelfCleaningMutex MUTEX_UPDATE: '<<<--Process Existing SLAs Mutex ' + this.taskGR.sys_id + '-->>>',
  // to prevent simultaneous/overlapping updates of the task_sla records)
  _processExistingSLAs_criticalSection: function(taskSLAgr, contractSLAgr) {
  	this.fieldValuesLogged = false;
  	if (this.lu.atLevel(GSLog.INFO))
  		this.lu.logInfo('_processExistingSLAs_criticalSection: ' + this.taskGR.sys_id);
  	var sw;
  	if (this.timers)
  		sw = new GlideStopWatch();

  	// Log the field values in the Task record
  	if (this.lu.atLevel(GSLog.DEBUG)) {
  		this.lu.logDebug('_processExistingSLAs_criticalSection:\n' + this.slalogging.getRecordContentMsg(this.taskGR));
  		this.fieldValuesLogged = true;
  	}

  	var resetTaskSLAs = [];
  	var contractSLA;
  	var taskSLA;
  	var conditionResults;
  	var previousTaskSLAgr;

  	while (taskSLAgr.next()) {
  		contractSLA = this._getContractSLA(taskSLAgr, contractSLAgr);

  		var oldLogLevel = this.lu.getLevel();
  		// if enable logging has been checked on the SLA definition up the log level to "debug"
  		if (contractSLA.enable_logging) {
  			this.lu.setLevel(GSLog.DEBUG);
  			if (!this.fieldValuesLogged) {
  				this.lu.logDebug('_processExistingSLAs_criticalSection:\n' + this.slalogging.getRecordContentMsg(this.taskGR));
  				this.fieldValuesLogged = true;
  			}
  		}

  		taskSLA = this._getTaskSLA(taskSLAgr, contractSLA);
  		this.slaUtil.setSLAUpdateSourceEngineParameter(taskSLA.getGlideRecord(), this.type);
  		previousTaskSLAgr = this.slaUtil.copyTaskSLA(taskSLAgr);
  		conditionResults = this._checkExistingSLA(taskSLA, contractSLA);

  		/* if the condition checking didn't result in a stage change of the Task SLA then just call SLACalculatorNG to update
  		   the duration values which could potentially result in "has_breached" changing */
  		if (!conditionResults.stageChangedTo) {
  			// Stop business rules from running when we do the calculation as we don't want the breakdown data to be automatically generated
  			taskSLA.getGlideRecord().setUseEngines(false);
  			SLACalculatorNG.calculateSLA(taskSLA.getGlideRecord(), false, this.taskGR.sys_updated_on.getGlideObject(), contractSLA, true);
  		}

  		if (this.breakdownsPluginActive && sn_sla_brkdwn.SLABreakdownProcessor.hasBreakdownDefinitions(taskSLAgr.getValue("sla")))
  			this._processSLABreakdowns(taskSLA.getGlideRecord(), previousTaskSLAgr, "update");

  		if (this.lu.atLevel(GSLog.DEBUG))
  			this.lu.logDebug("Condition results for Task SLA " + contractSLA.getDisplayValue() + " on task " + this.taskGR.getDisplayValue() +
  							 ":\n" + JSON.stringify(conditionResults));

  		if (conditionResults.stopCancel.reset && !conditionResults.stopCancel.skipResetAction)
  			resetTaskSLAs.push(taskSLAgr.getValue('sla'));

  		this.lu.setLevel(oldLogLevel);
  	}

  	if (resetTaskSLAs.length > 0)
  		SelfCleaningMutex.enterCriticalSectionRecordInStats(this.MUTEX_NEW + this.taskGR.sys_id, this.MUTEX_NEW,
  															this, this._checkNewSLAsFromReset, resetTaskSLAs);

  	if (this.timers)
  		sw.log('TaskSLAController._processExistingSLAs_criticalSection complete');
  },

  _getTaskSLA: function(taskSLAgr, slaDefGR) {
  	return new RepairTaskSLA(taskSLAgr, this.taskGR, null, slaDefGR);
  },

  _checkExistingSLA: function(taskSLA, contractSLA) {
  	var sw;
  	if (this.timers)
  		sw = new GlideStopWatch();

  	if (this.lu.atLevel(GSLog.DEBUG))
  		this.lu.logDebug('_checkExistingSLA: ' + taskSLA.getGlideRecord().sys_id);
  	taskSLA.setTaskGR(this.taskGR);
  	taskSLA.setUpdateTime(this.taskGR.sys_updated_on);
  	if (this.replayingTask)
  		taskSLA.setBreachTimer(false); // disable breach timers on the task_sla, for replay

  	var conditionResults = {
  		stopCancel: {

  		},
  		pauseResume: {

  		}
  	};
  	// (stop/cancel takes precedence over pause/unpause also matching in the same update to the task record)
  	conditionResults.stopCancel = this._stopCancel(taskSLA, contractSLA);

  	if (!conditionResults.stopCancel.conditionMatched)
  		conditionResults.pauseResume = this._pauseUnpause(taskSLA, contractSLA);

  	conditionResults.stageChangedTo = conditionResults.stopCancel.stageChangedTo || conditionResults.pauseResume.stageChangedTo;

  	// TODO: work-notes
  	if (this.timers)
  		sw.log('TaskSLAController: Finished _checkExistingSLA');

  	return conditionResults;
  },

  _runContractSLAs: function() {
  	var slaGR = new GlideRecord('contract_sla');
  	slaGR.addActiveQuery();
  	slaGR.addQuery('collection', this.taskGR.getRecordClassName());
  	if (this.slaDefIds)
  		slaGR.addQuery('sys_id', this.slaDefIds);
  	// contract_rel_contract_sla.contract=this.taskGR.contract
  	slaGR.addQuery('JOINcontract_sla.sys_id=contract_rel_contract_sla.contract_sla!contract=' + this.taskGR.contract);
  	SelfCleaningMutex.enterCriticalSectionRecordInStats(this.MUTEX_NEW + this.taskGR.sys_id, this.MUTEX_NEW, this, this._processNewSLAs_criticalSection, slaGR);
  },

  _needsAdjustPause: function(slaGR) {
  	// nothing to adjust for, as the task can have no previous updates
  	if (parseInt(this.taskGR.getValue("sys_mod_count")) === 0)
  		return;

  	// relative duration SLAs, and those without pause conditions, cannot pause
  	if (slaGR.duration_type != '' || !slaGR.pause_condition)
  		return false;

  	// (shouldn't have been called for a non retroactive pause SLA)
  	if (!slaGR.retroactive || !slaGR.retroactive_pause)
  		return false;

  	return true;
  },

  /**
  * Generate & replay task history, to adjust pause duration, pause time of retroactive-start SLAs (runs from within the
  * _processNewSLAs_criticalSection)
  */
  _adjustPauseTime: function(slasToAdjust) {
  	if (this.lu.atLevel(GSLog.INFO))
  		this.lu.logInfo('_adjustPauseTime: Adjusting pause time for retroactive SLAs on ' + this.taskGR.getDisplayValue());
  	if (!slasToAdjust || slasToAdjust.length === 0) {
  		this.lu.logWarning('There are no SLAs to be adjusted');
  		return;
  	}
  	// work out if the Task record is just being created and if it is we shouldn't adjust pause time
  	// "SLARepair" constructs the task GlideRecord with the correct "sys_mod_count"
  	if (parseInt(this.taskGR.getValue("sys_mod_count")) === 0) {
  		if (this.lu.atLevel(GSLog.DEBUG))
  			this.lu.logDebug("_adjustPauseTime: ends - task has sys_mod_count of 0 so there can't be any retroactive pauses");
  		// nothing to adjust for, as the task can have no previous updates
  		return;
  	}

  	if (!(new GlideAuditor(this.taskGR.getTableName(), null).auditTable())) {
  		if (this.lu.atLevel(GSLog.ERR))
  			this.lu.logError('Cannot adjust SLA pause time for a retroactive start - auditing not enabled on ' + this.taskGR.getTableName());
  		return;
  	}

  	if (this.lu.atLevel(GSLog.INFO))
  		this.lu.logInfo('_adjustPauseTime: at ' + this.taskGR.getValue("sys_updated_on"));

  	var hasRetroSLAs = false;
  	for (var i = 0; i < slasToAdjust.length; i++) {
  		if (slasToAdjust[i].needsAdjusting) {
  			hasRetroSLAs = true;
  			break;
  		}
  	}

  	if (!hasRetroSLAs) {
  		if (this.lu.atLevel(GSLog.DEBUG))
  			this.lu.logDebug("_adjustPauseTime: ends - there are no retroactive Task SLAs requiring retroactive pause time calculation");
  		return;
  	}

  	var taskSLA;
  	var contractSLA;
  	var withJournals = false;
  	var withSysFields = false;
  	var withVariables = false;

  	// Do we need to add journal or system fields or variables to the history walker?
  	for (var i = 0; (i < slasToAdjust.length && (!withJournals || !withSysFields || !withVariables)); i++) {
  		if (!slasToAdjust[i].needsAdjusting)
  			continue;

  		taskSLA = slasToAdjust[i].taskSLA;
  		contractSLA = slasToAdjust[i].contractSLAgr;
  		if (!contractSLA)
  			contractSLA = taskSLA.getGlideRecord().sla;

  		if (this.slaUtil.hasAdvancedJournalCondition(contractSLA))
  			withJournals = true;

  		if (this.slaUtil.hasAdvancedSysFieldCondition(contractSLA))
  			withSysFields = true;

  		if (this.slaUtil.hasVariablesCondition(contractSLA))
  			withVariables = true;
  	}

  	var hw = this.slaUtil.getHistoryWalker(
  		this.taskGR.getRecordClassName(),
  		this.taskGR.getUniqueValue(),
  		/*recordLevelSecurity*/ false,
  		/*fieldLevelSecurity*/ false,
  		/*withVariables*/ withVariables,
  		/*walkToFirstUpdate*/ true,
  		/*withJournalFields*/ withJournals,
  		/*withSysFields*/ withSysFields
  	);

  	if (!hw) {
  		this.lu.logError("_adjustPauseTime: failed getting HistoryWalker to initial update");
  		return;
  	}

  	// save a reference to the current task record before we start stepping through the updates
  	var originalTaskGR = this.taskGR;

  	var currentModCount = this.taskGR.getValue('sys_mod_count');
  	var currentUpdatedOn = this.taskGR.sys_updated_on.dateNumericValue();
  	var initialUpdateProcessed = false;
  	var taskSLAgr;
  	var walkedRecordUpdatedOnMs;
  	var startTimeMs;

  	do {
  		this.taskGR = hw.getWalkedRecord();

  		// We don't need to process the history for the current update number
  		if (this.taskGR.getValue('sys_mod_count') == currentModCount)
  			break;

  		/* Also make sure we haven't got to an update that is later than where our current task is
  		This can happen as SLA Repair replays the updates one by one */
  		walkedRecordUpdatedOnMs = this.taskGR.sys_updated_on.dateNumericValue();
  		if (walkedRecordUpdatedOnMs > currentUpdatedOn)
  			break;

  		if (this.lu.atLevel(GSLog.DEBUG))
  			this.lu.logDebug('_adjustPauseTime: [' + this.taskGR.getValue('sys_mod_count') + '] history update time: ' + this.taskGR.getValue('sys_updated_on'));
  		for (var j = 0; j < slasToAdjust.length; j++) {
  			if (!slasToAdjust[j].needsAdjusting)
  				continue;

  			taskSLA = slasToAdjust[j].taskSLA;
  			contractSLA = slasToAdjust[j].contractSLAgr;
  			if (!contractSLA)
  				contractSLA = taskSLA.getGlideRecord().sla;

  			taskSLAgr = taskSLA.getGlideRecord();
  			startTimeMs = taskSLAgr.start_time.dateNumericValue();
  			if (startTimeMs <= walkedRecordUpdatedOnMs)
  				taskSLA.setUpdateTime(this.taskGR.getValue('sys_updated_on'));
  			else
  				taskSLA.setUpdateTime(taskSLAgr.getValue('start_time'));

  			if (!initialUpdateProcessed) {
  				slasToAdjust[j].adjusted = true;
  				taskSLA.setRetroactiveAdjusting(true);
  				taskSLA.updateState(TaskSLA.STATE_IN_PROGRESS);
  				taskSLA.starting = false;
  			}

  			var conditionResults = this._pauseUnpause(taskSLA, contractSLA);

  			if (this.lu.atLevel(GSLog.DEBUG)) {
  				this.lu.logDebug("Condition Results for Task SLA " + contractSLA.getDisplayValue() + ": " + JSON.stringify(conditionResults));
  				this.lu.logDebug("Business elapsed: " + taskSLAgr.business_duration.getDurationValue() + ", Pause duration: " + taskSLAgr.pause_duration.getDurationValue() +
  								 ", Pause time: " + taskSLAgr.pause_time.getDisplayValue());
  			}
  		}
  		initialUpdateProcessed = true;
  	} while (hw.walkForward());

  	for (var k = 0; k < slasToAdjust.length; k++) {
  		if (!slasToAdjust[k].adjusted)
  			continue;

  		taskSLA = slasToAdjust[k].taskSLA;
  		contractSLA = slasToAdjust[k].contractSLAgr;
  		taskSLA.starting = true;
  		taskSLA.setRetroactiveAdjusting(false);
  		if (this.breakdownsPluginActive && sn_sla_brkdwn.SLABreakdownProcessor.hasBreakdownDefinitions(taskSLA.getGlideRecord().getValue("sla")))
  			this._processSLABreakdowns(taskSLA.getGlideRecord(), null, "insert");
  		if (this.lu.atLevel(GSLog.INFO)) {
  			taskSLAgr = taskSLA.getGlideRecord();
  			this.lu.logInfo('Finished adjusting pause time for retroactive SLA ' + contractSLA.getDisplayValue() + ' on ' + this.taskGR.getDisplayValue() + ': pause_duration=' + taskSLAgr.pause_duration + ', pause_time='
  							+ taskSLAgr.pause_time.getDisplayValue());
  		}
  		if (this.lu.atLevel(GSLog.DEBUG))
  			this.lu.logDebug('taskSLA: starting=' + taskSLA.starting);
  	}

  	// set back to the real task
  	this.taskGR = originalTaskGR;

  	if (this.lu.atLevel(GSLog.INFO))
  		this.lu.logInfo('_adjustPauseTime: Finished adjusting pause time for retroactive SLAs on ' + this.taskGR.getDisplayValue());

  	return;
  },

  type: 'RepairTaskSLAController',

  // ** Deprecated ** Below variables/functions are deprecated

  /* Deprecated */ runWorkflow: false,
  /* Deprecated */ setRunWorkflow: function(runWorkflow) {
  	this.runWorkflow = runWorkflow;
  }
});

Sys ID

6e80ff92ebb2310070a9666cd206feaf

Offical Documentation

Official Docs: