DatabaseConsolidator.java

1
package com.renomad.minum.database;
2
3
import com.renomad.minum.logging.ILogger;
4
import com.renomad.minum.state.Context;
5
import com.renomad.minum.utils.IFileUtils;
6
7
import java.io.IOException;
8
import java.nio.charset.StandardCharsets;
9
import java.nio.file.Path;
10
import java.time.Instant;
11
import java.time.ZoneId;
12
import java.util.*;
13
import java.util.stream.Collectors;
14
15
import static com.renomad.minum.database.AbstractDb.DATE_TIME_FORMATTER;
16
import static com.renomad.minum.database.ChecksumUtility.buildChecksum;
17
import static com.renomad.minum.database.ChecksumUtility.compareWithChecksum;
18
19
/**
20
 * Consolidates the database append logs.
21
 * <br>
22
 * As the append logs get filled up, the consolidator comes
23
 * along after to analyze those changes and determine a
24
 * consolidated version.  For example, if the append logs
25
 * have three updates for a particular element, then the consolidated file
26
 * will have just the last update.
27
 */
28
final class DatabaseConsolidator {
29
30
    /**
31
     * This is the path to the append-only files, where incoming
32
     * changes to the data are quickly stored.
33
     */
34
    private final Path appendLogDirectory;
35
36
    /**
37
     * This is the path to where we store consolidated data, so that
38
     * database startup is as fast as possible.
39
     */
40
    private final Path consolidatedDataDirectory;
41
42
    private final ILogger logger;
43
44
    private final int maxLinesPerFile;
45
    private final IFileUtils fileUtils;
46
47
    /**
48
     * This represents an instruction for how to change the overall consolidated
49
     * database files on disk.  Instructions are either to UPDATE or DELETE. This
50
     * also encapsulates the data we're updating.
51
     */
52
    record DatabaseChangeInstruction(DatabaseChangeAction action, long dataIndex, String data) {}
53
54
    DatabaseConsolidator(Path persistenceDirectory, Context context, IFileUtils fileUtils) throws IOException {
55
        this.fileUtils = fileUtils;
56
        this.appendLogDirectory = persistenceDirectory.resolve("append_logs");
57
        this.consolidatedDataDirectory = persistenceDirectory.resolve("consolidated_data");
58
        var constants = context.getConstants();
59
        this.logger = context.getLogger();
60 1 1. <init> : removed call to com/renomad/minum/utils/IFileUtils::makeDirectory → KILLED
        fileUtils.makeDirectory(this.consolidatedDataDirectory);
61
        this.maxLinesPerFile = constants.maxLinesPerConsolidatedDatabaseFile;
62
    }
63
64
    /**
65
     * Loop through all the append-only files
66
     */
67
    void consolidate() throws IOException {
68
        logger.logDebug(() -> "Starting database consolidator");
69
        List<Instant> sortedList = getSortedAppendLogs(appendLogDirectory);
70 1 1. consolidate : negated conditional → KILLED
        if (sortedList.isEmpty()) {
71
            logger.logDebug(() -> "No database files found to consolidate - exiting");
72
            return;
73
        } else {
74
            logger.logDebug(() -> "Files to consolidate: " + sortedList.stream()
75 1 1. lambda$consolidate$2 : replaced return value with "" for com/renomad/minum/database/DatabaseConsolidator::lambda$consolidate$2 → KILLED
                    .map(x -> x.atZone(ZoneId.of("UTC")).format(DATE_TIME_FORMATTER))
76
                    .collect(Collectors.joining(";")));
77
        }
78
79
        // process the files in order.  This does potentially cause
80
        // multiple updates for the consolidated files, but that's
81
        // safer than building up too large a structure in memory
82
        // before writing, and in any case, we're prioritizing efficiency
83
        // so there should only be one write to each file per loop.
84
        //
85
        // after each append-only file is fully processed, it gets deleted.
86
        for (Instant instant : sortedList) {
87
            String filename = instant.atZone(ZoneId.of("UTC")).format(DATE_TIME_FORMATTER);
88
            logger.logDebug(() -> "consolidator processing file " + filename + " in " + appendLogDirectory);
89 1 1. consolidate : removed call to com/renomad/minum/database/DatabaseConsolidator::processAppendLogFile → KILLED
            processAppendLogFile(filename, appendLogDirectory, fileUtils, maxLinesPerFile, logger, consolidatedDataDirectory);
90
            logger.logDebug(() -> "consolidator finished with file " + filename + " in " + appendLogDirectory);
91
        }
92
        logger.logDebug(() -> "Database consolidation finished");
93
    }
94
95
96
    /**
97
     * The expectation is that after we finish reading the X lines in
98
     * this append log, we will have a set of clear instructions to
99
     * apply to our previously consolidated files. There should end up
100
     * being just one action for each id - update or delete.
101
     * <br>
102
     * Build a data structure holding instructions for the next step.
103
     */
104
    static void processAppendLogFile(String filename, Path appendLogDirectory, IFileUtils fileUtils,
105
                                     int maxLinesPerFile, ILogger logger, Path consolidatedDataDirectory) throws IOException {
106
        Path fullPathToFile = appendLogDirectory.resolve(filename);
107
        List<String> lines = fileUtils.readAllLines(fullPathToFile);
108
109
        Map<Long, DatabaseChangeInstruction> resultingInstructions = new HashMap<>();
110
111
        // process each line from the file
112
113
        for (String line : lines) {
114
            DatabaseChangeInstruction databaseChange = parseDatabaseChangeInstructionString(line, filename);
115
116
            // the trick here is that by using a Map, only the last item added will remain at the end
117
            resultingInstructions.put(databaseChange.dataIndex(), databaseChange);
118
        }
119
120
        // now we have the concise list of state changes, but the next step is figuring out how
121
        // to organize them by their destination.  consolidated files will be grouped somehow.
122
        // For example, indexes 1 - 1000, 1001-2000, etc (there may be more than 1000 per file).
123
        // <br>
124
        // So, we will group our data that way,
125
        // and then efficiently update the files (a bad outcome, in contrast, would be updating
126
        // the files multiple times each).
127
128
        Map<Long, Collection<DatabaseChangeInstruction>> groupedInstructions = groupInstructionsByPartition(resultingInstructions, maxLinesPerFile);
129
130 1 1. processAppendLogFile : removed call to com/renomad/minum/database/DatabaseConsolidator::rewriteFiles → KILLED
        rewriteFiles(groupedInstructions, fileUtils, maxLinesPerFile, logger, consolidatedDataDirectory);
131
132
        // delete the file
133 1 1. processAppendLogFile : removed call to com/renomad/minum/utils/IFileUtils::delete → TIMED_OUT
        fileUtils.delete(fullPathToFile);
134
    }
135
136
    /**
137
     * Given a {@link Map} of database change instructions, grouped by keys representing the
138
     * first index in a group of indexes (like 1 to 100, or 101 to 200, etc), write the
139
     * data to files, with consideration for what might already exist.  That is to say,
140
     * if we are adding grouped instructions to an existing file such as "1_to_100", then
141
     * we want to merge our incoming data with what is already there.  Otherwise, we are just
142
     * creating a new file.
143
     */
144
    static void rewriteFiles(Map<Long, Collection<DatabaseChangeInstruction>> groupedInstructions,
145
                             IFileUtils fileUtils, int maxLinesPerFile, ILogger logger, Path consolidatedDataDirectory) throws IOException {
146
        for (Map.Entry<Long, Collection<DatabaseChangeInstruction>> instructions : groupedInstructions.entrySet()) {
147 2 1. rewriteFiles : Replaced long addition with subtraction → KILLED
2. rewriteFiles : Replaced integer subtraction with addition → KILLED
            String filename = String.format("%d_to_%d", instructions.getKey(), instructions.getKey() + (long) (maxLinesPerFile - 1));
148
            logger.logTrace(() -> "Writing consolidated data to " + filename);
149
            List<String> data;
150
            // if the file doesn't exist, we'll just start with an empty list. If it
151
            // does exist, read its lines into a List data structure.
152
            Path fullPathToConsolidatedFile = consolidatedDataDirectory.resolve(filename);
153 1 1. rewriteFiles : negated conditional → KILLED
            if (!fileUtils.exists(fullPathToConsolidatedFile)) {
154
                data = new ArrayList<>();
155
            } else {
156
                data = readConsolidatedFileWithChecksum(fullPathToConsolidatedFile, fileUtils);
157
            }
158
159
            // update the data in memory per the instructions
160
            Collection<String> updatedData = updateData(filename, data, instructions.getValue());
161
162
            String checksumString = buildChecksum(updatedData);
163
164
            // write the data to disk
165
            fileUtils.write(fullPathToConsolidatedFile, updatedData, StandardCharsets.US_ASCII);
166
167
            // write a hash of the data to use as a checksum.  This value will be checked
168
            // when reading the data later on, to confirm nothing has changed since writing.
169
            Path fullPathToChecksumFile = consolidatedDataDirectory.resolve(filename + ".checksum");
170 1 1. rewriteFiles : removed call to com/renomad/minum/utils/IFileUtils::writeString → KILLED
            fileUtils.writeString(fullPathToChecksumFile, checksumString);
171
        }
172
    }
173
174
    /**
175
     * Reads data from consolidated file, confirming the checksum in the process.
176
     */
177
    static List<String> readConsolidatedFileWithChecksum(Path fullPathToConsolidatedFile, IFileUtils fileUtils) throws IOException {
178
        // get all the data from the consolidated file
179
        List<String> data = fileUtils.readAllLines(fullPathToConsolidatedFile);
180
181
        compareWithChecksum(fullPathToConsolidatedFile, data, fileUtils);
182
183 1 1. readConsolidatedFileWithChecksum : replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::readConsolidatedFileWithChecksum → KILLED
        return data;
184
    }
185
186
187
    /**
188
     * Here, we have raw lines of data from a file, and a list of instructions for updating
189
     * that data.  We will organize the raw data better, apply the instructions, and return
190
     * the updated data
191
     *
192
     * @param linesOfData  raw lines of data from a file
193
     * @param instructions details of how to change the data in the file, either UPDATE or DELETE
194
     * @return an updated and sorted list of strings (sorted by index, which is the first value on each line)
195
     */
196
    static Collection<String> updateData(String filename, List<String> linesOfData, Collection<DatabaseChangeInstruction> instructions) {
197
        SortedMap<Long, String> result = new TreeMap<>();
198
        // put the original data into a map
199
        for (String line : linesOfData) {
200
            long dataIndexLong = getDataIndexFromLine(filename, line);
201
            result.put(dataIndexLong, line);
202
        }
203
204
        // change that data per instructions
205
        for (DatabaseChangeInstruction instruction : instructions) {
206 1 1. updateData : negated conditional → TIMED_OUT
            if (DatabaseChangeAction.UPDATE.equals(instruction.action())) {
207
                result.put(instruction.dataIndex(), instruction.data());
208
            } else {
209
                // only other option is DELETE
210
                result.remove(instruction.dataIndex());
211
            }
212
        }
213 1 1. updateData : replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::updateData → TIMED_OUT
        return result.values();
214
    }
215
216
    private static long getDataIndexFromLine(String filename, String line) {
217
        // the first pipe symbol is where the index number ends.  Apologies for
218
        // the overlap of terms here, index and index.
219
        int indexOfFirstPipe = line.indexOf('|');
220 1 1. getDataIndexFromLine : negated conditional → KILLED
        if (indexOfFirstPipe == -1) {
221
            throw new DbException(String.format("Error parsing line in file.  File: %s line: %s", filename, line));
222
        }
223
        String dataIndexString = line.substring(0, indexOfFirstPipe);
224
        long dataIndexLong;
225
        try {
226
            dataIndexLong = Long.parseLong(dataIndexString);
227
        } catch (NumberFormatException ex) {
228
            throw new DbException(String.format("Failed to parse index from line in file. File: %s line: %s", filename, line), ex);
229
        }
230 1 1. getDataIndexFromLine : replaced long return with 0 for com/renomad/minum/database/DatabaseConsolidator::getDataIndexFromLine → KILLED
        return dataIndexLong;
231
    }
232
233
    /**
234
     * This method will group the instructions for changes to the database by which
235
     * consolidated files they apply to, so that we only need to make one change
236
     * to each file.  Files are named like this: 1, 1001, etc., or
237
     * in other words, the starting index of each set of consolidated data.
238
     * @param databaseChangeInstructionMap this is a map between keys representing the
239
     *                                     index of the data, and the data itself.
240
     * @return a map consisting of keys representing the target file for the data, and
241
     * a collection of DatabaseChangeInstruction data to place in that file.
242
     */
243
    static Map<Long, Collection<DatabaseChangeInstruction>> groupInstructionsByPartition(
244
            Map<Long, DatabaseChangeInstruction> databaseChangeInstructionMap, int maxLinesPerFile) {
245
246
        // initialize a data structure to store our results
247
        Map<Long, Collection<DatabaseChangeInstruction>> instructionsGroupedByPartition = new HashMap<>();
248
249
        // loop through the incoming data, grouping and ordering as necessary
250
        for (var databaseChangeInstruction : databaseChangeInstructionMap.entrySet()) {
251
252
            // determine the expected filename for this file.  For example, if the index is 1234, then
253
            // the filename should be 1001
254 4 1. groupInstructionsByPartition : Replaced long division with multiplication → TIMED_OUT
2. groupInstructionsByPartition : Replaced long subtraction with addition → TIMED_OUT
3. groupInstructionsByPartition : Replaced long addition with subtraction → KILLED
4. groupInstructionsByPartition : Replaced long multiplication with division → KILLED
            long expectedFilename = (((databaseChangeInstruction.getKey() - 1) / maxLinesPerFile) * maxLinesPerFile) + 1;
255
256
            // If there is no key found, we need to add one, and add a new collection
257 1 1. lambda$groupInstructionsByPartition$8 : replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::lambda$groupInstructionsByPartition$8 → KILLED
            instructionsGroupedByPartition.computeIfAbsent(expectedFilename, x -> new ArrayList<>());
258
259
            // add a new item to the collection for this filename
260
            instructionsGroupedByPartition.get(expectedFilename).add(databaseChangeInstruction.getValue());
261
        }
262
263 1 1. groupInstructionsByPartition : replaced return value with Collections.emptyMap for com/renomad/minum/database/DatabaseConsolidator::groupInstructionsByPartition → KILLED
        return instructionsGroupedByPartition;
264
    }
265
266
    /**
267
     * read first 6 characters - is it update or delete?
268
     * skip a character
269
     * read digits until we hit a pipe symbol, that's our index.
270
     * read the rest of the content
271
     */
272
    static DatabaseChangeInstruction parseDatabaseChangeInstructionString(String databaseInstructionString, String filename) {
273
        String actionString;
274
        try {
275
            actionString = databaseInstructionString.substring(0, 6);
276
        } catch (IndexOutOfBoundsException ex) {
277
            // If the system encounters a crash mid-write, the resulting data could end up unreadable
278
            throw new DbException("Failed to parse database instruction string in file %s.  Data was: %s".formatted(filename, databaseInstructionString));
279
        }
280
        DatabaseChangeAction action;
281 1 1. parseDatabaseChangeInstructionString : negated conditional → KILLED
        if ("UPDATE".equals(actionString)) {
282
            action = DatabaseChangeAction.UPDATE;
283 1 1. parseDatabaseChangeInstructionString : negated conditional → KILLED
        } else if ("DELETE".equals(actionString)) {
284
            action = DatabaseChangeAction.DELETE;
285
        } else {
286
            throw new DbException("Line in append-only log was missing an action (UPDATE or DELETE) in the first characters. Line was: " + databaseInstructionString);
287
        }
288
        // confusing overlap of terms - index is used here to mean two things:
289
        // a) where we find the first pipe symbol
290
        // b) the index value of the data
291
        int indexOfPipe = databaseInstructionString.indexOf('|', 7);
292 1 1. parseDatabaseChangeInstructionString : negated conditional → TIMED_OUT
        if (indexOfPipe == -1) {
293
            throw new DbException(
294
                    "Failed to find index of the first pipe in the file %s, with content %s".formatted(filename, databaseInstructionString));
295
        }
296
        String dataIndex = databaseInstructionString.substring(7, indexOfPipe);
297
        long dataIndexLong = Long.parseLong(dataIndex);
298
299 1 1. parseDatabaseChangeInstructionString : replaced return value with null for com/renomad/minum/database/DatabaseConsolidator::parseDatabaseChangeInstructionString → TIMED_OUT
        return new DatabaseChangeInstruction(action, dataIndexLong, databaseInstructionString.substring(7));
300
    }
301
302
    /**
303
     * Given a directory, convert the list of files into a sorted
304
     * list of dates.
305
     * @return a sorted list of dates, or an empty list if nothing found
306
     */
307
    static List<Instant> getSortedAppendLogs(Path appendLogDirectory) {
308
        // get the list of file names, which are date-time stamps
309
        String[] fileList = appendLogDirectory.toFile().list();
310
311
        // if there aren't any append-only files, bail out with an empty list
312 1 1. getSortedAppendLogs : negated conditional → KILLED
        if (fileList == null) {
313
            return List.of();
314
        }
315
316
        List<Instant> appendLogDates = convertFileListToInstantList(fileList);
317
318
        // sort
319 1 1. getSortedAppendLogs : replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::getSortedAppendLogs → KILLED
        return appendLogDates.stream().sorted().toList();
320
    }
321
322
    /**
323
     * Convert a list of filenames to a list of instants
324
     */
325
    static List<Instant> convertFileListToInstantList(String[] listOfFiles) {
326
        // initialize a list which will hold the dates associated with each file name
327
        List<Instant> appendLogInstants = new ArrayList<>();
328
329
        // convert the names to dates
330
        for (String file : listOfFiles) {
331
            Instant instant = DATE_TIME_FORMATTER.parse(file, Instant::from);
332
            appendLogInstants.add(instant);
333
        }
334
335 1 1. convertFileListToInstantList : replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::convertFileListToInstantList → TIMED_OUT
        return appendLogInstants;
336
    }
337
}

