Changeset View
Changeset View
Standalone View
Standalone View
src/applications/herald/engine/HeraldEngine.php
<?php | <?php | ||||
final class HeraldEngine extends Phobject { | final class HeraldEngine extends Phobject { | ||||
protected $rules = array(); | protected $rules = array(); | ||||
protected $results = array(); | |||||
protected $stack = array(); | |||||
protected $activeRule; | protected $activeRule; | ||||
protected $transcript; | protected $transcript; | ||||
private $fieldCache = array(); | private $fieldCache = array(); | ||||
private $fieldExceptions = array(); | private $fieldExceptions = array(); | ||||
protected $object; | protected $object; | ||||
private $dryRun; | private $dryRun; | ||||
private $forbiddenFields = array(); | private $forbiddenFields = array(); | ||||
private $forbiddenActions = array(); | private $forbiddenActions = array(); | ||||
private $skipEffects = array(); | private $skipEffects = array(); | ||||
private $profilerStack = array(); | private $profilerStack = array(); | ||||
private $profilerFrames = array(); | private $profilerFrames = array(); | ||||
private $ruleResults; | |||||
private $ruleStack; | |||||
public function setDryRun($dry_run) { | public function setDryRun($dry_run) { | ||||
$this->dryRun = $dry_run; | $this->dryRun = $dry_run; | ||||
return $this; | return $this; | ||||
} | } | ||||
public function getDryRun() { | public function getDryRun() { | ||||
return $this->dryRun; | return $this->dryRun; | ||||
} | } | ||||
Show All 18 Lines | public static function loadAndApplyRules(HeraldAdapter $adapter) { | ||||
$rules = $engine->loadRulesForAdapter($adapter); | $rules = $engine->loadRulesForAdapter($adapter); | ||||
$effects = $engine->applyRules($rules, $adapter); | $effects = $engine->applyRules($rules, $adapter); | ||||
$engine->applyEffects($effects, $adapter, $rules); | $engine->applyEffects($effects, $adapter, $rules); | ||||
return $engine->getTranscript(); | return $engine->getTranscript(); | ||||
} | } | ||||
/* -( Rule Stack )--------------------------------------------------------- */ | |||||
private function resetRuleStack() { | |||||
$this->ruleStack = array(); | |||||
return $this; | |||||
} | |||||
private function hasRuleOnStack(HeraldRule $rule) { | |||||
$phid = $rule->getPHID(); | |||||
return isset($this->ruleStack[$phid]); | |||||
} | |||||
private function pushRuleStack(HeraldRule $rule) { | |||||
$phid = $rule->getPHID(); | |||||
$this->ruleStack[$phid] = $rule; | |||||
return $this; | |||||
} | |||||
private function getRuleStack() { | |||||
return array_values($this->ruleStack); | |||||
} | |||||
/* -( Rule Results )------------------------------------------------------- */ | |||||
private function resetRuleResults() { | |||||
$this->ruleResults = array(); | |||||
return $this; | |||||
} | |||||
private function setRuleResult( | |||||
HeraldRule $rule, | |||||
HeraldRuleResult $result) { | |||||
$phid = $rule->getPHID(); | |||||
if ($this->hasRuleResult($rule)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Herald rule "%s" already has an evaluation result.', | |||||
$phid)); | |||||
} | |||||
$this->ruleResults[$phid] = $result; | |||||
$this->newRuleTranscript($rule) | |||||
->setRuleResult($result); | |||||
return $this; | |||||
} | |||||
private function hasRuleResult(HeraldRule $rule) { | |||||
$phid = $rule->getPHID(); | |||||
return isset($this->ruleResults[$phid]); | |||||
} | |||||
private function getRuleResult(HeraldRule $rule) { | |||||
$phid = $rule->getPHID(); | |||||
if (!$this->hasRuleResult($rule)) { | |||||
throw new Exception( | |||||
pht( | |||||
'Herald rule "%s" does not have an evaluation result.', | |||||
$phid)); | |||||
} | |||||
return $this->ruleResults[$phid]; | |||||
} | |||||
public function applyRules(array $rules, HeraldAdapter $object) { | public function applyRules(array $rules, HeraldAdapter $object) { | ||||
assert_instances_of($rules, 'HeraldRule'); | assert_instances_of($rules, 'HeraldRule'); | ||||
$t_start = microtime(true); | $t_start = microtime(true); | ||||
// Rules execute in a well-defined order: sort them into execution order. | // Rules execute in a well-defined order: sort them into execution order. | ||||
$rules = msort($rules, 'getRuleExecutionOrderSortKey'); | $rules = msort($rules, 'getRuleExecutionOrderSortKey'); | ||||
$rules = mpull($rules, null, 'getPHID'); | $rules = mpull($rules, null, 'getPHID'); | ||||
$this->transcript = new HeraldTranscript(); | $this->transcript = new HeraldTranscript(); | ||||
$this->transcript->setObjectPHID((string)$object->getPHID()); | $this->transcript->setObjectPHID((string)$object->getPHID()); | ||||
$this->fieldCache = array(); | $this->fieldCache = array(); | ||||
$this->fieldExceptions = array(); | $this->fieldExceptions = array(); | ||||
$this->results = array(); | |||||
$this->rules = $rules; | $this->rules = $rules; | ||||
$this->object = $object; | $this->object = $object; | ||||
$this->resetRuleResults(); | |||||
$effects = array(); | $effects = array(); | ||||
foreach ($rules as $phid => $rule) { | foreach ($rules as $phid => $rule) { | ||||
$this->stack = array(); | $this->resetRuleStack(); | ||||
$is_first_only = $rule->isRepeatFirst(); | |||||
$caught = null; | $caught = null; | ||||
$result = null; | |||||
try { | try { | ||||
$is_first_only = $rule->isRepeatFirst(); | |||||
if (!$this->getDryRun() && | if (!$this->getDryRun() && | ||||
$is_first_only && | $is_first_only && | ||||
$rule->getRuleApplied($object->getPHID())) { | $rule->getRuleApplied($object->getPHID())) { | ||||
// This is not a dry run, and this rule is only supposed to be | // This is not a dry run, and this rule is only supposed to be | ||||
// applied a single time, and it's already been applied... | // applied a single time, and it has already been applied. | ||||
// That means automatic failure. | // That means automatic failure. | ||||
$this->newRuleTranscript($rule) | |||||
->setResult(false) | |||||
->setReason( | |||||
pht( | |||||
'This rule is only supposed to be repeated a single time, '. | |||||
'and it has already been applied.')); | |||||
$rule_matches = false; | |||||
} else { | |||||
if ($this->isForbidden($rule, $object)) { | |||||
$this->newRuleTranscript($rule) | |||||
->setResult(HeraldRuleTranscript::RESULT_FORBIDDEN) | |||||
->setReason( | |||||
pht( | |||||
'Object state is not compatible with rule.')); | |||||
$rule_matches = false; | $result_code = HeraldRuleResult::RESULT_ALREADY_APPLIED; | ||||
$result = HeraldRuleResult::newFromResultCode($result_code); | |||||
} else if ($this->isForbidden($rule, $object)) { | |||||
$result_code = HeraldRuleResult::RESULT_OBJECT_STATE; | |||||
$result = HeraldRuleResult::newFromResultCode($result_code); | |||||
} else { | } else { | ||||
$rule_matches = $this->doesRuleMatch($rule, $object); | $result = $this->getRuleMatchResult($rule, $object); | ||||
} | |||||
} | } | ||||
} catch (HeraldRecursiveConditionsException $ex) { | } catch (HeraldRecursiveConditionsException $ex) { | ||||
$names = array(); | $cycle_phids = array(); | ||||
foreach ($this->stack as $rule_phid => $ignored) { | |||||
$names[] = '"'.$rules[$rule_phid]->getName().'"'; | $stack = $this->getRuleStack(); | ||||
} | foreach ($stack as $stack_rule) { | ||||
$names = implode(', ', $names); | $cycle_phids[] = $stack_rule->getPHID(); | ||||
foreach ($this->stack as $rule_phid => $ignored) { | } | ||||
$this->newRuleTranscript($rules[$rule_phid]) | // Add the rule which actually cycled to the list to make the | ||||
->setResult(false) | // result more clear when we show it to the user. | ||||
->setReason( | $cycle_phids[] = $phid; | ||||
pht( | |||||
"Rules %s are recursively dependent upon one another! ". | foreach ($stack as $stack_rule) { | ||||
"Don't do this! You have formed an unresolvable cycle in the ". | if ($this->hasRuleResult($stack_rule)) { | ||||
"dependency graph!", | continue; | ||||
$names)); | |||||
} | } | ||||
$rule_matches = false; | |||||
$result_code = HeraldRuleResult::RESULT_RECURSION; | |||||
$result_data = array( | |||||
'cyclePHIDs' => $cycle_phids, | |||||
); | |||||
$result = HeraldRuleResult::newFromResultCode($result_code) | |||||
->setResultData($result_data); | |||||
$this->setRuleResult($stack_rule, $result); | |||||
} | |||||
$result = $this->getRuleResult($rule); | |||||
} catch (HeraldRuleEvaluationException $ex) { | |||||
// When we encounter an evaluation exception, the condition which | |||||
// failed to evaluate is responsible for logging the details of the | |||||
// error. | |||||
$result_code = HeraldRuleResult::RESULT_EVALUATION_EXCEPTION; | |||||
$result = HeraldRuleResult::newFromResultCode($result_code); | |||||
} catch (Exception $ex) { | } catch (Exception $ex) { | ||||
$caught = $ex; | $caught = $ex; | ||||
} catch (Throwable $ex) { | } catch (Throwable $ex) { | ||||
$caught = $ex; | $caught = $ex; | ||||
} | } | ||||
if ($caught) { | if ($caught) { | ||||
$this->newRuleTranscript($rules[$phid]) | // These exceptions are unexpected, and did not arise during rule | ||||
->setResult(false) | // evaluation, so we're responsible for handling the details. | ||||
->setReason( | |||||
pht( | $result_code = HeraldRuleResult::RESULT_EXCEPTION; | ||||
'Rule encountered an exception while evaluting.')); | |||||
$rule_matches = false; | $result_data = array( | ||||
'exception.class' => get_class($caught), | |||||
'exception.message' => $ex->getMessage(), | |||||
); | |||||
$result = HeraldRuleResult::newFromResultCode($result_code) | |||||
->setResultData($result_data); | |||||
} | } | ||||
$this->results[$phid] = $rule_matches; | if (!$this->hasRuleResult($rule)) { | ||||
$this->setRuleResult($rule, $result); | |||||
} | |||||
$result = $this->getRuleResult($rule); | |||||
if ($rule_matches) { | if ($result->getShouldApplyActions()) { | ||||
foreach ($this->getRuleEffects($rule, $object) as $effect) { | foreach ($this->getRuleEffects($rule, $object) as $effect) { | ||||
$effects[] = $effect; | $effects[] = $effect; | ||||
} | } | ||||
} | } | ||||
} | } | ||||
$xaction_phids = null; | $xaction_phids = null; | ||||
$xactions = $object->getAppliedTransactions(); | $xactions = $object->getAppliedTransactions(); | ||||
▲ Show 20 Lines • Show All 130 Lines • ▼ Show 20 Lines | /* -( Rule Results )------------------------------------------------------- */ | ||||
public function getTranscript() { | public function getTranscript() { | ||||
$this->transcript->save(); | $this->transcript->save(); | ||||
return $this->transcript; | return $this->transcript; | ||||
} | } | ||||
public function doesRuleMatch( | public function doesRuleMatch( | ||||
HeraldRule $rule, | HeraldRule $rule, | ||||
HeraldAdapter $object) { | HeraldAdapter $object) { | ||||
$result = $this->getRuleMatchResult($rule, $object); | |||||
return $result->getShouldApplyActions(); | |||||
} | |||||
$phid = $rule->getPHID(); | private function getRuleMatchResult( | ||||
HeraldRule $rule, | |||||
HeraldAdapter $object) { | |||||
if (isset($this->results[$phid])) { | if ($this->hasRuleResult($rule)) { | ||||
// If we've already evaluated this rule because another rule depends | // If we've already evaluated this rule because another rule depends | ||||
// on it, we don't need to reevaluate it. | // on it, we don't need to reevaluate it. | ||||
return $this->results[$phid]; | return $this->getRuleResult($rule); | ||||
} | } | ||||
if (isset($this->stack[$phid])) { | if ($this->hasRuleOnStack($rule)) { | ||||
// We've recursed, fail all of the rules on the stack. This happens when | // We've recursed, fail all of the rules on the stack. This happens when | ||||
// there's a dependency cycle with "Rule conditions match for rule ..." | // there's a dependency cycle with "Rule conditions match for rule ..." | ||||
// conditions. | // conditions. | ||||
foreach ($this->stack as $rule_phid => $ignored) { | |||||
$this->results[$rule_phid] = false; | |||||
} | |||||
throw new HeraldRecursiveConditionsException(); | throw new HeraldRecursiveConditionsException(); | ||||
} | } | ||||
$this->pushRuleStack($rule); | |||||
$this->stack[$phid] = true; | |||||
$all = $rule->getMustMatchAll(); | $all = $rule->getMustMatchAll(); | ||||
$conditions = $rule->getConditions(); | $conditions = $rule->getConditions(); | ||||
$result = null; | $result_code = null; | ||||
$result_data = array(); | |||||
$local_version = id(new HeraldRule())->getConfigVersion(); | $local_version = id(new HeraldRule())->getConfigVersion(); | ||||
if ($rule->getConfigVersion() > $local_version) { | if ($rule->getConfigVersion() > $local_version) { | ||||
$reason = pht( | $result_code = HeraldRuleResult::RESULT_VERSION; | ||||
'Rule could not be processed, it was created with a newer version '. | |||||
'of Herald.'); | |||||
$result = false; | |||||
} else if (!$conditions) { | } else if (!$conditions) { | ||||
$reason = pht( | $result_code = HeraldRuleResult::RESULT_EMPTY; | ||||
'Rule failed automatically because it has no conditions.'); | |||||
$result = false; | |||||
} else if (!$rule->hasValidAuthor()) { | } else if (!$rule->hasValidAuthor()) { | ||||
$reason = pht( | $result_code = HeraldRuleResult::RESULT_OWNER; | ||||
'Rule failed automatically because its owner is invalid '. | |||||
'or disabled.'); | |||||
$result = false; | |||||
} else if (!$this->canAuthorViewObject($rule, $object)) { | } else if (!$this->canAuthorViewObject($rule, $object)) { | ||||
$reason = pht( | $result_code = HeraldRuleResult::RESULT_VIEW_POLICY; | ||||
'Rule failed automatically because it is a personal rule and its '. | |||||
'owner can not see the object.'); | |||||
$result = false; | |||||
} else if (!$this->canRuleApplyToObject($rule, $object)) { | } else if (!$this->canRuleApplyToObject($rule, $object)) { | ||||
$reason = pht( | $result_code = HeraldRuleResult::RESULT_OBJECT_RULE; | ||||
'Rule failed automatically because it is an object rule which is '. | |||||
'not relevant for this object.'); | |||||
$result = false; | |||||
} else { | } else { | ||||
foreach ($conditions as $condition) { | foreach ($conditions as $condition) { | ||||
$caught = null; | $caught = null; | ||||
try { | try { | ||||
$match = $this->doesConditionMatch( | $match = $this->doesConditionMatch( | ||||
$rule, | $rule, | ||||
$condition, | $condition, | ||||
$object); | $object); | ||||
} catch (HeraldRuleEvaluationException $ex) { | |||||
throw $ex; | |||||
} catch (HeraldRecursiveConditionsException $ex) { | |||||
throw $ex; | |||||
} catch (Exception $ex) { | } catch (Exception $ex) { | ||||
$caught = $ex; | $caught = $ex; | ||||
} catch (Throwable $ex) { | } catch (Throwable $ex) { | ||||
$caught = $ex; | $caught = $ex; | ||||
} | } | ||||
if ($caught) { | if ($caught) { | ||||
throw $ex; | throw new HeraldRuleEvaluationException(); | ||||
} | } | ||||
if (!$all && $match) { | if (!$all && $match) { | ||||
$reason = pht('Any condition matched.'); | $result_code = HeraldRuleResult::RESULT_ANY_MATCHED; | ||||
$result = true; | |||||
break; | break; | ||||
} | } | ||||
if ($all && !$match) { | if ($all && !$match) { | ||||
$reason = pht('Not all conditions matched.'); | $result_code = HeraldRuleResult::RESULT_ANY_FAILED; | ||||
$result = false; | |||||
break; | break; | ||||
} | } | ||||
} | } | ||||
if ($result === null) { | if ($result_code === null) { | ||||
if ($all) { | if ($all) { | ||||
$reason = pht('All conditions matched.'); | $result_code = HeraldRuleResult::RESULT_ALL_MATCHED; | ||||
$result = true; | |||||
} else { | } else { | ||||
$reason = pht('No conditions matched.'); | $result_code = HeraldRuleResult::RESULT_ALL_FAILED; | ||||
$result = false; | |||||
} | } | ||||
} | } | ||||
} | } | ||||
// If this rule matched, and is set to run "if it did not match the last | // If this rule matched, and is set to run "if it did not match the last | ||||
// time", and we matched the last time, we're going to return a match in | // time", and we matched the last time, we're going to return a special | ||||
// the transcript but set a flag so we don't actually apply any effects. | // result code which records a match but doesn't actually apply effects. | ||||
// We need the rule to match so that storage gets updated properly. If we | // We need the rule to match so that storage gets updated properly. If we | ||||
// just pretend the rule didn't match it won't cause any effects (which | // just pretend the rule didn't match it won't cause any effects (which | ||||
// is correct), but it also won't set the "it matched" flag in storage, | // is correct), but it also won't set the "it matched" flag in storage, | ||||
// so the next run after this one would incorrectly trigger again. | // so the next run after this one would incorrectly trigger again. | ||||
$result = HeraldRuleResult::newFromResultCode($result_code) | |||||
->setResultData($result_data); | |||||
$should_apply = $result->getShouldApplyActions(); | |||||
$is_dry_run = $this->getDryRun(); | $is_dry_run = $this->getDryRun(); | ||||
if ($result && !$is_dry_run) { | if ($should_apply && !$is_dry_run) { | ||||
$is_on_change = $rule->isRepeatOnChange(); | $is_on_change = $rule->isRepeatOnChange(); | ||||
if ($is_on_change) { | if ($is_on_change) { | ||||
$did_apply = $rule->getRuleApplied($object->getPHID()); | $did_apply = $rule->getRuleApplied($object->getPHID()); | ||||
if ($did_apply) { | if ($did_apply) { | ||||
$reason = pht( | // Replace the result with our modified result. | ||||
'This rule matched, but did not take any actions because it '. | $result_code = HeraldRuleResult::RESULT_LAST_MATCHED; | ||||
'is configured to act only if it did not match the last time.'); | $result = HeraldRuleResult::newFromResultCode($result_code); | ||||
$this->skipEffects[$rule->getID()] = true; | $this->skipEffects[$rule->getID()] = true; | ||||
} | } | ||||
} | } | ||||
} | } | ||||
$this->newRuleTranscript($rule) | $this->setRuleResult($rule, $result); | ||||
->setResult($result) | |||||
->setReason($reason); | |||||
return $result; | return $result; | ||||
} | } | ||||
private function doesConditionMatch( | private function doesConditionMatch( | ||||
HeraldRule $rule, | HeraldRule $rule, | ||||
HeraldCondition $condition, | HeraldCondition $condition, | ||||
HeraldAdapter $adapter) { | HeraldAdapter $adapter) { | ||||
Show All 15 Lines | try { | ||||
$rule, | $rule, | ||||
$condition, | $condition, | ||||
$field_value); | $field_value); | ||||
if ($is_match) { | if ($is_match) { | ||||
$result_code = HeraldConditionResult::RESULT_MATCHED; | $result_code = HeraldConditionResult::RESULT_MATCHED; | ||||
} else { | } else { | ||||
$result_code = HeraldConditionResult::RESULT_FAILED; | $result_code = HeraldConditionResult::RESULT_FAILED; | ||||
} | } | ||||
} catch (HeraldRecursiveConditionsException $ex) { | |||||
$result_code = HeraldConditionResult::RESULT_RECURSION; | |||||
$caught = $ex; | |||||
} catch (HeraldInvalidConditionException $ex) { | } catch (HeraldInvalidConditionException $ex) { | ||||
$result_code = HeraldConditionResult::RESULT_INVALID; | $result_code = HeraldConditionResult::RESULT_INVALID; | ||||
$caught = $ex; | $caught = $ex; | ||||
} catch (Exception $ex) { | } catch (Exception $ex) { | ||||
$result_code = HeraldConditionResult::RESULT_EXCEPTION; | $result_code = HeraldConditionResult::RESULT_EXCEPTION; | ||||
$caught = $ex; | $caught = $ex; | ||||
} catch (Throwable $ex) { | } catch (Throwable $ex) { | ||||
$result_code = HeraldConditionResult::RESULT_EXCEPTION; | $result_code = HeraldConditionResult::RESULT_EXCEPTION; | ||||
▲ Show 20 Lines • Show All 449 Lines • Show Last 20 Lines |