From 5504a056f874eb01d4005188ca89f01930bbfc1d Mon Sep 17 00:00:00 2001 From: Pooja Malik Date: Wed, 6 Jun 2018 14:28:51 -0700 Subject: [PATCH] Adding advisor Rules and parser scripts with unit tests. (#3934) Summary: This adds some rules in the tools/advisor/advisor/rules.ini (refer this for more information) file and corresponding python parser scripts for parsing the rules file and the rocksdb LOG and OPTIONS files. This is WIP for adding rules depending on ODS. The starting point of the script is the rocksdb/tools/advisor/advisor/rule_parser.py file. Closes https://github.com/facebook/rocksdb/pull/3934 Reviewed By: maysamyabandeh Differential Revision: D8304059 Pulled By: poojam23 fbshipit-source-id: 47f2a50f04d46d40e225dd1cbf58ba490f79e239 --- tools/advisor/advisor/__init__.py | 0 tools/advisor/advisor/db_log_parser.py | 96 +++++ tools/advisor/advisor/db_options_parser.py | 107 +++++ tools/advisor/advisor/ini_parser.py | 76 ++++ tools/advisor/advisor/rule_parser.py | 365 ++++++++++++++++++ tools/advisor/advisor/rules.ini | 111 ++++++ tools/advisor/test/__init__.py | 0 tools/advisor/test/input_files/LOG-0 | 25 ++ tools/advisor/test/input_files/LOG-1 | 25 ++ tools/advisor/test/input_files/OPTIONS-000005 | 31 ++ tools/advisor/test/input_files/rules_err1.ini | 58 +++ tools/advisor/test/input_files/rules_err2.ini | 16 + tools/advisor/test/input_files/rules_err3.ini | 16 + tools/advisor/test/input_files/rules_err4.ini | 16 + tools/advisor/test/input_files/test_rules.ini | 51 +++ tools/advisor/test/test_rule_parser.py | 213 ++++++++++ 16 files changed, 1206 insertions(+) create mode 100644 tools/advisor/advisor/__init__.py create mode 100644 tools/advisor/advisor/db_log_parser.py create mode 100644 tools/advisor/advisor/db_options_parser.py create mode 100644 tools/advisor/advisor/ini_parser.py create mode 100644 tools/advisor/advisor/rule_parser.py create mode 100644 tools/advisor/advisor/rules.ini create mode 100644 tools/advisor/test/__init__.py create mode 100644 tools/advisor/test/input_files/LOG-0 create mode 100644 tools/advisor/test/input_files/LOG-1 create mode 100644 tools/advisor/test/input_files/OPTIONS-000005 create mode 100644 tools/advisor/test/input_files/rules_err1.ini create mode 100644 tools/advisor/test/input_files/rules_err2.ini create mode 100644 tools/advisor/test/input_files/rules_err3.ini create mode 100644 tools/advisor/test/input_files/rules_err4.ini create mode 100644 tools/advisor/test/input_files/test_rules.ini create mode 100644 tools/advisor/test/test_rule_parser.py diff --git a/tools/advisor/advisor/__init__.py b/tools/advisor/advisor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/advisor/advisor/db_log_parser.py b/tools/advisor/advisor/db_log_parser.py new file mode 100644 index 000000000..a62c2e197 --- /dev/null +++ b/tools/advisor/advisor/db_log_parser.py @@ -0,0 +1,96 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). + +from abc import ABC, abstractmethod +import glob +import re +from enum import Enum + + +class DataSource(ABC): + class Type(Enum): + LOG = 1 + DB_OPTIONS = 2 + STATS = 3 + PERF_CONTEXT = 4 + ODS = 5 + + def __init__(self, type): + self.type = type + + @abstractmethod + def check_and_trigger_conditions(self, conditions): + pass + + +class Log: + @staticmethod + def is_new_log(log_line): + # The assumption is that a new log will start with a date printed in + # the below regex format. + date_regex = '\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2}\.\d{6}' + return re.match(date_regex, log_line) + + def __init__(self, log_line): + token_list = log_line.strip().split() + self.time = token_list[0] + self.context = token_list[1] + self.message = " ".join(token_list[2:]) + + def get_time(self): + return self.time + + def get_context(self): + return self.context + + def get_message(self): + return self.message + + def append_message(self, remaining_log): + self.message = self.message + remaining_log + + def __repr__(self): + return 'time: ' + self.time + ', context: ' + self.context +\ + ', message: ' + self.message + + +class DatabaseLogs(DataSource): + def __init__(self, logs_path_prefix): + super().__init__(DataSource.Type.LOG) + self.logs_path_prefix = logs_path_prefix + + def trigger_appropriate_conditions(self, conditions, log): + conditions_to_be_removed = [] + for cond in conditions: + if re.search(cond.regex, log.get_message(), re.IGNORECASE): + cond.set_trigger(log) + conditions_to_be_removed.append(cond) + for remove_cond in conditions_to_be_removed: + conditions.remove(remove_cond) + return conditions + + def check_and_trigger_conditions(self, conditions): + for file_name in glob.glob(self.logs_path_prefix + '*'): + with open(file_name, 'r') as db_logs: + new_log = None + for line in db_logs: + if not conditions: + break + if Log.is_new_log(line): + if new_log: + conditions = self.trigger_appropriate_conditions( + conditions, + new_log + ) + new_log = Log(line) + else: + # To account for logs split into multiple lines + new_log.append_message(line) + # Check for the last log in the file. + if new_log and conditions: + conditions = self.trigger_appropriate_conditions( + conditions, + new_log + ) diff --git a/tools/advisor/advisor/db_options_parser.py b/tools/advisor/advisor/db_options_parser.py new file mode 100644 index 000000000..a8f84245a --- /dev/null +++ b/tools/advisor/advisor/db_options_parser.py @@ -0,0 +1,107 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). + +from advisor.db_log_parser import DataSource +from advisor.ini_parser import IniParser + + +class OptionsSpecParser(IniParser): + @staticmethod + def is_new_option(line): + return '=' in line + + @staticmethod + def get_section_type(line): + ''' + Example section header: [TableOptions/BlockBasedTable "default"] + Here section_type returned would be 'TableOptions.BlockBasedTable' + ''' + section_path = line.strip()[1:-1].split()[0] + section_type = '.'.join(section_path.split('/')) + return section_type + + @staticmethod + def get_section_name(line): + token_list = line.strip()[1:-1].split('"') + if len(token_list) < 3: + return None + return token_list[1] + + +class DatabaseOptions(DataSource): + def __init__(self, rocksdb_options): + super().__init__(DataSource.Type.DB_OPTIONS) + self.options_path = rocksdb_options + # Load the options from the given file to a dictionary. + self.load_from_source() + self.options_dict = None + self.column_families = None + + def load_from_source(self): + self.options_dict = {} + with open(self.options_path, 'r') as db_options: + for line in db_options: + line = OptionsSpecParser.remove_trailing_comment(line) + if not line: + continue + if OptionsSpecParser.is_section_header(line): + curr_sec_type = OptionsSpecParser.get_section_type(line) + curr_sec_name = OptionsSpecParser.get_section_name(line) + if curr_sec_name: + option_prefix = curr_sec_name + '.' + curr_sec_type + if curr_sec_type == 'CFOptions': + if not self.column_families: + self.column_families = [] + self.column_families.append(curr_sec_name) + else: + option_prefix = curr_sec_type + elif OptionsSpecParser.is_new_option(line): + key, value = OptionsSpecParser.get_key_value_pair(line) + if not self.options_dict: + self.options_dict = {} + self.options_dict[option_prefix + '.' + key] = value + else: + error = 'Not able to parse line in Options file.' + OptionsSpecParser.exit_with_parse_error(line, error) + + def check_and_trigger_conditions(self, conditions): + ''' + For every condition, if the fields are not present set_trigger will + not be called for it. Or if all the fields are present, then the + trigger will be set to whatever the expression evaluates to. + ''' + for cond in conditions: + # This contains the indices of options to whose name the column + # family name needs to be prepended in order to create the full + # option name as parsed from the options file. + incomplete_option_ix = [] + ix = 0 + options = [] + for option in cond.options: + if option in self.options_dict.keys(): + options.append(self.options_dict[option]) + else: + incomplete_option_ix.append(ix) + options.append(0) + ix += 1 + + # if all the options were present as is: + if not incomplete_option_ix: + if not eval(cond.eval_expr): + cond.set_trigger(cond.eval_expr) + continue + + # for all the options that were not present as is, we prepend them + # their names with every column family found in options file. + for col_fam in self.column_families: + present = True + for ix in incomplete_option_ix: + full_option = col_fam + '.' + cond.options[ix] + if full_option not in self.options_dict.keys(): + present = False + break + options[ix] = self.options_dict[full_option] + if present and not eval(cond.eval_expr): + cond.set_trigger(cond.eval_expr) diff --git a/tools/advisor/advisor/ini_parser.py b/tools/advisor/advisor/ini_parser.py new file mode 100644 index 000000000..5439589dc --- /dev/null +++ b/tools/advisor/advisor/ini_parser.py @@ -0,0 +1,76 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). + +from enum import Enum + + +class IniParser: + class Element(Enum): + rule = 1 + cond = 2 + sugg = 3 + key_val = 4 + comment = 5 + + @staticmethod + def remove_trailing_comment(line): + line = line.strip() + comment_start = line.find('#') + if comment_start > -1: + return line[:comment_start] + return line + + @staticmethod + def is_section_header(line): + # A section header looks like: [Rule "my-new-rule"]. Essentially, + # a line that is in square-brackets. + line = line.strip() + if line.startswith('[') and line.endswith(']'): + return True + return False + + @staticmethod + def get_section_name(line): + # For a section header: [Rule "my-new-rule"], this method will return + # "my-new-rule". + token_list = line.strip()[1:-1].split('"') + if len(token_list) < 3: + error = 'needed section header: [ ""]' + raise ValueError('Parsing error: ' + error + '\n' + line) + return token_list[1] + + @staticmethod + def get_element(line): + line = IniParser.remove_trailing_comment(line) + if not line: + return IniParser.Element.comment + if IniParser.is_section_header(line): + if line.strip()[1:-1].startswith('Suggestion'): + return IniParser.Element.sugg + if line.strip()[1:-1].startswith('Rule'): + return IniParser.Element.rule + if line.strip()[1:-1].startswith('Condition'): + return IniParser.Element.cond + if '=' in line: + return IniParser.Element.key_val + error = 'not a recognizable RulesSpec element' + raise ValueError('Parsing error: ' + error + '\n' + line) + + @staticmethod + def get_key_value_pair(line): + line = line.strip() + key = line.split('=')[0].strip() + value = line.split('=')[1].strip() + if not value: + return (key, None) + values = IniParser.get_list_from_value(value) + if len(values) == 1: + return (key, value) + return (key, values) + + @staticmethod + def get_list_from_value(value): + values = value.strip().split(':') + return values diff --git a/tools/advisor/advisor/rule_parser.py b/tools/advisor/advisor/rule_parser.py new file mode 100644 index 000000000..bba89c781 --- /dev/null +++ b/tools/advisor/advisor/rule_parser.py @@ -0,0 +1,365 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). + +from abc import ABC, abstractmethod +import argparse +from advisor.db_log_parser import DatabaseLogs, DataSource +from advisor.db_options_parser import DatabaseOptions +from enum import Enum +from advisor.ini_parser import IniParser + + +class Section(ABC): + def __init__(self, name): + self.name = name + + @abstractmethod + def set_parameter(self, key, value): + pass + + @abstractmethod + def perform_checks(self): + pass + + +class Rule(Section): + def __init__(self, name): + super().__init__(name) + self.conditions = None + self.suggestions = None + + def set_parameter(self, key, value): + # If the Rule is associated with a single suggestion/condition, then + # value will be a string and not a list. Hence, convert it to a single + # element list before storing it in self.suggestions or + # self.conditions. + if key == 'conditions': + if isinstance(value, str): + self.conditions = [value] + else: + self.conditions = value + elif key == 'suggestions': + if isinstance(value, str): + self.suggestions = [value] + else: + self.suggestions = value + + def get_suggestions(self): + return self.suggestions + + def perform_checks(self): + if not self.conditions or len(self.conditions) < 1: + raise ValueError( + self.name + ': rule must have at least one condition' + ) + if not self.suggestions or len(self.suggestions) < 1: + raise ValueError( + self.name + ': rule must have at least one suggestion' + ) + + def is_triggered(self, conditions_dict): + condition_triggers = [] + for cond in self.conditions: + condition_triggers.append(conditions_dict[cond].is_triggered()) + return all(condition_triggers) + + def __repr__(self): + # Append conditions + rule_string = "Rule: " + self.name + " has conditions:: " + is_first = True + for cond in self.conditions: + if is_first: + rule_string += cond + is_first = False + else: + rule_string += (" AND " + cond) + # Append suggestions + rule_string += "\nsuggestions:: " + is_first = True + for sugg in self.suggestions: + if is_first: + rule_string += sugg + is_first = False + else: + rule_string += (", " + sugg) + # Return constructed string + return rule_string + + +class Suggestion(Section): + class Action(Enum): + set = 1 + increase = 2 + decrease = 3 + + def __init__(self, name): + super().__init__(name) + self.option = None + self.action = None + self.suggested_value = None + self.description = None + + def set_parameter(self, key, value): + if key == 'option': + self.option = value + elif key == 'action': + if self.option and not value: + raise ValueError(self.name + ': provide action for option') + self.action = self.Action[value] + elif key == 'suggested_value': + self.suggested_value = value + elif key == 'description': + self.description = value + + def perform_checks(self): + if not self.description: + if not self.option: + raise ValueError(self.name + ': provide option or description') + if not self.action: + raise ValueError(self.name + ': provide action for option') + if self.action is self.Action.set and not self.suggested_value: + raise ValueError( + self.name + ': provide suggested value for option' + ) + + def __repr__(self): + if self.description: + return self.description + sugg_string = "" + if self.action is self.Action.set: + sugg_string = ( + self.name + ' suggests setting ' + self.option + + ' to ' + self.suggested_value + ) + else: + sugg_string = self.name + ' suggests ' + self.action.name + ' in ' + sugg_string += (self.option + '.') + if self.suggested_value: + sugg_string += ( + ' The suggested value is ' + self.suggested_value + ) + return sugg_string + + +class Condition(Section): + def __init__(self, name): + # a rule is identified by its name, so there should be no duplicates + super().__init__(name) + self.data_source = None + self.trigger = None + + def perform_checks(self): + if not self.data_source: + raise ValueError(self.name + ': condition not tied to data source') + + def set_data_source(self, data_source): + self.data_source = data_source + + def get_data_source(self): + return self.data_source + + def reset_trigger(self): + self.trigger = None + + def set_trigger(self, condition_trigger): + self.trigger = condition_trigger + + def is_triggered(self): + if self.trigger: + return True + return False + + def set_parameter(self, key, value): + # must be defined by the subclass + raise ValueError(self.name + ': provide source for condition') + + +class LogCondition(Condition): + @classmethod + def create(cls, base_condition): + base_condition.set_data_source(DataSource.Type['LOG']) + base_condition.__class__ = cls + return base_condition + + class Scope(Enum): + database = 1 + column_family = 2 + + def set_parameter(self, key, value): + if key == 'regex': + self.regex = value + elif key == 'scope': + self.scope = self.Scope[value] + + def perform_checks(self): + super().perform_checks() + if not self.regex: + raise ValueError(self.name + ': provide regex for log condition') + + def __repr__(self): + log_cond_str = ( + self.name + ' checks if the regex ' + self.regex + ' is found ' + + ' in the LOG file in the scope of ' + self.scope.name + ) + return log_cond_str + + +class OptionCondition(Condition): + @classmethod + def create(cls, base_condition): + base_condition.set_data_source(DataSource.Type['DB_OPTIONS']) + base_condition.__class__ = cls + return base_condition + + def set_parameter(self, key, value): + if key == 'options': + self.options = value + if key == 'evaluate': + self.eval_expr = value + + def perform_checks(self): + super().perform_checks() + if not self.options: + raise ValueError(self.name + ': options missing in condition') + if not self.eval_expr: + raise ValueError(self.name + ': expression missing in condition') + + def __repr__(self): + log_cond_str = ( + self.name + ' checks if the given expression evaluates to true' + ) + return log_cond_str + + +class RulesSpec: + def __init__(self, rules_path): + self.file_path = rules_path + self.rules_dict = {} + self.conditions_dict = {} + self.suggestions_dict = {} + + def perform_section_checks(self): + for rule in self.rules_dict.values(): + rule.perform_checks() + for cond in self.conditions_dict.values(): + cond.perform_checks() + for sugg in self.suggestions_dict.values(): + sugg.perform_checks() + + def load_rules_from_spec(self): + with open(self.file_path, 'r') as db_rules: + curr_section = None + for line in db_rules: + element = IniParser.get_element(line) + if element is IniParser.Element.comment: + continue + elif element is not IniParser.Element.key_val: + curr_section = element # it's a new IniParser header + section_name = IniParser.get_section_name(line) + if element is IniParser.Element.rule: + new_rule = Rule(section_name) + self.rules_dict[section_name] = new_rule + elif element is IniParser.Element.cond: + new_cond = Condition(section_name) + self.conditions_dict[section_name] = new_cond + elif element is IniParser.Element.sugg: + new_suggestion = Suggestion(section_name) + self.suggestions_dict[section_name] = new_suggestion + elif element is IniParser.Element.key_val: + key, value = IniParser.get_key_value_pair(line) + if curr_section is IniParser.Element.rule: + new_rule.set_parameter(key, value) + elif curr_section is IniParser.Element.cond: + if key == 'source': + if value == 'LOG': + new_cond = LogCondition.create(new_cond) + elif value == 'OPTIONS': + new_cond = OptionCondition.create(new_cond) + else: + new_cond.set_parameter(key, value) + elif curr_section is IniParser.Element.sugg: + new_suggestion.set_parameter(key, value) + + def get_rules_dict(self): + return self.rules_dict + + def get_conditions_dict(self): + return self.conditions_dict + + def get_suggestions_dict(self): + return self.suggestions_dict + + +def trigger_conditions(data_sources, conditions_dict): + for source in data_sources: + cond_subset = [ + cond + for cond in conditions_dict.values() + if cond.get_data_source() is source.type + ] + if not cond_subset: + continue + source.check_and_trigger_conditions(cond_subset) + + +def get_triggered_rules(rules_dict, conditions_dict): + triggered_rules = [] + for rule in rules_dict.values(): + if rule.is_triggered(conditions_dict): + triggered_rules.append(rule) + return triggered_rules + + +def main(args): + # Load the rules with their conditions and suggestions. + db_rules = RulesSpec(args.rules_spec) + db_rules.load_rules_from_spec() + # Perform some basic sanity checks for each section. + db_rules.perform_section_checks() + rules_dict = db_rules.get_rules_dict() + conditions_dict = db_rules.get_conditions_dict() + suggestions_dict = db_rules.get_suggestions_dict() + print() + print('RULES') + for rule in rules_dict.values(): + print(repr(rule)) + print() + print('CONDITIONS') + for cond in conditions_dict.values(): + print(repr(cond)) + print() + print('SUGGESTIONS') + for sugg in suggestions_dict.values(): + print(repr(sugg)) + + # Initialise the data sources. + data_sources = [] + data_sources.append(DatabaseOptions(args.rocksdb_options)) + data_sources.append(DatabaseLogs(args.rocksdb_log_prefix)) + + # Initialise the ConditionChecker with the provided data sources. + trigger_conditions(data_sources, conditions_dict) + + # Check for the conditions read in from the Rules spec, if triggered. + print() + triggered_rules = get_triggered_rules(rules_dict, conditions_dict) + for rule in triggered_rules: + print('Rule: ' + rule.name + ' has been triggered and:') + rule_suggestions = rule.get_suggestions() + for sugg_name in rule_suggestions: + print(suggestions_dict[sugg_name]) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='This script is used for\ + gauging rocksdb performance using as input: Rocksdb LOG, OPTIONS,\ + performance context, command-line statistics and statistics published\ + on ODS and providing as output: suggestions to improve Rocksdb\ + performance') + parser.add_argument('--rules_spec', required=True, type=str) + parser.add_argument('--rocksdb_options', required=True, type=str) + parser.add_argument('--rocksdb_log_prefix', required=True, type=str) + args = parser.parse_args() + main(args) diff --git a/tools/advisor/advisor/rules.ini b/tools/advisor/advisor/rules.ini new file mode 100644 index 000000000..3c6a15607 --- /dev/null +++ b/tools/advisor/advisor/rules.ini @@ -0,0 +1,111 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). +# +# This ini file is very similar to the Rocksdb ini file in terms of syntax. +# (refer rocksdb/examples/rocksdb_option_file_example.ini) +# It is made up of multiple sections and each section is made up of multiple +# key-value pairs. Each section must have a name. The recognized sections are +# Rule, Suggestion, Condition followed by their name in "" that acts as an +# identifier. There should be at least one Rule section in the file. +# +# Each rule must be associated with at least one condition and one suggestion. +# If a Rule is associated with multiple Conditions, then all the conditions +# must be triggered in order for the Rule to be triggered. +# The suggestions don't have any ordering amongst them as of now. +# +# A Condition must be associated to a data source specified by the parameter +# 'source' and this must be the first parameter specified for the Condition. +# +# A suggestion is an advised change to a database or column_family option to +# improve the performance of the database in some way. Every suggestion is +# is associated with one or more Rules. + +[Rule "stall-too-many-memtables"] +suggestions=inc-bg-flush:inc-write-buffer +conditions=stall-too-many-memtables + +[Condition "stall-too-many-memtables"] +source=LOG +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Rule "stall-too-many-L0"] +suggestions=inc-max-subcompactions:inc-max-bg-compactions:inc-write-buffer-size:dec-max-bytes-for-level-base:inc-l0-slowdown-writes-trigger +conditions=stall-too-many-L0 + +[Condition "stall-too-many-L0"] +source=LOG +regex=Stalling writes because we have \d+ level-0 files +scope=column_family + +[Rule "stop-too-many-L0"] +suggestions=inc-max-bg-compactions:inc-write-buffer-size:inc-l0-stop-writes-trigger +conditions=stop-too-many-L0 + +[Condition "stop-too-many-L0"] +source=LOG +regex=Stopping writes because we have \d+ level-0 files +scope=column_family + +[Rule "stall-too-many-compaction-bytes"] +suggestions=inc-max-bg-compactions:inc-write-buffer-size:inc-hard-pending-compaction-bytes-limit:inc-soft-pending-compaction-bytes-limit +conditions=stall-too-many-compaction-bytes + +[Condition "stall-too-many-compaction-bytes"] +source=LOG +regex=Stalling writes because of estimated pending compaction bytes \d+ +scope=column_family + +[Suggestion "inc-bg-flush"] +option=DBOptions.max_background_flushes +action=increase + +[Suggestion "inc-write-buffer"] +option=CFOptions.max_write_buffer_number +action=increase + +[Suggestion "inc-max-subcompactions"] +option=DBOptions.max_subcompactions +action=increase + +[Suggestion "inc-max-bg-compactions"] +option=DBOptions.max_background_compactions +action=increase + +[Suggestion "inc-write-buffer-size"] +option=CFOptions.write_buffer_size +action=increase + +[Suggestion "dec-max-bytes-for-level-base"] +option=CFOptions.max_bytes_for_level_base +action=decrease + +[Suggestion "inc-l0-slowdown-writes-trigger"] +option=CFOptions.level0_slowdown_writes_trigger +action=increase + +[Suggestion "inc-l0-stop-writes-trigger"] +option=CFOptions.level0_stop_writes_trigger +action=increase + +[Suggestion "inc-hard-pending-compaction-bytes-limit"] +option=CFOptions.hard_pending_compaction_bytes_limit +action=increase + +[Suggestion "inc-soft-pending-compaction-bytes-limit"] +option=CFOptions.soft_pending_compaction_bytes_limit +action=increase + +[Rule "level0-level1-ratio"] +conditions=level0-level1-ratio +suggestions=l0-l1-ratio-health-check + +[Condition "level0-level1-ratio"] +source=OPTIONS +options=CFOptions.level0_file_num_compaction_trigger:CFOptions.write_buffer_size:CFOptions.max_bytes_for_level_base +evaluate=int(options[0])*int(options[1])-int(options[2])<(-251659456) # should evaluate to a boolean + +[Suggestion "l0-l1-ratio-health-check"] +description='modify options such that (level0_file_num_compaction_trigger * write_buffer_size - max_bytes_for_level_base < 5) is satisfied' diff --git a/tools/advisor/test/__init__.py b/tools/advisor/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/advisor/test/input_files/LOG-0 b/tools/advisor/test/input_files/LOG-0 new file mode 100644 index 000000000..456d33b93 --- /dev/null +++ b/tools/advisor/test/input_files/LOG-0 @@ -0,0 +1,25 @@ +2018/05/25-14:30:05.601692 7f82bd676200 RocksDB version: 5.14.0 +2018/05/25-14:30:07.626719 7f82ba72e700 (Original Log Time 2018/05/25-14:30:07.621966) [db/db_impl_compaction_flush.cc:1424] Calling FlushMemTableToOutputFile with column family [default], flush slots available 1, compaction slots available 1, flush slots scheduled 1, compaction slots scheduled 0 +2018/05/25-14:30:07.626725 7f82ba72e700 [db/flush_job.cc:301] [default] [JOB 3] Flushing memtable with next log file: 8 +2018/05/25-14:30:07.626738 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283807626732, "job": 3, "event": "flush_started", "num_memtables": 1, "num_entries": 28018, "num_deletes": 0, "memory_usage": 4065512, "flush_reason": "Write Buffer Full"} +2018/05/25-14:30:07.626740 7f82ba72e700 [db/flush_job.cc:331] [default] [JOB 3] Level-0 flush table #10: started +2018/05/25-14:30:07.764232 7f82b2f20700 [db/db_impl_write.cc:1373] [default] New memtable created with log file: #11. Immutable memtables: 1. +2018/05/25-14:30:07.764240 7f82b2f20700 [WARN] [db/column_family.cc:743] [default] Stopping writes because we have 2 immutable memtables (waiting for flush), max_write_buffer_number is set to 2 +2018/05/23-11:53:12.800143 7f9f36b40700 [WARN] [db/column_family.cc:799] [default] Stalling writes because we have 4 level-0 files rate 39886 +2018/05/23-11:53:12.800143 7f9f36b40700 [WARN] [db/column_family.cc:799] [default] Stopping writes because we have 4 level-0 files rate 39886 +2018/05/25-14:30:09.398302 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283809398276, "cf_name": "default", "job": 3, "event": "table_file_creation", "file_number": 10, "file_size": 1890434, "table_properties": {"data_size": 1876749, "index_size": 23346, "filter_size": 0, "raw_key_size": 663120, "raw_average_key_size": 24, "raw_value_size": 2763000, "raw_average_value_size": 100, "num_data_blocks": 838, "num_entries": 27630, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:30:09.398351 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 3] Level-0 flush table #10: 1890434 bytes OK +2018/05/25-14:30:25.491635 7f82ba72e700 [db/flush_job.cc:331] [default] [JOB 10] Level-0 flush table #23: started +2018/05/25-14:30:25.643618 7f82b2f20700 [db/db_impl_write.cc:1373] [default] New memtable created with log file: #24. Immutable memtables: 1. +2018/05/25-14:30:25.643633 7f82b2f20700 [WARN] [db/column_family.cc:743] [default] Stopping writes because we have 2 immutable memtables (waiting for flush), max_write_buffer_number is set to 2 +2018/05/25-14:30:27.288181 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283827288158, "cf_name": "default", "job": 10, "event": "table_file_creation", "file_number": 23, "file_size": 1893200, "table_properties": {"data_size": 1879460, "index_size": 23340, "filter_size": 0, "raw_key_size": 663360, "raw_average_key_size": 24, "raw_value_size": 2764000, "raw_average_value_size": 100, "num_data_blocks": 838, "num_entries": 27640, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:30:27.288210 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 10] Level-0 flush table #23: 1893200 bytes OK +2018/05/25-14:30:27.289353 7f82ba72e700 [WARN] [db/column_family.cc:764] [default] Stalling writes because of estimated pending compaction bytes 14410584 +2018/05/25-14:30:27.289390 7f82ba72e700 (Original Log Time 2018/05/25-14:30:27.288829) [db/memtable_list.cc:377] [default] Level-0 commit table #23 started +2018/05/25-14:30:27.289393 7f82ba72e700 (Original Log Time 2018/05/25-14:30:27.289332) [db/memtable_list.cc:409] [default] Level-0 commit table #23: memtable #1 done +2018/05/25-14:34:21.047206 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527284061047181, "cf_name": "default", "job": 44, "event": "table_file_creation", "file_number": 84, "file_size": 1890780, "table_properties": {"data_size": 1877100, "index_size": 23309, "filter_size": 0, "raw_key_size": 662808, "raw_average_key_size": 24, "raw_value_size": 2761700, "raw_average_value_size": 100, "num_data_blocks": 837, "num_entries": 27617, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:34:21.047233 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 44] Level-0 flush table #84: 1890780 bytes OK +2018/05/25-14:34:21.048017 7f82ba72e700 (Original Log Time 2018/05/25-14:34:21.048005) EVENT_LOG_v1 {"time_micros": 1527284061047997, "job": 44, "event": "flush_finished", "output_compression": "Snappy", "lsm_state": [2, 1, 0, 0, 0, 0, 0], "immutable_memtables": 1} +2018/05/25-14:34:21.048592 7f82bd676200 [DEBUG] [db/db_impl_files.cc:261] [JOB 45] Delete /tmp/rocksdbtest-155919/dbbench/000084.sst type=2 #84 -- OK +2018/05/25-14:34:21.048603 7f82bd676200 EVENT_LOG_v1 {"time_micros": 1527284061048600, "job": 45, "event": "table_file_deletion", "file_number": 84} +2018/05/25-14:34:21.048981 7f82bd676200 [db/db_impl.cc:398] Shutdown complete diff --git a/tools/advisor/test/input_files/LOG-1 b/tools/advisor/test/input_files/LOG-1 new file mode 100644 index 000000000..b163f9a99 --- /dev/null +++ b/tools/advisor/test/input_files/LOG-1 @@ -0,0 +1,25 @@ +2018/05/25-14:30:05.601692 7f82bd676200 RocksDB version: 5.14.0 +2018/05/25-14:30:07.626719 7f82ba72e700 (Original Log Time 2018/05/25-14:30:07.621966) [db/db_impl_compaction_flush.cc:1424] Calling FlushMemTableToOutputFile with column family [default], flush slots available 1, compaction slots available 1, flush slots scheduled 1, compaction slots scheduled 0 +2018/05/25-14:30:07.626725 7f82ba72e700 [db/flush_job.cc:301] [default] [JOB 3] Flushing memtable with next log file: 8 +2018/05/25-14:30:07.626738 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283807626732, "job": 3, "event": "flush_started", "num_memtables": 1, "num_entries": 28018, "num_deletes": 0, "memory_usage": 4065512, "flush_reason": "Write Buffer Full"} +2018/05/25-14:30:07.626740 7f82ba72e700 [db/flush_job.cc:331] [default] [JOB 3] Level-0 flush table #10: started +2018/05/25-14:30:07.764232 7f82b2f20700 [db/db_impl_write.cc:1373] [default] New memtable created with log file: #11. Immutable memtables: 1. +2018/05/25-14:30:07.764240 7f82b2f20700 [WARN] [db/column_family.cc:743] [default] Stopping writes because we have 2 immutable memtables (waiting for flush), max_write_buffer_number is set to 2 +2018/05/23-11:53:12.800143 7f9f36b40700 [WARN] [db/column_family.cc:799] [default] Stalling writes because we have 4 level-0 files rate 39886 +2018/05/23-11:53:12.800143 7f9f36b40700 [WARN] [db/column_family.cc:799] [default] Stopping writes because we have 4 level-0 files rate 39886 +2018/05/25-14:30:09.398302 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283809398276, "cf_name": "default", "job": 3, "event": "table_file_creation", "file_number": 10, "file_size": 1890434, "table_properties": {"data_size": 1876749, "index_size": 23346, "filter_size": 0, "raw_key_size": 663120, "raw_average_key_size": 24, "raw_value_size": 2763000, "raw_average_value_size": 100, "num_data_blocks": 838, "num_entries": 27630, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:30:09.398351 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 3] Level-0 flush table #10: 1890434 bytes OK +2018/05/25-14:30:25.491635 7f82ba72e700 [db/flush_job.cc:331] [default] [JOB 10] Level-0 flush table #23: started +2018/05/25-14:30:25.643618 7f82b2f20700 [db/db_impl_write.cc:1373] [default] New memtable created with log file: #24. Immutable memtables: 1. +2018/05/25-14:30:25.643633 7f82b2f20700 [WARN] [db/column_family.cc:743] [default] Stopping writes because we have 2 immutable memtables (waiting for flush), max_write_buffer_number is set to 2 +2018/05/25-14:30:27.288181 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527283827288158, "cf_name": "default", "job": 10, "event": "table_file_creation", "file_number": 23, "file_size": 1893200, "table_properties": {"data_size": 1879460, "index_size": 23340, "filter_size": 0, "raw_key_size": 663360, "raw_average_key_size": 24, "raw_value_size": 2764000, "raw_average_value_size": 100, "num_data_blocks": 838, "num_entries": 27640, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:30:27.288210 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 10] Level-0 flush table #23: 1893200 bytes OK +2018/05/25-14:30:27.289353 7f82ba72e700 [WARN] [db/column_family.cc:764] [default] Stopping writes because of estimated pending compaction bytes 14410584 +2018/05/25-14:30:27.289390 7f82ba72e700 (Original Log Time 2018/05/25-14:30:27.288829) [db/memtable_list.cc:377] [default] Level-0 commit table #23 started +2018/05/25-14:30:27.289393 7f82ba72e700 (Original Log Time 2018/05/25-14:30:27.289332) [db/memtable_list.cc:409] [default] Level-0 commit table #23: memtable #1 done +2018/05/25-14:34:21.047206 7f82ba72e700 EVENT_LOG_v1 {"time_micros": 1527284061047181, "cf_name": "default", "job": 44, "event": "table_file_creation", "file_number": 84, "file_size": 1890780, "table_properties": {"data_size": 1877100, "index_size": 23309, "filter_size": 0, "raw_key_size": 662808, "raw_average_key_size": 24, "raw_value_size": 2761700, "raw_average_value_size": 100, "num_data_blocks": 837, "num_entries": 27617, "filter_policy_name": "", "kDeletedKeys": "0", "kMergeOperands": "0"}} +2018/05/25-14:34:21.047233 7f82ba72e700 [db/flush_job.cc:371] [default] [JOB 44] Level-0 flush table #84: 1890780 bytes OK +2018/05/25-14:34:21.048017 7f82ba72e700 (Original Log Time 2018/05/25-14:34:21.048005) EVENT_LOG_v1 {"time_micros": 1527284061047997, "job": 44, "event": "flush_finished", "output_compression": "Snappy", "lsm_state": [2, 1, 0, 0, 0, 0, 0], "immutable_memtables": 1} +2018/05/25-14:34:21.048592 7f82bd676200 [DEBUG] [db/db_impl_files.cc:261] [JOB 45] Delete /tmp/rocksdbtest-155919/dbbench/000084.sst type=2 #84 -- OK +2018/05/25-14:34:21.048603 7f82bd676200 EVENT_LOG_v1 {"time_micros": 1527284061048600, "job": 45, "event": "table_file_deletion", "file_number": 84} +2018/05/25-14:34:21.048981 7f82bd676200 [db/db_impl.cc:398] Shutdown complete diff --git a/tools/advisor/test/input_files/OPTIONS-000005 b/tools/advisor/test/input_files/OPTIONS-000005 new file mode 100644 index 000000000..0331e6355 --- /dev/null +++ b/tools/advisor/test/input_files/OPTIONS-000005 @@ -0,0 +1,31 @@ +# This is a RocksDB option file. +# +# For detailed file format spec, please refer to the example file +# in examples/rocksdb_option_file_example.ini +# + +[Version] + rocksdb_version=5.14.0 + options_file_version=1.1 + +[DBOptions] + manual_wal_flush=false + allow_ingest_behind=false + db_write_buffer_size=0 + + +[CFOptions "default"] + ttl=0 + max_bytes_for_level_base=268435456 + max_bytes_for_level_multiplier=10.000000 + level0_file_num_compaction_trigger=4 + level0_stop_writes_trigger=36 + write_buffer_size=4194000 + min_write_buffer_number_to_merge=1 + num_levels=7 + compaction_filter_factory=nullptr + compaction_style=kCompactionStyleLevel + +[TableOptions/BlockBasedTable "default"] + block_align=false + index_type=kBinarySearch diff --git a/tools/advisor/test/input_files/rules_err1.ini b/tools/advisor/test/input_files/rules_err1.ini new file mode 100644 index 000000000..bbc7cfa5d --- /dev/null +++ b/tools/advisor/test/input_files/rules_err1.ini @@ -0,0 +1,58 @@ +[Rule "missing-suggestions"] +suggestions= +conditions=missing-source + +[Condition "normal-rule"] +source=LOG +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Suggestion "inc-bg-flush"] +option=DBOptions.max_background_flushes +action=increase + +[Suggestion "inc-write-buffer"] +option=CFOptions.max_write_buffer_number +action=increase + +[Rule "missing-conditions"] +conditions= +suggestions=missing-description + +[Condition "missing-options"] +source=OPTIONS +options= +evaluate=int(options[0])*int(options[1])-int(options[2])<(-251659456) # should evaluate to a boolean + +[Rule "missing-expression"] +conditions=missing-expression +suggestions=missing-description + +[Condition "missing-expression"] +source=OPTIONS +options=CFOptions.level0_file_num_compaction_trigger:CFOptions.write_buffer_size:CFOptions.max_bytes_for_level_base +evaluate= + +[Suggestion "missing-description"] +description= + +[Rule "stop-too-many-L0"] +suggestions=inc-max-bg-compactions:missing-action:inc-l0-stop-writes-trigger +conditions=missing-regex + +[Condition "missing-regex"] +source=LOG +regex= +scope=column_family + +[Suggestion "missing-option"] +option= +action=increase + +[Suggestion "normal-suggestion"] +option=CFOptions.write_buffer_size +action=increase + +[Suggestion "inc-l0-stop-writes-trigger"] +option=CFOptions.level0_stop_writes_trigger +action=increase diff --git a/tools/advisor/test/input_files/rules_err2.ini b/tools/advisor/test/input_files/rules_err2.ini new file mode 100644 index 000000000..e8bb4d007 --- /dev/null +++ b/tools/advisor/test/input_files/rules_err2.ini @@ -0,0 +1,16 @@ +[Rule "normal-rule"] +suggestions=inc-bg-flush:inc-write-buffer +conditions=missing-source + +[Condition "missing-source"] +source= +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Suggestion "inc-bg-flush"] +option=DBOptions.max_background_flushes +action=increase + +[Suggestion "inc-write-buffer"] +option=CFOptions.max_write_buffer_number +action=increase diff --git a/tools/advisor/test/input_files/rules_err3.ini b/tools/advisor/test/input_files/rules_err3.ini new file mode 100644 index 000000000..a848267ab --- /dev/null +++ b/tools/advisor/test/input_files/rules_err3.ini @@ -0,0 +1,16 @@ +[Rule "normal-rule"] +suggestions=missing-action:inc-write-buffer +conditions=missing-source + +[Condition "normal-condition"] +source=LOG +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Suggestion "missing-action"] +option=DBOptions.max_background_flushes +action= + +[Suggestion "inc-write-buffer"] +option=CFOptions.max_write_buffer_number +action=increase diff --git a/tools/advisor/test/input_files/rules_err4.ini b/tools/advisor/test/input_files/rules_err4.ini new file mode 100644 index 000000000..e969a6bd1 --- /dev/null +++ b/tools/advisor/test/input_files/rules_err4.ini @@ -0,0 +1,16 @@ +[Rule "normal-rule"] +suggestions=inc-bg-flush +conditions=missing-source + +[Condition "normal-condition"] +source=LOG +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Suggestion "inc-bg-flush"] +option=DBOptions.max_background_flushes +action=increase + +[Suggestion] # missing section name +option=CFOptions.max_write_buffer_number +action=increase diff --git a/tools/advisor/test/input_files/test_rules.ini b/tools/advisor/test/input_files/test_rules.ini new file mode 100644 index 000000000..07557ab1c --- /dev/null +++ b/tools/advisor/test/input_files/test_rules.ini @@ -0,0 +1,51 @@ +[Rule "single-condition-false"] +suggestions=inc-bg-flush:inc-write-buffer +conditions=log-4-false + +[Rule "multiple-conds-true"] +suggestions=inc-write-buffer +conditions=log-1-true:log-2-true:log-3-true + +[Rule "multiple-conds-one-false"] +suggestions=inc-bg-flush +conditions=log-1-true:log-4-false:log-3-true + +[Rule "multiple-conds-all-false"] +suggestions=l0-l1-ratio-health-check +conditions=log-4-false:options-1-false + +[Condition "log-1-true"] +source=LOG +regex=Stopping writes because we have \d+ immutable memtables \(waiting for flush\), max_write_buffer_number is set to \d+ +scope=column_family + +[Condition "log-2-true"] +source=LOG +regex=Stalling writes because we have \d+ level-0 files +scope=column_family + +[Condition "log-3-true"] +source=LOG +regex=Stopping writes because we have \d+ level-0 files +scope=column_family + +[Condition "log-4-false"] +source=LOG +regex=Stalling writes because of estimated pending compaction bytes \d+ +scope=column_family + +[Condition "options-1-false"] +source=OPTIONS +options=CFOptions.level0_file_num_compaction_trigger:CFOptions.write_buffer_size:random_access_max_buffer_size +evaluate=int(options[0])*int(options[1])-int(options[2])<0 # should evaluate to a boolean + +[Suggestion "inc-bg-flush"] +option=DBOptions.max_background_flushes +action=increase + +[Suggestion "inc-write-buffer"] +option=CFOptions.max_write_buffer_number +action=increase + +[Suggestion "l0-l1-ratio-health-check"] +description='modify options such that (level0_file_num_compaction_trigger * write_buffer_size - max_bytes_for_level_base < 5) is satisfied' diff --git a/tools/advisor/test/test_rule_parser.py b/tools/advisor/test/test_rule_parser.py new file mode 100644 index 000000000..3a05cafeb --- /dev/null +++ b/tools/advisor/test/test_rule_parser.py @@ -0,0 +1,213 @@ +# Copyright (c) 2011-present, Facebook, Inc. All rights reserved. +# This source code is licensed under both the GPLv2 (found in the +# COPYING file in the root directory) and Apache 2.0 License +# (found in the LICENSE.Apache file in the root directory). + +import os +import unittest +from advisor.rule_parser import RulesSpec, DatabaseLogs, DatabaseOptions +from advisor.rule_parser import get_triggered_rules, trigger_conditions + +RuleToSuggestions = { + "stall-too-many-memtables": [ + 'inc-bg-flush', + 'inc-write-buffer' + ], + "stall-too-many-L0": [ + 'inc-max-subcompactions', + 'inc-max-bg-compactions', + 'inc-write-buffer-size', + 'dec-max-bytes-for-level-base', + 'inc-l0-slowdown-writes-trigger' + ], + "stop-too-many-L0": [ + 'inc-max-bg-compactions', + 'inc-write-buffer-size', + 'inc-l0-stop-writes-trigger' + ], + "stall-too-many-compaction-bytes": [ + 'inc-max-bg-compactions', + 'inc-write-buffer-size', + 'inc-hard-pending-compaction-bytes-limit', + 'inc-soft-pending-compaction-bytes-limit' + ], + "level0-level1-ratio": [ + 'l0-l1-ratio-health-check' + ] +} + + +class TestAllRulesTriggered(unittest.TestCase): + def setUp(self): + # load the Rules + this_path = os.path.abspath(os.path.dirname(__file__)) + ini_path = os.path.join(this_path, '../advisor/rules.ini') + self.db_rules = RulesSpec(ini_path) + self.db_rules.load_rules_from_spec() + self.db_rules.perform_section_checks() + # load the data sources: LOG and OPTIONS + log_path = os.path.join(this_path, 'input_files/LOG-0') + options_path = os.path.join(this_path, 'input_files/OPTIONS-000005') + self.data_sources = [] + self.data_sources.append(DatabaseOptions(options_path)) + self.data_sources.append(DatabaseLogs(log_path)) + + def test_triggered_conditions(self): + conditions_dict = self.db_rules.get_conditions_dict() + rules_dict = self.db_rules.get_rules_dict() + # Make sure none of the conditions is triggered beforehand + for cond in conditions_dict.values(): + self.assertFalse(cond.is_triggered(), repr(cond)) + for rule in rules_dict.values(): + self.assertFalse(rule.is_triggered(conditions_dict), repr(rule)) + + # Trigger the conditions as per the data sources. + trigger_conditions(self.data_sources, conditions_dict) + + # Make sure each condition and rule is triggered + for cond in conditions_dict.values(): + self.assertTrue(cond.is_triggered(), repr(cond)) + + # Get the set of rules that have been triggered + triggered_rules = get_triggered_rules(rules_dict, conditions_dict) + + for rule in rules_dict.values(): + self.assertIn(rule, triggered_rules) + # Check the suggestions made by the triggered rules + for sugg in rule.get_suggestions(): + self.assertIn(sugg, RuleToSuggestions[rule.name]) + + for rule in triggered_rules: + self.assertIn(rule, rules_dict.values()) + for sugg in RuleToSuggestions[rule.name]: + self.assertIn(sugg, rule.get_suggestions()) + + +class TestConditionsConjunctions(unittest.TestCase): + def setUp(self): + # load the Rules + this_path = os.path.abspath(os.path.dirname(__file__)) + ini_path = os.path.join(this_path, 'input_files/test_rules.ini') + self.db_rules = RulesSpec(ini_path) + self.db_rules.load_rules_from_spec() + self.db_rules.perform_section_checks() + # load the data sources: LOG and OPTIONS + log_path = os.path.join(this_path, 'input_files/LOG-1') + options_path = os.path.join(this_path, 'input_files/OPTIONS-000005') + self.data_sources = [] + self.data_sources.append(DatabaseOptions(options_path)) + self.data_sources.append(DatabaseLogs(log_path)) + + def test_condition_conjunctions(self): + conditions_dict = self.db_rules.get_conditions_dict() + rules_dict = self.db_rules.get_rules_dict() + # Make sure none of the conditions is triggered beforehand + for cond in conditions_dict.values(): + self.assertFalse(cond.is_triggered(), repr(cond)) + for rule in rules_dict.values(): + self.assertFalse(rule.is_triggered(conditions_dict), repr(rule)) + + # Trigger the conditions as per the data sources. + trigger_conditions(self.data_sources, conditions_dict) + + # Check for the conditions + conds_triggered = ['log-1-true', 'log-2-true', 'log-3-true'] + conds_not_triggered = ['log-4-false', 'options-1-false'] + for cond in conds_triggered: + self.assertTrue(conditions_dict[cond].is_triggered(), repr(cond)) + for cond in conds_not_triggered: + self.assertFalse(conditions_dict[cond].is_triggered(), repr(cond)) + + # Check for the rules + rules_triggered = ['multiple-conds-true'] + rules_not_triggered = [ + 'single-condition-false', + 'multiple-conds-one-false', + 'multiple-conds-all-false' + ] + for rule in rules_triggered: + self.assertTrue( + rules_dict[rule].is_triggered(conditions_dict), + repr(rule) + ) + for rule in rules_not_triggered: + self.assertFalse( + rules_dict[rule].is_triggered(conditions_dict), + repr(rule) + ) + + +class TestSanityChecker(unittest.TestCase): + def setUp(self): + this_path = os.path.abspath(os.path.dirname(__file__)) + ini_path = os.path.join(this_path, 'input_files/rules_err1.ini') + db_rules = RulesSpec(ini_path) + db_rules.load_rules_from_spec() + self.rules_dict = db_rules.get_rules_dict() + self.conditions_dict = db_rules.get_conditions_dict() + self.suggestions_dict = db_rules.get_suggestions_dict() + + def test_rule_missing_suggestions(self): + regex = '.*rule must have at least one suggestion.*' + with self.assertRaisesRegex(ValueError, regex): + self.rules_dict['missing-suggestions'].perform_checks() + + def test_rule_missing_conditions(self): + regex = '.*rule must have at least one condition.*' + with self.assertRaisesRegex(ValueError, regex): + self.rules_dict['missing-conditions'].perform_checks() + + def test_condition_missing_regex(self): + regex = '.*provide regex for log condition.*' + with self.assertRaisesRegex(ValueError, regex): + self.conditions_dict['missing-regex'].perform_checks() + + def test_condition_missing_options(self): + regex = '.*options missing in condition.*' + with self.assertRaisesRegex(ValueError, regex): + self.conditions_dict['missing-options'].perform_checks() + + def test_condition_missing_expression(self): + regex = '.*expression missing in condition.*' + with self.assertRaisesRegex(ValueError, regex): + self.conditions_dict['missing-expression'].perform_checks() + + def test_suggestion_missing_option(self): + regex = '.*provide option or description.*' + with self.assertRaisesRegex(ValueError, regex): + self.suggestions_dict['missing-option'].perform_checks() + + def test_suggestion_missing_description(self): + regex = '.*provide option or description.*' + with self.assertRaisesRegex(ValueError, regex): + self.suggestions_dict['missing-description'].perform_checks() + + +class TestParsingErrors(unittest.TestCase): + def setUp(self): + self.this_path = os.path.abspath(os.path.dirname(__file__)) + + def test_condition_missing_source(self): + ini_path = os.path.join(self.this_path, 'input_files/rules_err2.ini') + db_rules = RulesSpec(ini_path) + regex = '.*provide source for condition.*' + with self.assertRaisesRegex(ValueError, regex): + db_rules.load_rules_from_spec() + + def test_suggestion_missing_action(self): + ini_path = os.path.join(self.this_path, 'input_files/rules_err3.ini') + db_rules = RulesSpec(ini_path) + regex = '.*provide action for option.*' + with self.assertRaisesRegex(ValueError, regex): + db_rules.load_rules_from_spec() + + def test_section_no_name(self): + ini_path = os.path.join(self.this_path, 'input_files/rules_err4.ini') + db_rules = RulesSpec(ini_path) + regex = 'Parsing error: section header be like:.*' + with self.assertRaisesRegex(ValueError, regex): + db_rules.load_rules_from_spec() + + +if __name__ == '__main__': + unittest.main()