Mutations

60

1.1
Location : <init>
Killed by : com.renomad.minum.database.DatabaseConsolidatorTests
removed call to com/renomad/minum/utils/IFileUtils::makeDirectory → KILLED

70

1.1
Location : consolidate
Killed by : com.renomad.minum.database.DbFileConverterTests
negated conditional → KILLED

75

1.1
Location : lambda$consolidate$2
Killed by : com.renomad.minum.web.WebPerformanceTests.webPerfTest(com.renomad.minum.web.WebPerformanceTests)
replaced return value with "" for com/renomad/minum/database/DatabaseConsolidator::lambda$consolidate$2 → KILLED

89

1.1
Location : consolidate
Killed by : com.renomad.minum.database.DbFileConverterTests
removed call to com/renomad/minum/database/DatabaseConsolidator::processAppendLogFile → KILLED

130

1.1
Location : processAppendLogFile
Killed by : com.renomad.minum.web.WebPerformanceTests.webPerfTest(com.renomad.minum.web.WebPerformanceTests)
removed call to com/renomad/minum/database/DatabaseConsolidator::rewriteFiles → KILLED

133

1.1
Location : processAppendLogFile
Killed by : none
removed call to com/renomad/minum/utils/IFileUtils::delete → TIMED_OUT

147

1.1
Location : rewriteFiles
Killed by : com.renomad.minum.security.TheBrigTests
Replaced long addition with subtraction → KILLED

