View Javadoc
1   package de.spiritscorp.datasync.io;
2   
3   /*-
4    * 		Data Sync
5    *
6    * 		Copyright ©   2022    The Spirit
7    * 		@email                        thespirit@spiritscorp.network
8    *
9    * 		This program is free software; you can redistribute it and/or modify
10   * 		it under the terms of the GNU General Public License as published by
11   * 		the Free Software Foundation; either version 3 of the License, or
12   * 		(at your option) any later version.
13   *
14   * 		This program is distributed in the hope that it will be useful,
15   * 		but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * 		MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17   * 		See the GNU General Public License for more details.
18   *
19   * 		You should have received a copy of the GNU General Public License
20   * 		along with this program. If not, see <http://www.gnu.org/licenses/>.
21   */
22  
23  import java.io.BufferedWriter;
24  import java.io.IOException;
25  import java.nio.file.Files;
26  import java.nio.file.Path;
27  import java.nio.file.StandardCopyOption;
28  import java.nio.file.StandardOpenOption;
29  import java.time.LocalDateTime;
30  import java.time.ZoneId;
31  import java.time.format.DateTimeFormatter;
32  import java.util.ArrayList;
33  import java.util.List;
34  import java.util.Objects;
35  import java.util.concurrent.locks.ReentrantLock;
36  
37  import de.spiritscorp.datasync.model.FileAttributes;
38  import jakarta.json.Json;
39  import jakarta.json.JsonArray;
40  import jakarta.json.JsonException;
41  import jakarta.json.JsonObject;
42  
43  /**
44   * High-performance background logger using JSON Lines format (NDJSON).
45   * Features an automated, size-based log rotation upon initialization.
46   * Designed with Dependency Injection for optimal testability.
47   */
48  public class Logger {
49  
50  	/** Formatter for generating standardized German timestamp strings. */
51  	private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern( "dd.MM.yyyy  HH:mm:ss" );
52  
53  	/** The absolute destination path of the active log file. */
54  	private final Path baseLogPath;
55  
56  	/** The maximum threshold size in bytes before a file rotation is enforced. */
57  	private final long maxFileSize;
58  
59  	/** The capacity limit of historical backup archives to keep on disk. */
60  	private final int maxBackupIndex;
61  
62  	/** Thread-safe internal memory cache containing unwritten log entries. */
63  	private final List<JsonArray> logCache = new ArrayList<>();
64  
65  	/** Concurrency lock ensuring atomic operations across background threads. */
66  	private final ReentrantLock threadLock = new ReentrantLock();
67  
68  	/**
69  	 * Public default constructor utilizing production configurations.
70  	 * Fetches the default log path and enforces a 10 MB retention limit with 5 backups.
71  	 */
72  	public Logger() {
73  		this(
74  				PreferenceManager.getInstance().getLogPath(),
75  				10_485_760, // 10 MB
76  				5 );
77  	}
78  
79  	/**
80  	 * Initializes the logger and executes an immediate size-based log rotation check.
81  	 *
82  	 * @param baseLogPath    The primary path to the active log file
83  	 * @param maxFileSize    The maximum allowed file size in bytes before rotation triggers
84  	 * @param maxBackupIndex The maximum number of archived log files to retain
85  	 * @throws NullPointerException if baseLogPath is null
86  	 */
87  	/* package */ Logger( final Path baseLogPath, final long maxFileSize, final int maxBackupIndex ) {
88  		this.baseLogPath = Objects.requireNonNull( baseLogPath, "baseLogPath must not be null" );
89  		this.maxFileSize = maxFileSize;
90  		this.maxBackupIndex = maxBackupIndex;
91  
92  		executeLogRotationIfNeeded();
93  	}
94  
95  	/**
96  	 * Sets a new log entry and queues it inside the volatile internal cache.
97  	 *
98  	 * @param filePath       The path where the file is/was located
99  	 * @param changeStatus   The status representing the change event
100 	 * @param fileAttributes The structural attributes of the file
101 	 */
102 	public void setEntry( final String filePath, final String changeStatus, final FileAttributes fileAttributes ) {
103 		threadLock.lock();
104 		try {
105 			final JsonObject jsonObject = Json.createObjectBuilder()
106 					.add( "Dateiname", fileAttributes.getFileName() )
107 					.add( "erstellt", fileAttributes.getCreateTimeString() )
108 					.add( "zuletzt modifiziert", fileAttributes.getModTimeString() )
109 					.add( "Größe", fileAttributes.getSize() )
110 					.add( "Fingerabdruck", ( fileAttributes.getFileHash() == null ) ? "null" : fileAttributes.getFileHash() )
111 					.build();
112 
113 			final JsonArray jsonArray = Json.createArrayBuilder()
114 					.add( filePath )
115 					.add( LocalDateTime.now( ZoneId.systemDefault() ).format( DATE_FORMATTER ) )
116 					.add( changeStatus )
117 					.add( jsonObject )
118 					.build();
119 
120 			logCache.add( jsonArray );
121 		}finally {
122 			threadLock.unlock();
123 		}
124 	}
125 
126 	/**
127 	 * Writes the cached log entries to the file system using an efficient O(1) append strategy.
128 	 * Avoids loading existing files into memory, keeping the footprint static.
129 	 */
130 	public void printStatus() {
131 		threadLock.lock();
132 		try {
133 			if( logCache.isEmpty() ) { return; }
134 
135 			try( BufferedWriter writer = Files.newBufferedWriter(
136 					baseLogPath,
137 					StandardOpenOption.CREATE,
138 					StandardOpenOption.APPEND ) ) {
139 
140 				for( final JsonArray logEntry : logCache ) {
141 					writer.write( logEntry.toString() );
142 					writer.newLine();
143 				}
144 
145 				logCache.clear();
146 			}catch( final IOException e ) {
147 				Debug.printDebug( "[Logger] can´t write log file at -> %s", baseLogPath );
148 				Debug.printException( this.getClass(), e );
149 			}
150 		}finally {
151 			threadLock.unlock();
152 		}
153 	}
154 
155 	/**
156 	 * Reads the log file sequentially and parses the JSON lines.
157 	 * Returns the entries in reverse order, positioning the newest events at the top for UI representation.
158 	 *
159 	 * @return A list containing all logged structures ordered from newest to oldest
160 	 */
161 	public List<JsonArray> readLogForGui() {
162 		final List<JsonArray> invertedGuiList = new ArrayList<>();
163 
164 		if( !Files.exists( baseLogPath ) ) { return invertedGuiList; }
165 
166 		threadLock.lock();
167 		String currentLine = "";
168 		try {
169 			final List<String> lines = Files.readAllLines( baseLogPath );
170 			for( int i = lines.size() - 1; i >= 0; i-- ) {
171 				currentLine = lines.get( i ).trim();
172 				if( currentLine.isEmpty() ) continue;
173 				invertedGuiList.add( Json.createArrayBuilder().add( currentLine ).build() );
174 			}
175 		}catch( final JsonException e ) {
176 			Debug.printDebug( "[Logger] Invalid JSON in log line: %s", currentLine );
177 			Debug.printException( this.getClass(), e );
178 		}catch( final IOException e ) {
179 			Debug.printDebug( "[Logger] can´t read log file at -> %s", baseLogPath );
180 			Debug.printException( this.getClass(), e );
181 		}finally {
182 			threadLock.unlock();
183 		}
184 		return invertedGuiList;
185 	}
186 
187 	/**
188 	 * Evaluates the size of the primary log file on startup and initiates a cascading shift
189 	 * of backup history files if the configured size threshold is exceeded.
190 	 */
191 	private void executeLogRotationIfNeeded() {
192 		if( !Files.exists( baseLogPath ) ) { return; }
193 
194 		try {
195 			if( Files.size( baseLogPath ) < maxFileSize ) { return; }
196 
197 			// Cascade existing backups downwards (e.g., log.4 -> log.5)
198 			for( int i = maxBackupIndex - 1; i >= 1; i-- ) {
199 				final Path sourceBackup = resolveBackupPath( i );
200 				if( Files.exists( sourceBackup ) ) {
201 					final Path targetBackup = resolveBackupPath( i + 1 );
202 					Files.move( sourceBackup, targetBackup, StandardCopyOption.REPLACE_EXISTING );
203 				}
204 			}
205 
206 			// Move the current active log file to index 1 (e.g., log -> log.1)
207 			final Path firstBackup = resolveBackupPath( 1 );
208 			Files.move( baseLogPath, firstBackup, StandardCopyOption.REPLACE_EXISTING );
209 
210 		}catch( final IOException e ) {
211 			Debug.printDebug( "[Logger] Execution of log rotation failed at -> %s", baseLogPath );
212 			Debug.printException( this.getClass(), e );
213 		}
214 	}
215 
216 	/**
217 	 * Resolves the system path for an archived backup file based on its history index.
218 	 *
219 	 * @param index The history index marker
220 	 * @return Path representing the target location of the historical log file
221 	 */
222 	private Path resolveBackupPath( final int index ) {
223 		return baseLogPath.resolveSibling( baseLogPath.getFileName().toString() + "." + index );
224 	}
225 }