/**
* Copyright 2013 Benjamin Lerer
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.horizondb.db.commitlog;
import io.horizondb.db.Configuration;
import io.horizondb.db.StorageEngine;
import io.horizondb.io.ReadableBuffer;
import io.horizondb.io.checksum.ChecksumByteReader;
import io.horizondb.io.checksum.ChecksumByteWriter;
import io.horizondb.io.files.RandomAccessDataFile;
import io.horizondb.io.files.SeekableFileDataInput;
import io.horizondb.io.files.SeekableFileDataOutput;
import java.io.Closeable;
import java.io.Flushable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.builder.ToStringBuilder;
import org.apache.commons.lang.builder.ToStringStyle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A segment of the commit log.
*
* @author Benjamin
*
*/
final class CommitLogSegment implements Closeable, Comparable<CommitLogSegment>, Flushable {
/**
* The marker written out at the end of a segment.
*/
private static final int END_OF_SEGMENT_MARKER = 0;
/**
* The number of bytes of the end segment.
*/
private static final int END_OF_SEGMENT_MARKER_SIZE = 4;
/**
* The prefix of the commit logs file name.
*/
private static final String FILENAME_PREFIX = "CommitLog-";
/**
* The commit log files extension.
*/
private static final String FILENAME_EXTENSION = ".log";
/**
* The regular expression used verify filenames.
*/
private static final Pattern COMMIT_LOG_FILE_PATTERN = Pattern.compile(FILENAME_PREFIX + "(\\d+)"
+ FILENAME_EXTENSION);
/**
* The checksum size in number of bytes.
*/
static final int CHECKSUM_SIZE = 8;
/**
* Overhead in bytes for each log entry (int: length + long: head checksum + long: tail checksum).
*/
static final int LOG_OVERHEAD_SIZE = 4 + CHECKSUM_SIZE + CHECKSUM_SIZE;
/**
* The logger.
*/
private final static Logger LOGGER = LoggerFactory.getLogger(CommitLogSegment.class);
/**
* The database configuration.
*/
private final Configuration configuration;
/**
* The segment ID.
*/
public final long id;
/**
* The segment file.
*/
private final RandomAccessDataFile file;
/**
* The <code>FileDataOutput</code> used to write to the disk.
*/
private final SeekableFileDataOutput output;
/**
* The utility to compute checksum.
*/
private final ChecksumByteWriter crcOutput;
/**
* <code>true</code> if some data need to be flushed on the disk.
*/
private boolean needsFlush = false;
/**
* <code>true</code> if the segment is closed.
*/
private boolean closed;
/**
* Returns <code>true</code> if the filename of the specified path is the one of a commit log segment.
*
* @param path the file path.
* @return <code>true</code> if the filename of the specified path is the one of a commit log segment,
* <code>false</code> otherwise.
*/
public static boolean isCommitLogSegment(Path path) {
String filename = path.getFileName().toString();
Matcher matcher = COMMIT_LOG_FILE_PATTERN.matcher(filename);
return matcher.matches();
}
/**
* Creates a new segment file.
*
* @param configuration the database configuration.
* @return the new <code>CommitLogSegment</code>.
* @throws IOException if a problem occurs while creating the segment.
* @throws InterruptedException if the thread is interrupted while retrieving the output stream.
*/
public static CommitLogSegment freshSegment(Configuration configuration) throws IOException, InterruptedException {
long id = IdFactory.nextId();
Path commitLogDirectory = configuration.getCommitLogDirectory();
Path newFilePath = commitLogDirectory.resolve(fileName(id));
LOGGER.debug("Creating new commit log segment {}", newFilePath);
return new CommitLogSegment(configuration, id, newFilePath);
}
/**
* Restores an existing commit log segment.
*
* @param configuration the database configuration.
* @param path the file path.
* @return a <code>CommitLogSegment</code> that contains the data of the specified file.
* @throws IOException if a problem occurs while restoring the segment.
* @throws InterruptedException if the thread is interrupted while retrieving the output stream.
*/
public static CommitLogSegment loadFromFile(Configuration configuration, Path path) throws IOException,
InterruptedException {
return new CommitLogSegment(configuration, path);
}
/**
* Creates a new segment by recycling the specified one.
*
* @param segment the segment to recycle.
* @return the recycled segment.
* @throws IOException if a problem occurs while recycling the specified segment.
* @throws InterruptedException if the thread is interrupted while retrieving the output stream.
*/
public static CommitLogSegment recycleSegment(CommitLogSegment segment) throws IOException, InterruptedException {
long id = IdFactory.nextId();
Path commitLogDirectory = segment.configuration.getCommitLogDirectory();
Path newFilePath = commitLogDirectory.resolve(fileName(id));
segment.close();
LOGGER.debug("Re-using discarded CommitLog segment for {} from {}", Long.valueOf(id), segment.getPath());
Files.move(segment.getPath(), newFilePath);
return new CommitLogSegment(segment.configuration, id, newFilePath);
}
/**
* Checks if the specified amount of bytes can be written to this segment.
*
* @return <code>true</code> if there is room to write the specified amount of bytes.
* @param size the size of the data to add to the segment.
* @throws IOException if an I/O problem occurs while retrieving the file size.
*/
public boolean hasCapacityFor(long size) throws IOException {
return (size + LOG_OVERHEAD_SIZE) <= writableBytes();
}
/**
* Writes the specified bytes to this segment.
*
* @param bytes the bytes to write to this segments.
* @return the replay position corresponding to the position after the specified bytes have been written.
* @throws IOException if an I/O problem occurs while writing to the file.
*/
public ReplayPosition write(ReadableBuffer bytes) throws IOException {
if (this.closed) {
throw new IllegalStateException("This segment " + getPath() + " has already been closed.");
}
int length = bytes.readableBytes();
if (length == 0) {
return new ReplayPosition(this.id, this.output.getPosition());
}
this.crcOutput.reset();
this.crcOutput.writeInt(length);
this.crcOutput.writeChecksum();
this.crcOutput.transfer(bytes);
this.crcOutput.writeChecksum();
ReplayPosition position = new ReplayPosition(this.id, this.output.getPosition());
if (writableBytes() >= END_OF_SEGMENT_MARKER_SIZE) {
writeEndOfSegmentMarkerAndRewind();
}
this.needsFlush = true;
return position;
}
/**
* Returns the file path.
*
* @return the file path.
*/
public Path getPath() {
return this.file.getPath();
}
/**
* Returns the segment ID.
*
* @return the segment ID.
*/
public long getId() {
return this.id;
}
/**
* {@inheritDoc}
*/
@Override
public void flush() throws IOException {
if (this.needsFlush) {
this.output.flush();
this.needsFlush = false;
}
}
/**
* {@inheritDoc}
*/
@Override
public void close() {
if (this.closed) {
return;
}
try {
this.output.close();
this.file.close();
} catch (IOException e) {
// Do nothing.
}
this.closed = true;
}
/**
* Replays the content of this segment.
*
* @param databaseEngine the database engine on which the data must be replayed.
* @return the number of message replayed.
* @throws IOException if an I/O problem occurs while replaying the data.
*/
public int replay(StorageEngine databaseEngine) throws IOException {
int count = 0;
try (SeekableFileDataInput input = this.file.newInput()) {
ChecksumByteReader crcInput = ChecksumByteReader.wrap(input);
while (input.readableBytes() >= 4) {
crcInput.resetChecksum();
int length = crcInput.readInt();
if (length == END_OF_SEGMENT_MARKER) {
break;
}
if (!crcInput.readChecksum()) {
break;
}
ReadableBuffer bytes = crcInput.slice(length);
if (!crcInput.readChecksum()) {
break;
}
ReplayPosition replayPosition = new ReplayPosition(this.id, input.getPosition());
databaseEngine.replay(replayPosition, bytes);
count++;
}
} catch (IndexOutOfBoundsException e) {
LOGGER.error("The file is shorter than expected. The last entry will not be replayed.", e);
}
return count;
}
/**
* {@inheritDoc}
*/
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE).append("path", getPath()).toString();
}
/**
* {@inheritDoc}
*/
@Override
public int compareTo(CommitLogSegment other) {
return Long.compare(this.id, other.id);
}
/**
* Creates the filename for the segment with the specified id.
*
* @param id the segment id.
* @return the filename for the segment with the specified id.
*/
private static String fileName(long id) {
return FILENAME_PREFIX + id + FILENAME_EXTENSION;
}
/**
* Creates a segment instance by recovering the data from the specified file.
*
* @param configuration the database configuration.
* @param filePath the path to the existing segment.
* @throws IOException if a problem occurs while reading the existing file.
* @throws InterruptedException if the thread is interrupted while retrieving the output stream.
*/
private CommitLogSegment(Configuration configuration, Path filePath) throws IOException, InterruptedException {
this.configuration = configuration;
String filename = filePath.getFileName().toString();
Matcher matcher = COMMIT_LOG_FILE_PATTERN.matcher(filename);
if (!matcher.matches()) {
throw new IllegalStateException("The file: " + filePath + " is not a valid commit log segment.");
}
this.id = Long.parseLong(matcher.group(1));
this.file = RandomAccessDataFile.mmap(filePath);
this.output = this.file.getOutput();
this.crcOutput = ChecksumByteWriter.wrap(this.output);
}
/**
* Creates a new segment instance.
*
* @param configuration the database configuration.
* @param id the segment id.
* @param filePath the path to the file where the data must be stored.
* @throws IOException if a problem occurs while opening the existing file.
* @throws InterruptedException if the thread is interrupted while retrieving the output stream.
*/
private CommitLogSegment(Configuration configuration, long id, Path filePath) throws IOException,
InterruptedException {
this.configuration = configuration;
this.id = id;
long segmentSize = configuration.getCommitLogSegmentSize();
this.file = RandomAccessDataFile.mmap(filePath, segmentSize);
this.output = this.file.getOutput();
this.crcOutput = ChecksumByteWriter.wrap(this.output);
writeEndOfSegmentMarkerAndRewind();
this.needsFlush = true;
}
/**
* Returns the number of writable bytes.
*
* @return the number of writable bytes.
* @throws IOException if an I/O problem occurs while retrieving the file size.
*/
private long writableBytes() throws IOException {
return this.file.size() - this.output.getPosition();
}
/**
* Writes the end of segment marker and rewind at the position where it starts.
*
* @throws IOException if an I/O problem occurs.
*/
private void writeEndOfSegmentMarkerAndRewind() throws IOException {
this.output.writeInt(END_OF_SEGMENT_MARKER);
this.output.seek(this.output.getPosition() - END_OF_SEGMENT_MARKER_SIZE);
}
}