2.2
Location : rewriteFiles
Killed by : com.renomad.minum.security.TheBrigTests
Replaced integer subtraction with addition → KILLED

153

1.1
Location : rewriteFiles
Killed by : com.renomad.minum.security.TheBrigTests
negated conditional → KILLED

170

1.1
Location : rewriteFiles
Killed by : com.renomad.minum.security.TheBrigTests
removed call to com/renomad/minum/utils/IFileUtils::writeString → KILLED

183

1.1
Location : readConsolidatedFileWithChecksum
Killed by : com.renomad.minum.security.TheBrigTests
replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::readConsolidatedFileWithChecksum → KILLED

206

1.1
Location : updateData
Killed by : none
negated conditional → TIMED_OUT

213

1.1
Location : updateData
Killed by : none
replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::updateData → TIMED_OUT

220

1.1
Location : getDataIndexFromLine
Killed by : com.renomad.minum.database.DatabaseConsolidatorTests
negated conditional → KILLED

230

1.1
Location : getDataIndexFromLine
Killed by : com.renomad.minum.security.TheBrigTests
replaced long return with 0 for com/renomad/minum/database/DatabaseConsolidator::getDataIndexFromLine → KILLED

254

1.1
Location : groupInstructionsByPartition
Killed by : none
Replaced long division with multiplication → TIMED_OUT

