001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.benchmarking.common.report; 017 018import de.cuioss.benchmarking.common.util.JsonSerializationHelper; 019import de.cuioss.tools.logging.CuiLogger; 020 021import java.io.IOException; 022import java.nio.file.Files; 023import java.nio.file.Path; 024import java.time.Instant; 025import java.time.ZoneOffset; 026import java.time.format.DateTimeFormatter; 027import java.util.Comparator; 028import java.util.List; 029import java.util.Map; 030import java.util.stream.Stream; 031 032import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.INFO; 033import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN; 034 035/** 036 * Manages historical benchmark data persistence. 037 * <p> 038 * This manager handles: 039 * <ul> 040 * <li>Saving current benchmark runs to history with timestamp-based naming</li> 041 * <li>Maintaining a retention policy (keeping only the most recent runs)</li> 042 * <li>Organizing historical data in a queryable directory structure</li> 043 * </ul> 044 * <p> 045 * File naming convention: {@code YYYY-MM-DD-THHMMZ-{commitSha}.json} 046 * where the timestamp is in UTC and the commit SHA is truncated to 8 characters. 047 */ 048public class HistoricalDataManager { 049 050 private static final CuiLogger LOGGER = new CuiLogger(HistoricalDataManager.class); 051 private static final int RETENTION_COUNT = 10; 052 private static final String HISTORY_DIR = "history"; 053 private static final String JSON_EXTENSION = ".json"; 054 private static final DateTimeFormatter TIMESTAMP_FORMAT = 055 DateTimeFormatter.ofPattern("yyyy-MM-dd-'T'HHmm'Z'").withZone(ZoneOffset.UTC); 056 057 058 /** 059 * Archives the current benchmark data to the history directory. 060 * 061 * @param currentData the current benchmark data to archive 062 * @param outputDir the base output directory 063 * @param commitSha the Git commit SHA (will be truncated to 8 chars) 064 * @throws IOException if writing the archive file fails 065 */ 066 public void archiveCurrentRun(Map<String, Object> currentData, String outputDir, String commitSha) 067 throws IOException { 068 Path historyDir = Path.of(outputDir, HISTORY_DIR); 069 Files.createDirectories(historyDir); 070 071 String timestamp = TIMESTAMP_FORMAT.format(Instant.now()); 072 String truncatedSha = commitSha != null && commitSha.length() >= 8 073 ? commitSha.substring(0, 8) 074 : "unknown"; 075 String filename = "%s-%s%s".formatted(timestamp, truncatedSha, JSON_EXTENSION); 076 077 Path archiveFile = historyDir.resolve(filename); 078 String jsonContent = JsonSerializationHelper.toJson(currentData); 079 Files.writeString(archiveFile, jsonContent); 080 081 LOGGER.info(INFO.ARCHIVED_BENCHMARK_DATA, archiveFile); 082 } 083 084 /** 085 * Enforces the retention policy by keeping only the most recent files. 086 * 087 * @param historyDir the history directory path 088 * @throws IOException if accessing or deleting files fails 089 */ 090 public void enforceRetentionPolicy(Path historyDir) throws IOException { 091 if (!Files.exists(historyDir)) { 092 return; 093 } 094 095 try (Stream<Path> files = Files.list(historyDir)) { 096 List<Path> jsonFiles = files 097 .filter(Files::isRegularFile) 098 .filter(p -> p.toString().endsWith(JSON_EXTENSION)) 099 .sorted(Comparator.comparing(this::extractTimestamp).reversed()) 100 .toList(); 101 102 if (jsonFiles.size() > RETENTION_COUNT) { 103 List<Path> filesToDelete = jsonFiles.subList(RETENTION_COUNT, jsonFiles.size()); 104 for (Path file : filesToDelete) { 105 Files.delete(file); 106 LOGGER.info(INFO.REMOVED_HISTORY_FILE, file.getFileName()); 107 } 108 } 109 } 110 } 111 112 /** 113 * Retrieves a sorted list of historical data files. 114 * 115 * @param historyDir the history directory path 116 * @return sorted list of historical data files (newest first) 117 * @throws IOException if reading the directory fails 118 */ 119 public List<Path> getHistoricalFiles(Path historyDir) throws IOException { 120 if (!Files.exists(historyDir)) { 121 return List.of(); 122 } 123 124 try (Stream<Path> files = Files.list(historyDir)) { 125 return files 126 .filter(Files::isRegularFile) 127 .filter(p -> p.toString().endsWith(JSON_EXTENSION)) 128 .sorted(Comparator.comparing(this::extractTimestamp).reversed()) 129 .toList(); 130 } 131 } 132 133 /** 134 * Checks if historical data is available. 135 * 136 * @param outputDir the base output directory 137 * @return true if historical data exists, false otherwise 138 */ 139 public boolean hasHistoricalData(String outputDir) { 140 Path historyDir = Path.of(outputDir, HISTORY_DIR); 141 if (!Files.exists(historyDir)) { 142 return false; 143 } 144 145 try (Stream<Path> files = Files.list(historyDir)) { 146 return files 147 .filter(Files::isRegularFile) 148 .anyMatch(p -> p.toString().endsWith(JSON_EXTENSION)); 149 } catch (IOException e) { 150 LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "checking historical data"); 151 return false; 152 } 153 } 154 155 /** 156 * Extracts the timestamp from a historical data filename. 157 * 158 * @param file the file path 159 * @return the timestamp string, or empty string if extraction fails 160 */ 161 private String extractTimestamp(Path file) { 162 String filename = file.getFileName().toString(); 163 int dashIndex = filename.lastIndexOf('-'); 164 if (dashIndex > 0) { 165 return filename.substring(0, dashIndex); 166 } 167 return ""; 168 } 169}