diff --git a/e2e-tests/utils/block_size_benchmarks/README.md b/e2e-tests/utils/block_size_benchmarks/README.md new file mode 100644 index 0000000000..b6d5498158 --- /dev/null +++ b/e2e-tests/utils/block_size_benchmarks/README.md @@ -0,0 +1,12 @@ +# Block Size Benchmarking Scripts + +Script calculates block propagation time as a timestamp difference between “Pre-sealed block for proposal” and “Imported #XXX” lines from partner-chains node logs. + +## How to use + +1. Install `python3`, `pip` +2. Install pandas - `pip install pandas` +3. Gather logs from nodes. Put logs from each node in the dedicated txt file: alice.txt, bob.txt, etc +4. Transform raw Grafana logs to a logs for a particular node: `python3 transformer.py` +5. Extract data from logs: `python3 extractor.py` +6. Generate statistics by node `python3 analyzer.py block_propagation_report.txt analysis.txt` \ No newline at end of file diff --git a/e2e-tests/utils/block_size_benchmarks/analyzer.py b/e2e-tests/utils/block_size_benchmarks/analyzer.py new file mode 100644 index 0000000000..a05992d5b3 --- /dev/null +++ b/e2e-tests/utils/block_size_benchmarks/analyzer.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 + +import sys +import re +import statistics +from typing import Dict, List, Optional, Tuple + + +class Block: + def __init__(self, number: int, hash_str: Optional[str] = None): + self.number = number + self.hash = hash_str + self.creator: Optional[str] = None + self.imports: Dict[str, float] = {} + + def add_import(self, node: str, delay_ms: float): + self.imports[node] = delay_ms + + def has_all_nodes(self, required_nodes: List[str]) -> bool: + return all(node in self.imports for node in required_nodes) + + def is_complete(self, required_nodes: List[str]) -> bool: + return (self.creator and self.creator != 'unknown' + and self.has_all_nodes(required_nodes)) + + +class BlockPropagationAnalyzer: + def __init__(self, nodes: List[str]): + if not nodes: + raise ValueError("At least one node must be specified") + self.all_nodes = [node.lower() for node in nodes] + self.blocks: List[Block] = [] + + def parse_file(self, filename: str) -> None: + try: + with open(filename, 'r', encoding='utf-8') as file: + content = file.read() + except FileNotFoundError: + print(f"Error: File '{filename}' not found.") + sys.exit(1) + except Exception as e: + print(f"Error reading file '{filename}': {e}") + sys.exit(1) + self._parse_content(content) + + def _parse_content(self, content: str) -> None: + lines = content.split('\n') + current_block = None + for line in lines: + line = line.strip() + if line.startswith('Block #'): + current_block = self._parse_block_header(line) + if current_block: + self.blocks.append(current_block) + elif line.startswith('Created by:') and current_block: + current_block.creator = self._parse_creator(line) + elif line.startswith('Imported by') and current_block: + node, delay = self._parse_import(line) + if node: + current_block.add_import(node, delay) + elif 'Creator unknown' in line and current_block: + current_block.creator = 'unknown' + + def _parse_block_header(self, line: str) -> Optional[Block]: + block_match = re.search(r'Block #(\d+)', line) + hash_match = re.search(r'0x[a-f0-9]{4}…[a-f0-9]{4}', line) + if block_match: + number = int(block_match.group(1)) + hash_str = hash_match.group(0) if hash_match else None + return Block(number, hash_str) + return None + + def _parse_creator(self, line: str) -> Optional[str]: + creator_match = re.search(r'Created by: (\w+)', line) + return creator_match.group(1).lower() if creator_match else None + + def _parse_import(self, line: str) -> Tuple[Optional[str], float]: + import_match = re.search( + r'Imported by (\w+)' + r'(?:\s+\(creator node\))?' + r'(?:\s+after ([\d.]+) ms)?', + line + ) + if import_match: + node = import_match.group(1).lower() + delay_str = import_match.group(2) + delay = float(delay_str) if delay_str else 0.0 + return node, delay + return None, 0.0 + + def get_complete_blocks(self) -> List[Block]: + return [block for block in self.blocks + if block.is_complete(self.all_nodes)] + + def _format_table_row(self, values: List[str], widths: List[int]) -> str: + formatted_values = [] + for i, (value, width) in enumerate(zip(values, widths)): + if i == 0: + formatted_values.append(f"{value:<{width}}") + else: + formatted_values.append(f"{value:^{width}}") + return "| " + " | ".join(formatted_values) + " |" + + def generate_summary_statistics(self, complete_blocks: List[Block]) -> str: + lines = [] + lines.append("=== SUMMARY STATISTICS BY NODE ===") + lines.append("") + + stats = {} + for node in self.all_nodes: + blocks_created = len([block for block in complete_blocks if block.creator == node]) + + import_times = [ + float(block.imports[node]) + for block in complete_blocks + if block.creator != node + ] + + avg_import = statistics.mean(import_times) if import_times else 0 + + stats[node] = { + 'blocks_created': blocks_created, + 'blocks_imported': len(import_times), + 'min_import': min(import_times) if import_times else 0, + 'max_import': max(import_times) if import_times else 0, + 'avg_import': avg_import + } + + header = "| Node | Blocks Created | Blocks Imported | Min Import Time | Max Import Time | Avg Import Time |" + separator = "|---------|----------------|-----------------|-----------------|-----------------|-----------------|" + lines.append(header) + lines.append(separator) + + for node in self.all_nodes: + s = stats[node] + row = (f"| {node.capitalize():<7} | {s['blocks_created']:<14} | " + f"{s['blocks_imported']:<15} | {s['min_import']:<15.0f} | " + f"{s['max_import']:<15.0f} | {s['avg_import']:<15.1f} |") + lines.append(row) + + return '\n'.join(lines) + + def run(self, input_filename: str, output_filename: str) -> None: + """Main analysis function""" + print(f"Analyzing nodes: {', '.join(self.all_nodes)}") + print(f"Parsing file: {input_filename}") + self.parse_file(input_filename) + print(f"Total blocks parsed: {len(self.blocks)}") + complete_blocks = self.get_complete_blocks() + print(f"Complete blocks: {len(complete_blocks)}") + if not complete_blocks: + print("No complete blocks found. Exiting.") + sys.exit(1) + stats_table = self.generate_summary_statistics(complete_blocks) + try: + with open(output_filename, 'w', encoding='utf-8') as file: + file.write("# Block Propagation Analysis\n\n") + nodes = ', '.join(node.capitalize() for node in self.all_nodes) + file.write(f"**Nodes analyzed:** {nodes}") + file.write("\n\n") + file.write(stats_table) + file.write("\n\n") + print(f"Analysis complete. Results saved to: {output_filename}") + except Exception as e: + print(f"Error writing output file '{output_filename}': {e}") + sys.exit(1) + + +def main(): + nodes = [ + "alice", + "bob", + "charlie", + "dave", + "eve", + "ferdie", + "george", + "henry", + "iris", + "jack" + ] + + if len(sys.argv) < 3: + print( + "Usage: python analyzer.py " + "[node1 node2 node3 ...]" + ) + print( + "Example: python analyzer.py data.txt results.txt " + "alice bob charlie" + ) + print( + "If no nodes specified, default nodes will be used: " + f"{', '.join(nodes)}" + ) + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] + + if len(sys.argv) > 3: + nodes = sys.argv[3:] + + try: + analyzer = BlockPropagationAnalyzer(nodes) + analyzer.run(input_file, output_file) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/utils/block_size_benchmarks/extractor.py b/e2e-tests/utils/block_size_benchmarks/extractor.py new file mode 100644 index 0000000000..d43d2a1741 --- /dev/null +++ b/e2e-tests/utils/block_size_benchmarks/extractor.py @@ -0,0 +1,222 @@ +import re +import sys +from datetime import datetime + + +def parse_logs(nodes): + blocks = {} + pre_sealed_blocks = {} + + for node_name in nodes: + log_file = f"{node_name}.txt" + node_name = log_file.split(".")[0] + + with open(log_file, "r") as f: + for line in f: + timestamp_match = re.search( + r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})?)", line + ) + if not timestamp_match: + continue + + timestamp = parse_timestamp(timestamp_match) + + extract_pre_sealed_data( + pre_sealed_blocks, node_name, line, timestamp + ) + + if "🏆 Imported #" in line: + import_match = re.search( + r"🏆 Imported #(\d+) \((.*) → (.*)\)", line + ) + + if import_match: + block_num = int(import_match.group(1)) + block_hash = import_match.group(3) + + block_key = (block_num, block_hash) + + if block_key not in blocks: + blocks[block_key] = { + "number": block_num, + "hash": block_hash, + "creator": None, + "creation_time": None, + "import_times": {}, + } + + blocks[block_key]["import_times"][ + node_name + ] = timestamp + + parse_pre_sealed_blocks(blocks, pre_sealed_blocks) + + return blocks + + +def parse_pre_sealed_blocks(blocks, pre_sealed_blocks): + for node_name, node_blocks in pre_sealed_blocks.items(): + for block_num, pre_sealed_info in node_blocks.items(): + pre_sealed_hash = pre_sealed_info["hash"] + + for block_info in blocks.values(): + if block_info["number"] == block_num: + imported_hash = block_info["hash"] + + if pre_sealed_hash[-4:] == imported_hash[-4:]: + block_info["creator"] = node_name + block_info["creation_time"] = pre_sealed_info["time"] + block_info["full_hash"] = pre_sealed_hash + + +def extract_pre_sealed_data(pre_sealed_blocks, node_name, line, timestamp): + if "🔖 Pre-sealed block for proposal at" in line: + block_num_match = re.search(r"at (\d+)", line) + hash_match = re.search(r"Hash now (0x[0-9a-f]+)", line) + + if block_num_match and hash_match: + block_num = int(block_num_match.group(1)) + block_hash = hash_match.group(1) + + if node_name not in pre_sealed_blocks: + pre_sealed_blocks[node_name] = {} + + pre_sealed_blocks[node_name][block_num] = { + "hash": block_hash, + "time": timestamp, + } + + +def parse_timestamp(timestamp_match): + timestamp_str = timestamp_match.group(1) + format = ( + "%Y-%m-%d %H:%M:%S.%f" + if "." in timestamp_str + else "%Y-%m-%d %H:%M:%S" + ) + return datetime.strptime(timestamp_str, format) + + +def calculate_propagation_times(blocks): + results = [] + + for block_info in blocks.values(): + result = { + "block_num": block_info["number"], + "block_hash": block_info["hash"], + "creator": block_info["creator"], + "import_times": block_info["import_times"].copy(), + } + + if "full_hash" in block_info: + result["full_hash"] = block_info["full_hash"] + + if block_info["creator"] and "creation_time" in block_info: + result["creation_time"] = block_info["creation_time"] + result["propagation_times"] = {} + + for node, import_time in block_info["import_times"].items(): + prop_time_delta = import_time - block_info["creation_time"] + prop_time = ( + prop_time_delta.total_seconds() * 1000 + ) + result["propagation_times"][node] = prop_time + + results.append(result) + + results.sort(key=lambda x: x["block_num"]) + + return results + + +def generate_report(results): + report_lines = [] + + for result in results: + block_num = result["block_num"] + block_hash = result["block_hash"] + + if "full_hash" in result: + report_lines.append( + ( + f"Block #{block_num} (Full Hash: {result['full_hash']}, " + f"Displayed as: {block_hash})" + ) + ) + else: + report_lines.append(f"Block #{block_num} (Hash: {block_hash})") + + if result["creator"]: + report_lines.append( + ( + ( + f" Created by: {result['creator']} at " + f"{result['creation_time']}" + ) + ) + ) + + for node, import_time in sorted(result["import_times"].items()): + if node == result["creator"]: + report_lines.append( + f" Imported by {node} (creator node) at {import_time}" + ) + else: + prop_time = result["propagation_times"][node] + report_lines.append( + ( + f" Imported by {node} after {prop_time:.3f} ms " + f"at {import_time}" + ) + ) + else: + report_lines.append(" Creator unknown") + for node, import_time in sorted(result["import_times"].items()): + report_lines.append(f" Imported by {node} at {import_time}") + + report_lines.append("") + + return "\n".join(report_lines) + + +def main(): + nodes = [ + "alice", + "bob", + "charlie", + "dave", + "eve", + "ferdie", + "george", + "henry", + "iris", + "jack" + ] + + if len(sys.argv) < 1: + print("Usage: python extractor.py [node1 node2 node3 ...]") + print("Example: python extractor.py alice bob charlie") + print("If no nodes specified, default nodes will be used:") + print(", ".join(nodes)) + sys.exit(1) + + if len(sys.argv) > 1: + nodes = sys.argv[1:] + print(f"Parsing the following nodes: {', '.join(nodes)}\n") + + blocks = parse_logs(nodes) + + print("Calculating propagation times...\n") + results = calculate_propagation_times(blocks) + + print("Generating report...\n") + report = generate_report(results) + + with open("block_propagation_report.txt", "w") as f: + f.write(report) + + print("Report saved to block_propagation_report.txt") + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/utils/block_size_benchmarks/transformer.py b/e2e-tests/utils/block_size_benchmarks/transformer.py new file mode 100644 index 0000000000..b20af79761 --- /dev/null +++ b/e2e-tests/utils/block_size_benchmarks/transformer.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import os +import re +import glob +import json + + +def extract_host_from_file(filepath): + try: + with open(filepath, 'r', encoding='utf-8') as file: + for _, line in enumerate(file, 1): + if 'Common labels:' in line and '"host":' in line: + match = re.search(r'Common labels:\s*({.*?})', line) + if match: + try: + json_str = match.group(1) + labels = json.loads(json_str) + host = labels.get('host') + if host: + return host + except json.JSONDecodeError: + host_match = re.search( + r'"host"\s*:\s*"([^"]+)"', line + ) + if host_match: + host = host_match.group(1) + return host + except Exception as e: + print(f"Error reading file {filepath}: {e}") + return None + + +def rename_log_files(): + txt_files = glob.glob("*.txt") + + if not txt_files: + print("No .txt files found in current directory") + return + print(f"Found {len(txt_files)} .txt files to process") + processed_count = 0 + error_files = [] + for txt_file in txt_files: + print(f"\nProcessing: {txt_file}") + + if re.match(r'^[a-zA-Z0-9_-]+\.txt$', txt_file) and not txt_file.startswith('temp_'): + base_name = os.path.splitext(txt_file)[0] + if len(base_name) < 20: + print(f"Skipping {txt_file} - appears to already be renamed") + continue + + host = extract_host_from_file(txt_file) + + if host: + new_filename = f"{host}.txt" + + if os.path.exists(new_filename) and new_filename != txt_file: + print(f"Warning: {new_filename} already exists!") + counter = 1 + while os.path.exists(f"{host}_{counter}.txt"): + counter += 1 + new_filename = f"{host}_{counter}.txt" + print(f"Using alternative name: {new_filename}") + + if new_filename != txt_file: + try: + os.rename(txt_file, new_filename) + print(f"Renamed '{txt_file}' -> '{new_filename}'") + processed_count += 1 + except Exception as e: + print(f"Error renaming {txt_file}: {e}") + error_files.append(txt_file) + else: + print(f"File {txt_file} already has correct name") + else: + print( + f"Can't find host name - please rename {txt_file} manually" + ) + error_files.append(txt_file) + + print("\n=== Summary ===") + print(f"Files processed successfully: {processed_count}") + if error_files: + print(f"Files requiring manual attention: {len(error_files)}") + for error_file in error_files: + print(f" - {error_file}") + + +if __name__ == "__main__": + print("Node Log Transformer") + print("=" * 40) + rename_log_files()