2.2
Location : groupInstructionsByPartition
Killed by : com.renomad.minum.security.TheBrigTests
Replaced long addition with subtraction → KILLED

3.3
Location : groupInstructionsByPartition
Killed by : com.renomad.minum.security.TheBrigTests
Replaced long multiplication with division → KILLED

4.4
Location : groupInstructionsByPartition
Killed by : none
Replaced long subtraction with addition → TIMED_OUT

257

1.1
Location : lambda$groupInstructionsByPartition$8
Killed by : com.renomad.minum.security.TheBrigTests
replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::lambda$groupInstructionsByPartition$8 → KILLED

263

1.1
Location : groupInstructionsByPartition
Killed by : com.renomad.minum.web.WebPerformanceTests.webPerfTest(com.renomad.minum.web.WebPerformanceTests)
replaced return value with Collections.emptyMap for com/renomad/minum/database/DatabaseConsolidator::groupInstructionsByPartition → KILLED

281

1.1
Location : parseDatabaseChangeInstructionString
Killed by : com.renomad.minum.database.DatabaseConsolidatorTests
negated conditional → KILLED

283

1.1
Location : parseDatabaseChangeInstructionString
Killed by : com.renomad.minum.database.DatabaseConsolidatorTests
negated conditional → KILLED

292

1.1
Location : parseDatabaseChangeInstructionString
Killed by : none
negated conditional → TIMED_OUT

299

1.1
Location : parseDatabaseChangeInstructionString
Killed by : none
replaced return value with null for com/renomad/minum/database/DatabaseConsolidator::parseDatabaseChangeInstructionString → TIMED_OUT

312

1.1
Location : getSortedAppendLogs
Killed by : com.renomad.minum.database.DatabaseConsolidatorTests
negated conditional → KILLED

319

1.1
Location : getSortedAppendLogs
Killed by : com.renomad.minum.database.DbFileConverterTests
replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::getSortedAppendLogs → KILLED

335

1.1
Location : convertFileListToInstantList
Killed by : none
replaced return value with Collections.emptyList for com/renomad/minum/database/DatabaseConsolidator::convertFileListToInstantList → TIMED_OUT

Active mutators

Tests examined


Report generated by PIT 1.17.0