All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.jsimpledb.kv.array.AtomicArrayKVStore Maven / Gradle / Ivy

The newest version!

/*
 * Copyright (C) 2015 Archie L. Cobbs. All rights reserved.
 */

package org.jsimpledb.kv.array;

import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.ForwardingFuture;

import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;

import org.dellroad.stuff.io.AtomicUpdateFileOutputStream;
import org.jsimpledb.kv.AbstractKVStore;
import org.jsimpledb.kv.CloseableKVStore;
import org.jsimpledb.kv.KVPair;
import org.jsimpledb.kv.KVStore;
import org.jsimpledb.kv.KeyRange;
import org.jsimpledb.kv.KeyRanges;
import org.jsimpledb.kv.mvcc.AtomicKVStore;
import org.jsimpledb.kv.mvcc.MutableView;
import org.jsimpledb.kv.mvcc.Mutations;
import org.jsimpledb.kv.mvcc.Writes;
import org.jsimpledb.kv.util.CloseableForwardingKVStore;
import org.jsimpledb.util.ByteUtil;
import org.jsimpledb.util.CloseableIterator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * {@link AtomicKVStore} based on {@link ArrayKVStore} plus a write-ahead log and background compaction.
 *
 * 

* This implementation is designed to maximize the speed of reads and minimize the amount of memory overhead per key/value pair. * It is optimized for relatively infrequent writes. * *

* A (read-only) {@link ArrayKVStore} is the basis for the database; the array files are mapped into memory. As mutations are * applied, they are added to an in-memory change set, and appended to a mutation log file for persistence. On restart, * the mutation log file (if any) is read to reconstruct the in-memory change set. * *

* Compaction * *

* Instances periodically compact outstanding changes into new array files (and truncate the mutation log file) in a background * thread. A compaction is scheduled whenever: *

    *
  • The size of mutation log file exceeds the {@linkplain #setCompactLowWater compaction space low-water mark}
  • *
  • The oldest uncompacted modification is older than the {@linkplain #setCompactMaxDelay compaction maximum delay}
  • *
  • The {@link #scheduleCompaction} method is invoked
  • *
* *

* In order to prevent compaction from getting hopelessly behind when there is high write volume, a * {@linkplain #setCompactHighWater compaction space high-water mark} is also used. When the size of the mutation log file * exceeds the half-way point between the low-water and high-water marks, new write attempts start being artificially delayed, * and this delay amount {@linkplain #calculateCompactionPressureDelay increases to infinity} as the high-water mark is approached, * so that as long as the high-water mark is exceeded, new writes are blocked completely until the current compaction cycle * completes. * *

* The number of bytes occupied by the in-memory change set and the length of the outstanding mutations log file are * related. Therefore, the compaction high-water mark loosely correlates to a maximum amount of memory required * by the in-memory change set. * *

* Hot Backups * *

* "Hot" backups may be created in parallel with normal operation via {@link #hotCopy hotCopy()}. * Hard links are used to make this operation fast; only the mutation log file (if any) is actually copied. * *

* The {@linkplain #setDirectory database directory} is a required configuration property. * *

* Key and value data must not exceed 2GB (each separately). * *

* Instances may be stopped and (re)started multiple times. * * @see Write-ahead logging */ @ThreadSafe public class AtomicArrayKVStore extends AbstractKVStore implements AtomicKVStore { /** * Default compaction maximum delay in seconds ({@value #DEFAULT_COMPACTION_MAX_DELAY} seconds). */ public static final int DEFAULT_COMPACTION_MAX_DELAY = 24 * 60 * 60; /** * Default compaction space low-water mark in bytes ({@value #DEFAULT_COMPACTION_LOW_WATER} bytes). */ public static final int DEFAULT_COMPACTION_LOW_WATER = 64 * 1024; /** * Default compaction space high-water mark in bytes ({@value #DEFAULT_COMPACTION_HIGH_WATER} bytes). */ public static final int DEFAULT_COMPACTION_HIGH_WATER = 1024 * 1024 * 1024; private static final int MIN_MMAP_LENGTH = 1024 * 1024; private static final String GENERATION_FILE_NAME = "gen"; private static final String LOCK_FILE_NAME = "lockfile"; private static final String INDX_FILE_NAME_BASE = "indx."; private static final String KEYS_FILE_NAME_BASE = "keys."; private static final String VALS_FILE_NAME_BASE = "vals."; private static final String MODS_FILE_NAME_BASE = "mods."; private final Logger log = LoggerFactory.getLogger(this.getClass()); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); private final ReentrantReadWriteLock.ReadLock readLock = this.lock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = this.lock.writeLock(); private final Condition hotCopyFinishedCondition = this.writeLock.newCondition(); private final boolean suckyOS = this.isWindows(); // Configuration state @GuardedBy("lock") private File directory; @GuardedBy("lock") private ScheduledExecutorService scheduledExecutorService; @GuardedBy("lock") private int compactMaxDelay = DEFAULT_COMPACTION_MAX_DELAY; @GuardedBy("lock") private int compactLowWater = DEFAULT_COMPACTION_LOW_WATER; @GuardedBy("lock") private int compactHighWater = DEFAULT_COMPACTION_HIGH_WATER; // Runtime state @GuardedBy("lock") private long generation; @GuardedBy("lock") private boolean createdExecutorService; @GuardedBy("lock") private File generationFile; @GuardedBy("lock") private File lockFile; @GuardedBy("lock") private FileChannel lockFileChannel; @GuardedBy("lock") private File indxFile; @GuardedBy("lock") private File keysFile; @GuardedBy("lock") private File valsFile; @GuardedBy("lock") private File modsFile; @GuardedBy("lock") private FileOutputStream modsFileOutput; @GuardedBy("lock") private FileChannel directoryChannel; // not used on Windows @GuardedBy("lock") private long modsFileLength; @GuardedBy("lock") private long modsFileSyncPoint; @GuardedBy("lock") private ByteBuffer indx; @GuardedBy("lock") private ByteBuffer keys; @GuardedBy("lock") private ByteBuffer vals; @GuardedBy("lock") private ArrayKVStore kvstore; @GuardedBy("lock") private MutableView mods; @GuardedBy("lock") private Compaction compaction; @GuardedBy("lock") private long firstModTimestamp; @GuardedBy("lock") private int hotCopiesInProgress; // Accessors /** * Get the filesystem directory containing the database. If not set, this class functions as an im-memory store. * * @return database directory, or null for none */ public File getDirectory() { this.readLock.lock(); try { return this.directory; } finally { this.readLock.unlock(); } } /** * Configure the filesystem directory containing the database. If not set, this class functions as an im-memory store. * * @param directory database directory, or null for none * @throws IllegalStateException if this instance is already {@link #start}ed */ public void setDirectory(File directory) { this.writeLock.lock(); try { Preconditions.checkState(this.kvstore == null, "already started"); this.directory = directory; } finally { this.writeLock.unlock(); } } /** * Configure the {@link ScheduledExecutorService} used to schedule background compaction. * *

* If not explicitly configured, a {@link ScheduledExecutorService} will be created automatically during {@link #start} * using {@link Executors#newSingleThreadScheduledExecutor} and shutdown by {@link #stop} (if explicitly configured here, * the configured {@link ScheduledExecutorService} will not be shutdown by {@link #stop}). * * @param scheduledExecutorService schduled executor service, or null to have one created automatically * @throws IllegalStateException if this instance is already {@link #start}ed */ public void setScheduledExecutorService(ScheduledExecutorService scheduledExecutorService) { this.writeLock.lock(); try { Preconditions.checkState(this.kvstore == null, "already started"); this.scheduledExecutorService = scheduledExecutorService; } finally { this.writeLock.unlock(); } } /** * Configure the compaction time maximum delay. Compaction will be automatically triggered whenever there is any * uncompacted modification older than this. * * @param compactMaxDelay compaction time maximum delay in seconds * @throws IllegalArgumentException if {@code compactMaxDelay} is negative * @throws IllegalStateException if this instance is already {@link #start}ed */ public void setCompactMaxDelay(int compactMaxDelay) { Preconditions.checkState(compactMaxDelay >= 0, "negative value"); this.writeLock.lock(); try { Preconditions.checkState(this.kvstore == null, "already started"); this.compactMaxDelay = compactMaxDelay; } finally { this.writeLock.unlock(); } } /** * Configure the compaction space low-water mark in bytes. * *

* This value is applied to the size of the on-disk modifications file. * * @param compactLowWater compaction space low-water mark in bytes * @throws IllegalArgumentException if {@code compactLowWater} is negative * @throws IllegalStateException if this instance is already {@link #start}ed */ public void setCompactLowWater(int compactLowWater) { Preconditions.checkState(compactLowWater >= 0, "negative value"); this.writeLock.lock(); try { Preconditions.checkState(this.kvstore == null, "already started"); this.compactLowWater = compactLowWater; } finally { this.writeLock.unlock(); } } /** * Configure the compaction space high-water mark in bytes. * *

* This value is applied to the size of the on-disk modifications file. * *

* If the compaction space high water mark is set smaller than the the compaction space low water mark, * then it's treated as if it were the same as the compaction space low water mark. * * @param compactHighWater compaction space high-water mark in bytes * @throws IllegalArgumentException if {@code compactHighWater} is negative * @throws IllegalStateException if this instance is already {@link #start}ed */ public void setCompactHighWater(int compactHighWater) { Preconditions.checkState(compactHighWater >= 0, "negative value"); this.writeLock.lock(); try { Preconditions.checkState(this.kvstore == null, "already started"); this.compactHighWater = compactHighWater; } finally { this.writeLock.unlock(); } } // Lifecycle @Override @PostConstruct public void start() { boolean success = false; this.writeLock.lock(); try { // Already started? if (this.kvstore != null) { success = true; return; } this.log.info("starting " + this); // Sanity check assert this.compaction == null; assert this.scheduledExecutorService == null; assert !this.createdExecutorService; assert this.generation == 0; assert this.generationFile == null; assert this.lockFile == null; assert this.lockFileChannel == null; assert this.indxFile == null; assert this.keysFile == null; assert this.valsFile == null; assert this.modsFile == null; assert this.modsFileOutput == null; assert this.directoryChannel == null; assert this.modsFileLength == 0; assert this.modsFileSyncPoint == 0; assert this.indx == null; assert this.keys == null; assert this.vals == null; assert this.kvstore == null; assert this.mods == null; assert this.firstModTimestamp == 0; // Check configuration Preconditions.checkState(this.directory != null, "no directory configured"); // Create executor if needed this.createdExecutorService = this.scheduledExecutorService == null; if (this.createdExecutorService) { this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(action -> { final Thread thread = new Thread(action); thread.setName("Compactor for " + this); return thread; }); } // Create directory if needed boolean initializeFiles = false; if (!this.directory.exists()) { if (!this.directory.mkdirs()) throw new ArrayKVException("failed to create directory `" + this.directory + "'"); } if (!this.directory.isDirectory()) throw new ArrayKVException("file `" + this.directory + "' is not a directory"); // Get directory channel we can fsync() try { this.directoryChannel = FileChannel.open(this.directory.toPath()); } catch (IOException e) { if (!this.suckyOS) throw e; } // Open and lock the lock file this.lockFile = new File(this.directory, LOCK_FILE_NAME); if (!this.lockFile.exists()) { this.lockFileChannel = FileChannel.open(this.lockFile.toPath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); } else { this.lockFileChannel = FileChannel.open(this.lockFile.toPath(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); } FileLock fileLock = null; try { fileLock = this.lockFileChannel.tryLock(); } catch (OverlappingFileLockException e) { // too bad } if (fileLock == null) throw new ArrayKVException("database is already locked by another process or thread"); // If no generation file exists, initialize generation zero this.generationFile = new File(this.directory, GENERATION_FILE_NAME); if (!this.generationFile.exists()) { // Verify no index, keys, or values file exists try (DirectoryStream paths = Files.newDirectoryStream(this.directory.toPath())) { for (Path path : paths) { final File file = path.toFile(); final String name = file.getName(); if (name.startsWith(INDX_FILE_NAME_BASE) || name.startsWith(KEYS_FILE_NAME_BASE) || name.startsWith(VALS_FILE_NAME_BASE)) { throw new ArrayKVException("database file inconsistency: found " + name + " but not " + GENERATION_FILE_NAME + " in " + this.directory); } } } // Create empty index, keys, and values files try ( final FileOutputStream indxOutput = new FileOutputStream(new File(this.directory, INDX_FILE_NAME_BASE + 0)); final FileOutputStream keysOutput = new FileOutputStream(new File(this.directory, KEYS_FILE_NAME_BASE + 0)); final FileOutputStream valsOutput = new FileOutputStream(new File(this.directory, VALS_FILE_NAME_BASE + 0)); final ArrayKVWriter arrayWriter = new ArrayKVWriter(indxOutput, keysOutput, valsOutput)) { arrayWriter.flush(); // avoid compiler warning valsOutput.getChannel().force(false); keysOutput.getChannel().force(false); indxOutput.getChannel().force(false); } if (this.directoryChannel != null) this.directoryChannel.force(false); // Create generation file try (FileOutputStream output = new FileOutputStream(this.generationFile)) { output.write("0\n".getBytes(StandardCharsets.UTF_8)); output.flush(); output.getChannel().force(false); } if (this.directoryChannel != null) this.directoryChannel.force(false); } // Read current generation number try (LineNumberReader reader = new LineNumberReader( new InputStreamReader(new FileInputStream(this.generationFile), "UTF-8"))) { final String line = reader.readLine(); if (line == null) throw new ArrayKVException("generation file " + this.generationFile + " is empty"); this.generation = Long.parseLong(line.trim(), 10); if (this.generation < 0) throw new ArrayKVException("read negative generation number from " + this.generationFile); } catch (Exception e) { throw new ArrayKVException("error reading generation file", e); } // Set corresponding filenames this.indxFile = new File(this.directory, INDX_FILE_NAME_BASE + this.generation); this.keysFile = new File(this.directory, KEYS_FILE_NAME_BASE + this.generation); this.valsFile = new File(this.directory, VALS_FILE_NAME_BASE + this.generation); this.modsFile = new File(this.directory, MODS_FILE_NAME_BASE + this.generation); // Scan directory for unexpected files final List expectedFiles = Arrays.asList(this.lockFile, this.generationFile, this.indxFile, this.keysFile, this.valsFile, this.modsFile); try (DirectoryStream paths = Files.newDirectoryStream(this.directory.toPath())) { for (Path path : paths) { final File file = path.toFile(); if (!expectedFiles.contains(file)) this.log.warn("ignoring unexpected file " + file.getName() + " in my database directory"); } } // Create buffers that wrap the index, keys, and values files try (FileInputStream input = new FileInputStream(this.indxFile)) { this.indx = AtomicArrayKVStore.getBuffer(this.indxFile, input.getChannel()); } try (FileInputStream input = new FileInputStream(this.keysFile)) { this.keys = AtomicArrayKVStore.getBuffer(this.keysFile, input.getChannel()); } try (FileInputStream input = new FileInputStream(this.valsFile)) { this.vals = AtomicArrayKVStore.getBuffer(this.valsFile, input.getChannel()); } // Set up underlying k/v store and uncompacted modifications this.kvstore = new ArrayKVStore(this.indx, this.keys, this.vals); this.mods = new MutableView(this.kvstore, null, new Writes()); // Setup modifications file this.modsFileOutput = new FileOutputStream(this.modsFile, true); this.modsFileLength = this.modsFileOutput.getChannel().size(); this.modsFileSyncPoint = this.modsFileLength; // Read and apply pre-existing uncompacted modifications from modifications file if (this.modsFileLength > 0) { this.log.info("reading " + this.modsFileLength + " bytes of uncompacted modifications from " + this.modsFile); try (FileInputStream input = new FileInputStream(this.modsFile)) { while (input.available() > 0) { final Writes writes; try { writes = Writes.deserialize(input, true); } catch (Exception e) { break; // probably a partial write } writes.applyTo(this.mods); } } this.firstModTimestamp = System.nanoTime() | 1; // avoid zero value which is special } // Schedule compaction if necessary this.scheduleCompactionIfNecessary(); // Done success = true; } catch (IOException e) { throw new ArrayKVException("startup failed", e); } finally { try { if (!success) this.cleanup(); } finally { this.writeLock.unlock(); } } } @Override @PreDestroy public void stop() { this.writeLock.lock(); try { // Check state if (this.kvstore == null) return; this.log.info("stopping " + this); // Cleanup this.cleanup(); } finally { this.writeLock.unlock(); } } private void cleanup() { // Should hold write lock now assert this.lock.isWriteLockedByCurrentThread(); // Cancel compaction if possible, otherwise wait for it to complete if (this.compaction != null && !this.compaction.cancel()) { // Wait for compaction to complete this.log.debug("waiting for in-progress compaction to complete before shutdown"); this.compaction.waitForCompletion(0); this.log.debug("compaction completed, proceeding with shutdown"); // Check whether another thread invoked stop() while we were asleep if (this.kvstore == null) return; } // Wait for any in-progress hot copies to complete if (this.hotCopiesInProgress > 0) { this.log.debug("waiting for " + this.hotCopiesInProgress + " hot copies to complete before shutdown"); boolean interrupted = false; do { // Wait for hot copies to complete try { this.hotCopyFinishedCondition.await(); } catch (InterruptedException e) { this.log.warn("thread interrupted while waiting for " + this.hotCopiesInProgress + " hot copies to complete (ignoring)", e); interrupted = true; } // Check whether another thread invoked stop() while we were asleep if (this.kvstore == null) return; } while (this.hotCopiesInProgress > 0); if (interrupted) Thread.currentThread().interrupt(); this.log.debug("hot copies completed, proceeding with shutdown"); } // Shut down executor - only if we created it if (this.createdExecutorService) { this.scheduledExecutorService.shutdownNow(); this.scheduledExecutorService = null; this.createdExecutorService = false; } // Close files for (Closeable resource : new Closeable[] { this.modsFileOutput, this.directoryChannel, this.lockFileChannel }) { if (resource != null) this.closeIgnoreException(resource); } // Reset state this.generation = 0; this.generationFile = null; this.lockFile = null; this.lockFileChannel = null; this.indxFile = null; this.keysFile = null; this.valsFile = null; this.modsFile = null; this.modsFileOutput = null; this.directoryChannel = null; this.modsFileLength = 0; this.modsFileSyncPoint = 0; this.indx = null; this.keys = null; this.vals = null; this.kvstore = null; this.mods = null; this.firstModTimestamp = 0; } // KVStore @Override public byte[] get(byte[] key) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.get(key); } finally { this.readLock.unlock(); } } @Override public KVPair getAtLeast(byte[] minKey, byte[] maxKey) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.getAtLeast(minKey, maxKey); } finally { this.readLock.unlock(); } } @Override public KVPair getAtMost(byte[] maxKey, byte[] minKey) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.getAtMost(maxKey, minKey); } finally { this.readLock.unlock(); } } @Override public CloseableIterator getRange(byte[] minKey, byte[] maxKey, boolean reverse) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.getRange(minKey, maxKey, reverse); } finally { this.readLock.unlock(); } } @Override public void put(byte[] key, byte[] value) { final Writes writes = new Writes(); writes.getPuts().put(key, value); this.mutate(writes, false); } @Override public void remove(byte[] key) { final Writes writes = new Writes(); writes.getRemoves().add(new KeyRange(key)); this.mutate(writes, false); } @Override public void removeRange(byte[] minKey, byte[] maxKey) { final Writes writes = new Writes(); writes.getRemoves().add(new KeyRange(minKey != null ? minKey : ByteUtil.EMPTY, maxKey)); this.mutate(writes, false); } @Override public void adjustCounter(byte[] key, long amount) { final Writes writes = new Writes(); writes.getAdjusts().put(key, amount); this.mutate(writes, false); } @Override public byte[] encodeCounter(long value) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.encodeCounter(value); } finally { this.readLock.unlock(); } } @Override public long decodeCounter(byte[] bytes) { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); return this.mods.decodeCounter(bytes); } finally { this.readLock.unlock(); } } // AtomicKVStore @Override public CloseableKVStore snapshot() { this.readLock.lock(); try { Preconditions.checkState(this.kvstore != null, "closed"); // Clone the modifications currrently being compacted, if any Writes compactingWrites = null; if (this.mods.getKVStore() instanceof MutableView) { // we are compacting assert this.compaction != null; final MutableView compactingMods = (MutableView)this.mods.getKVStore(); assert compactingMods.getKVStore() == this.kvstore; synchronized (compactingMods) { if (!compactingMods.getWrites().isEmpty()) compactingWrites = compactingMods.getWrites().immutableSnapshot(); } } // Clone the uncompacted modifications, if any Writes outstandingWrites = null; final MutableView uncompactedMods = this.mods; synchronized (uncompactedMods) { if (!uncompactedMods.getWrites().isEmpty()) outstandingWrites = uncompactedMods.getWrites().immutableSnapshot(); } // Build snapshot by layering uncompacted modifications on top KVStore snapshot = this.kvstore; if (compactingWrites != null) snapshot = new MutableView(snapshot, null, compactingWrites); if (outstandingWrites != null) snapshot = new MutableView(snapshot, null, outstandingWrites); // Done return new CloseableForwardingKVStore(snapshot); } finally { this.readLock.unlock(); } } @Override public void mutate(Mutations mutations, final boolean sync) { Preconditions.checkArgument(mutations != null, "null mutations"); this.writeLock.lock(); try { // Verify we are started Preconditions.checkState(this.kvstore != null, "not started"); // Delay operation if size of outstanding modifications is approaching high water mark while (this.compaction != null) { // Calculate how full is low -> high window final float windowRatio = this.modsFileLength <= this.compactLowWater ? 0.0f : this.modsFileLength >= this.compactHighWater ? 1.0f : (float)(this.modsFileLength - this.compactLowWater) / (float)(this.compactHighWater - this.compactLowWater); // Calculate corresponding delay final long delayMillis = this.calculateCompactionPressureDelay(windowRatio); if (delayMillis < 0) break; // If there is already a scheduled compaction, reschedule to be immediate if (this.compaction.getDelay() > 0) this.scheduleCompaction(0); // Wait for it to complete final long delayNanos = TimeUnit.MILLISECONDS.toNanos(delayMillis); long waitStart = 0; if (this.log.isDebugEnabled()) { this.log.debug(String.format("reached %d%% of high-water mark; waiting up to %dms for in-progress" + " compaction to complete before applying mutation(s)", (int)(windowRatio * 100), delayMillis)); waitStart = System.nanoTime(); } this.compaction.waitForCompletion(delayNanos); if (this.log.isDebugEnabled()) { this.log.debug(String.format("compaction completed after %dms, proceeding with application of mutation(s)", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - waitStart))); } // Verify we are still open after giving up lock if (this.kvstore == null) throw new ArrayKVException("k/v store was closed while waiting for compaction to complete"); // Done waiting break; } // Get mutations as a Writes object final Writes writes; if (mutations instanceof Writes) writes = (Writes)mutations; else { writes = new Writes(); writes.getRemoves().add(new KeyRanges(mutations.getRemoveRanges())); for (Map.Entry entry : mutations.getPutPairs()) writes.getPuts().put(entry.getKey(), entry.getValue()); for (Map.Entry entry : mutations.getAdjustPairs()) writes.getAdjusts().put(entry.getKey(), entry.getValue()); } // Check for the trivial case where there are zero modifications if (writes.isEmpty()) return; // Append mutations to uncompacted mods file try { final BufferedOutputStream buf = new BufferedOutputStream(this.modsFileOutput); writes.serialize(buf); buf.flush(); } catch (IOException e) { try { this.modsFileOutput.getChannel().truncate(this.modsFileLength); // undo append } catch (IOException e2) { this.log.error("error truncating log file (ignoring)", e2); } throw new ArrayKVException("error appending to " + this.modsFile, e); } final long newModsFileLength; try { newModsFileLength = this.modsFileOutput.getChannel().size(); } catch (IOException e) { throw new ArrayKVException("error getting length of " + this.modsFile, e); } if (this.log.isDebugEnabled()) { this.log.debug("appended " + (newModsFileLength - this.modsFileLength) + " bytes to " + this.modsFile + " (new length " + newModsFileLength + ")"); } this.modsFileLength = newModsFileLength; // Apply removes for (KeyRange range : mutations.getRemoveRanges()) { final byte[] min = range.getMin(); final byte[] max = range.getMax(); this.mods.removeRange(min, max); } // Apply puts for (Map.Entry entry : mutations.getPutPairs()) { final byte[] key = entry.getKey(); final byte[] val = entry.getValue(); this.mods.put(key, val); } // Apply counter adjustments for (Map.Entry entry : mutations.getAdjustPairs()) { final byte[] key = entry.getKey(); final long adjust = entry.getValue(); this.mods.adjustCounter(key, adjust); } // Set first uncompacted modification timestamp, if not already set if (this.firstModTimestamp == 0) this.firstModTimestamp = System.nanoTime() | 1; // avoid zero value which is special // Schedule compaction if necessary this.scheduleCompactionIfNecessary(); // If we're not syncing, we're done if (!sync) return; // Update sync point, so compaction knows to also sync these mods when it copies them this.modsFileSyncPoint = this.modsFileLength; // Downgrade lock this.readLock.lock(); } finally { this.writeLock.unlock(); } // Sync the mods file while holding only the read lock try { this.modsFileOutput.getChannel().force(false); } catch (IOException e) { this.log.error("error syncing log file (ignoring)", e); } finally { this.readLock.unlock(); } } // Hot Copy /** * Create a filesystem atomic snapshot, or "hot" copy", of this instance in the specified destination directory. * The copy will be up-to-date as of the time this method is invoked. * *

* The {@code target} directory will be created if it does not exist; otherwise, it must be empty. * All files except the uncompacted modifications file are copied into {@code target} * in constant time using {@linkplain Files#createLink hard links} if possible. * *

* The hot copy operation can proceed in parallel with normal database activity, with the exception * that the compaction operation cannot start while there are concurrent hot copies in progress. * *

* Therefore, for best performance, consider {@linkplain #scheduleCompaction performing a compaction} * immediately prior to this operation. * *

* Note: when this method returns, all copied files, and the {@code target} directory, have been {@code fsync()}'d * and therefore may be considered durable in case of a system crash. * * @param target destination directory * @throws IOException if an I/O error occurs * @throws IllegalArgumentException if {@code target} exists and is not a directory or is non-empty * @throws IllegalArgumentException if {@code target} is null */ public void hotCopy(File target) throws IOException { // Sanity check Preconditions.checkArgument(target != null, "null target"); final Path dir = target.toPath(); // Create/verify directory if (!Files.exists(dir)) Files.createDirectories(dir); if (!Files.isDirectory(dir)) throw new IllegalArgumentException("target `" + dir + "' is not a directory"); try (DirectoryStream stream = Files.newDirectoryStream(dir)) { for (Path path : stream) throw new IllegalArgumentException("target `" + dir + "' is not empty"); } // Increment hot copy counter - this prevents compaction from removing files while we're copying them this.writeLock.lock(); try { // Sanity check Preconditions.checkState(this.kvstore != null, "not started"); // Bump counter this.hotCopiesInProgress++; } finally { this.writeLock.unlock(); } try { // Logit this.log.debug("started hot copy into " + target); // Copy index, keys, and values files using hard links (if possible) as these files are read-only final ArrayList regularCopyFiles = new ArrayList<>(5); for (File file : new File[] { this.indxFile, this.keysFile, this.valsFile }) { try { Files.createLink(dir.resolve(file.getName()), file.toPath()); } catch (IOException | UnsupportedOperationException e) { regularCopyFiles.add(file); // fall back to normal copy } } // Copy remaining files without using hard links regularCopyFiles.add(this.modsFile); // it's ok if we copy a partial write regularCopyFiles.add(this.generationFile); // copy this one last for (File file : regularCopyFiles) { try (final FileOutputStream fileCopy = new FileOutputStream(new File(target, file.getName()))) { Files.copy(file.toPath(), fileCopy); fileCopy.getChannel().force(false); } } // Sync directory try (FileChannel dirChannel = FileChannel.open(dir)) { dirChannel.force(false); } catch (IOException e) { if (!this.suckyOS) throw e; } } finally { this.writeLock.lock(); try { // Sanity check assert this.hotCopiesInProgress > 0; assert this.kvstore != null; // Logit this.log.debug("completed hot copy into " + target); // Decrement counter this.hotCopiesInProgress--; // Wakeup waiters this.hotCopyFinishedCondition.signalAll(); } finally { this.writeLock.unlock(); } } } // Compaction /** * Calculate the maximum amount of time that a thread attempting to {@link #mutate mutate()} must wait * for a background compaction to complete as we start nearing the high water mark. * *

* The implementation in {@link AtomicArrayKVStore} returns {@code -1} for all values below 50%, * and then scales according to an inverse power function returning zero at 50%, 100ms at 75%, * and {@link Long#MAX_VALUE} at 100%. * * @param windowRatio the uncompacted mutation size as a fraction of the interval between low and high * water marks; always a value value between zero and one (exclusive) * @return negative value for no delay, zero or positive value to trigger a background compaction * (if not already running) and wait up to that many milliseconds for it to complete */ protected long calculateCompactionPressureDelay(float windowRatio) { if ((windowRatio -= 0.5f) < 0) return -1; return (long)(100 * (1.0f / (0.5f - windowRatio) - 1.0f)); } /** * Schedule a new compaction cycle, unless there is one already scheduled or running, or there are no * outstanding uncompacted modifications. * * @return a future for the completion of the next compaction cycle, or null if there are no uncompacted modifications * @throws IllegalStateException if this instance is not started */ public Future scheduleCompaction() { this.writeLock.lock(); try { // Sanity check Preconditions.checkState(this.kvstore != null, "not started"); // Is there anything to compact? if (this.modsFileLength == 0) return null; // Schedule an immediate compaction return this.scheduleCompaction(0); } finally { this.writeLock.unlock(); } } private Future scheduleCompaction(long millis) { // Should hold write lock now assert this.lock.isWriteLockedByCurrentThread(); assert this.modsFileLength > 0; // (Re)schedule compaction if necessary if (this.compaction == null || (this.compaction.getDelay() > millis && this.compaction.cancel())) { assert this.compaction == null; this.compaction = new Compaction(millis); if (this.log.isTraceEnabled()) this.log.trace("scheduling compaction for " + millis + "ms from now"); } // Done return new ForwardingFuture.SimpleForwardingFuture(this.compaction.getFuture()) { @Override public boolean cancel(boolean mayInterrupt) { // disallow this operation return false; } }; } private void scheduleCompactionIfNecessary() { // Should hold write lock now assert this.lock.isWriteLockedByCurrentThread(); // Is there anything to do? if (this.modsFileLength == 0) return; // Check space low-water mark if (this.modsFileLength > this.compactLowWater) { this.scheduleCompaction(0); return; } // Check maximum compaction delay if (this.firstModTimestamp != 0) { final long firstModAgeMillis = (System.nanoTime() - this.firstModTimestamp) / 1000000L; // convert ns -> ms final long remainingDelay = Math.max(0, this.compactMaxDelay * 1000L - firstModAgeMillis); if (this.log.isTraceEnabled()) this.log.trace("first modification was " + firstModAgeMillis + "ms ago, remainingDelay = " + remainingDelay); this.scheduleCompaction(remainingDelay); return; } } @SuppressWarnings("fallthrough") private void compact(final Compaction compaction) throws IOException { // Sanity check assert compaction != null; // Handle cancel() race condition this.writeLock.lock(); try { // Were we canceled? if (compaction != this.compaction) return; // Set now running - this prevents future cancel()'s assert !compaction.isStarted(); assert !compaction.isCompleted(); compaction.setStarted(); } finally { this.writeLock.unlock(); } // Start compaction final long compactionStartTime; try { // Snapshot (and wrap) pending modifications, and mark log file position final Writes writesToCompact; final long previousModsFileLength; final long previousModsFileSyncPoint; this.writeLock.lock(); try { // Sanity checks assert this.kvstore != null; assert this.modsFileLength > 0; // Mark start time and get uncompacted modifications compactionStartTime = System.nanoTime(); writesToCompact = this.mods.getWrites(); // It's possible the uncompacted modifications are a no-op; if so, no compaction is necessary if (writesToCompact.isEmpty()) { // Wait for any in-progress hot copies to complete while (this.hotCopiesInProgress > 0) { this.log.debug("waiting for " + this.hotCopiesInProgress + " hot copies to complete before completing (trivial) compaction"); try { this.hotCopyFinishedCondition.await(); } catch (InterruptedException e) { throw new ArrayKVException("thread was interrupted while waiting for " + this.hotCopiesInProgress + " hot copies to complete", e); } this.log.debug("hot copies completed, proceeding with completion of (trivial) compaction"); } assert this.kvstore != null; assert this.modsFileLength > 0; // Discard outstanding mods this.modsFileOutput.getChannel().truncate(0); this.modsFileLength = 0; this.modsFileSyncPoint = 0; this.modsFileOutput.getChannel().force(false); return; } // Allow new modifications to be added by other threads while we are compacting the old modifications this.mods = new MutableView(this.mods, null, new Writes()); previousModsFileLength = this.modsFileLength; previousModsFileSyncPoint = this.modsFileSyncPoint; } finally { this.writeLock.unlock(); } if (this.log.isDebugEnabled()) { this.log.debug("starting compaction for generation " + this.generation + " -> " + (this.generation + 1) + " with mods file length " + previousModsFileLength); } // Create the next generation final long newGeneration = this.generation + 1; final File newIndxFile = new File(this.directory, INDX_FILE_NAME_BASE + newGeneration); final File newKeysFile = new File(this.directory, KEYS_FILE_NAME_BASE + newGeneration); final File newValsFile = new File(this.directory, VALS_FILE_NAME_BASE + newGeneration); final File newModsFile = new File(this.directory, MODS_FILE_NAME_BASE + newGeneration); ByteBuffer newIndx = null; ByteBuffer newKeys = null; ByteBuffer newVals = null; FileOutputStream newModsFileOutput = null; boolean success = false; try { // Merge existing compacted key/value data with uncompacted modifications try ( final FileOutputStream indxOutput = new FileOutputStream(newIndxFile); final FileOutputStream keysOutput = new FileOutputStream(newKeysFile); final FileOutputStream valsOutput = new FileOutputStream(newValsFile); final ArrayKVWriter arrayWriter = new ArrayKVWriter(indxOutput, keysOutput, valsOutput)) { // Write out merged key/value pairs try (CloseableIterator i = this.kvstore.getRange(null, null)) { arrayWriter.writeMerged(this.kvstore, i, writesToCompact); } // Sync file data arrayWriter.flush(); valsOutput.getChannel().force(false); keysOutput.getChannel().force(false); indxOutput.getChannel().force(false); } assert newIndxFile.exists(); assert newKeysFile.exists(); assert newValsFile.exists(); // Create byte buffers from new files try (FileInputStream input = new FileInputStream(newIndxFile)) { newIndx = AtomicArrayKVStore.getBuffer(newIndxFile, input.getChannel()); } try (FileInputStream input = new FileInputStream(newKeysFile)) { newKeys = AtomicArrayKVStore.getBuffer(newKeysFile, input.getChannel()); } try (FileInputStream input = new FileInputStream(newValsFile)) { newVals = AtomicArrayKVStore.getBuffer(newValsFile, input.getChannel()); } // Create new, empty mods file newModsFileOutput = new FileOutputStream(newModsFile, true); assert newModsFile.exists(); // Sync directory if (this.directoryChannel != null) this.directoryChannel.force(false); // We're done creating files success = true; } finally { // Finish up this.writeLock.lock(); try { if (success) { // Initialize for wrap-up success = false; // Size up additional mods final long additionalModsLength = this.modsFileLength - previousModsFileLength; // Logit if (this.log.isDebugEnabled()) { final float duration = (System.nanoTime() - compactionStartTime) / 1000000000f; this.log.debug("compaction for generation " + this.generation + " -> " + (this.generation + 1) + " finishing up with " + additionalModsLength + " bytes of new modifications after " + String.format("%.4f", duration) + " seconds"); } // Wait for any in-progress hot copies to complete while (this.hotCopiesInProgress > 0) { this.log.debug("waiting for " + this.hotCopiesInProgress + " hot copies to complete before completing compaction"); try { this.hotCopyFinishedCondition.await(); } catch (InterruptedException e) { throw new ArrayKVException("thread was interrupted while waiting for " + this.hotCopiesInProgress + " hot copies to complete", e); } assert this.compaction == compaction; assert this.kvstore != null; this.log.debug("hot copies completed, proceeding with completion of compaction"); } // Apply any changes that were made while we were unlocked and writing files long newModsFileLength = 0; long newModsFileSyncPoint = 0; if (additionalModsLength > 0) { try (FileChannel modsFileChannel = FileChannel.open(this.modsFile.toPath(), StandardOpenOption.READ)) { final FileChannel newModsFileChannel = newModsFileOutput.getChannel(); // Append new data in old mods file to new mods file while (newModsFileLength < additionalModsLength) { final long transferred = modsFileChannel.transferTo( previousModsFileLength + newModsFileLength, additionalModsLength - newModsFileLength, newModsFileChannel); assert transferred > 0; newModsFileLength += transferred; } // If any of that new data was fsync()'d, we must fsync() the copy of it in the new mods file if (this.modsFileSyncPoint > previousModsFileSyncPoint) { newModsFileChannel.force(false); newModsFileSyncPoint = newModsFileLength; } } } // Atomically update new generation file contents, except on Windows where that's impossible final FileOutputStream genOutput = !this.suckyOS ? new AtomicUpdateFileOutputStream(this.generationFile) : new FileOutputStream(this.generationFile); boolean genSuccess = false; try { genOutput.write((newGeneration + "\n").getBytes(StandardCharsets.UTF_8)); genOutput.flush(); genOutput.getChannel().force(false); genSuccess = true; } finally { if (genSuccess) genOutput.close(); else if (genOutput instanceof AtomicUpdateFileOutputStream) ((AtomicUpdateFileOutputStream)genOutput).cancel(); } // Declare success success = true; // Remember old info so we can clean it up final File oldIndxFile = this.indxFile; final File oldKeysFile = this.keysFile; final File oldValsFile = this.valsFile; final File oldModsFile = this.modsFile; final FileOutputStream oldModsFileOutput = this.modsFileOutput; // Change to the new generation this.generation = newGeneration; this.indx = newIndx; this.keys = newKeys; this.vals = newVals; this.indxFile = newIndxFile; this.keysFile = newKeysFile; this.valsFile = newValsFile; this.modsFile = newModsFile; this.modsFileOutput = newModsFileOutput; newModsFileOutput = null; this.modsFileLength = newModsFileLength; this.modsFileSyncPoint = newModsFileSyncPoint; this.kvstore = new ArrayKVStore(this.indx, this.keys, this.vals); this.mods = new MutableView(this.kvstore, null, this.mods.getWrites()); if (additionalModsLength == 0) this.firstModTimestamp = 0; else { this.firstModTimestamp = System.nanoTime() | 1; this.scheduleCompactionIfNecessary(); } // Sync directory prior to deleting files if (this.directoryChannel != null) { try { this.directoryChannel.force(false); } catch (IOException e) { this.log.error("error syncing directory " + this.directory + " (ignoring)", e); } } // Close old mods file output stream this.closeIgnoreException(oldModsFileOutput); // Delete old files this.deleteWarnException(oldIndxFile); this.deleteWarnException(oldKeysFile); this.deleteWarnException(oldValsFile); this.deleteWarnException(oldModsFile); } } finally { try { // Logit if (this.log.isDebugEnabled()) { final float duration = (System.nanoTime() - compactionStartTime) / 1000000000f; this.log.debug("compaction for generation " + (newGeneration - 1) + " -> " + newGeneration + (success ? " successfully compacted " + previousModsFileLength + " bytes" : " failed") + " in " + String.format("%.4f", duration) + " seconds"); } // Cleanup/undo on failure if (newModsFileOutput != null) { this.closeIgnoreException(newModsFileOutput); this.deleteWarnException(newModsFile); } if (!success) { // Put back the old uncompacted modifications, and merge any new mods into them final Writes writesDuringCompaction = this.mods.getWrites(); this.mods = new MutableView(this.kvstore, null, writesToCompact); writesDuringCompaction.applyTo(this.mods); // Delete the files we were creating this.deleteWarnException(newIndxFile); this.deleteWarnException(newKeysFile); this.deleteWarnException(newValsFile); } } finally { this.writeLock.unlock(); } } } } finally { // Update state this.writeLock.lock(); try { assert this.kvstore != null; assert this.compaction == compaction; compaction.setCompleted(); this.compaction = null; } finally { this.writeLock.unlock(); } } } private boolean isWindows() { return System.getProperty("os.name", "generic").toLowerCase(Locale.ENGLISH).contains("win"); } private void deleteWarnException(File file) { try { Files.delete(file.toPath()); } catch (IOException e) { this.log.warn("error deleting " + file + " (proceeding anyway): " + e); } } private void closeIgnoreException(Closeable resource) { try { resource.close(); } catch (IOException e) { // ignore } } // Compaction private class Compaction implements Runnable { private final Condition completedCondition = AtomicArrayKVStore.this.writeLock.newCondition(); private final ScheduledFuture future; private boolean started; private boolean completed; @SuppressWarnings("unchecked") Compaction(long millis) { // Sanity check assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); Preconditions.checkState(AtomicArrayKVStore.this.compaction == null, "compaction already exists"); // Schedule this.future = (ScheduledFuture)AtomicArrayKVStore.this.scheduledExecutorService.schedule(this, millis, TimeUnit.MILLISECONDS); } /** * Cancel this compaction. * * @return true if canceled, false if already started */ public boolean cancel() { // Sanity check assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); Preconditions.checkState(this.future != null, "not scheduled"); // Already canceled? if (AtomicArrayKVStore.this.compaction != this) return false; // Already started? if (this.started) return false; // Attempt to cancel scheduled task assert this.future != null; this.future.cancel(false); // Make this task do nothing even if it runs anyway AtomicArrayKVStore.this.compaction = null; return true; } /** * Get delay until start. * * @return delay in milliseconds */ public long getDelay() { // Sanity check assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); Preconditions.checkState(this.future != null, "not scheduled"); // Get delay return Math.max(0, this.future.getDelay(TimeUnit.MILLISECONDS)); } /** * Wait for this compaction to complete. * * @param nanos max wait in nanoseconds, or zero for indefinite wait * @return true if compaction completed, false if timeout occurred first */ public boolean waitForCompletion(long nanos) { // Sanity check assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); // Wait for completion boolean interrupted = false; while (!this.completed) { try { if (nanos > 0) { if ((nanos = this.completedCondition.awaitNanos(nanos)) <= 0) break; } else this.completedCondition.await(); } catch (InterruptedException e) { AtomicArrayKVStore.this.log.warn( "thread was interrupted while waiting for compaction to complete (ignoring)", e); interrupted = true; } } if (interrupted) Thread.currentThread().interrupt(); return this.completed; } public Future getFuture() { return this.future; } public boolean isStarted() { assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); return this.started; } public void setStarted() { assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); this.started = true; } public boolean isCompleted() { assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); return this.completed; } public void setCompleted() { assert AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); this.completed = true; this.completedCondition.signalAll(); } @Override public void run() { assert !AtomicArrayKVStore.this.lock.isWriteLockedByCurrentThread(); try { AtomicArrayKVStore.this.compact(this); } catch (Throwable t) { AtomicArrayKVStore.this.log.error("error during compaction", t); } } } // Object /** * Finalize this instance. Invokes {@link #stop} to close any unclosed iterators. */ @Override protected void finalize() throws Throwable { try { if (this.kvstore != null) this.log.warn(this + " leaked without invoking stop()"); this.stop(); } finally { super.finalize(); } } @Override public String toString() { return this.getClass().getSimpleName() + "[" + this.directory + "]"; } private static ByteBuffer getBuffer(File file, FileChannel fileChannel) throws IOException { final long length = fileChannel.size(); return length >= MIN_MMAP_LENGTH ? fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, length) : ByteBuffer.wrap(Files.readAllBytes(file.toPath())).asReadOnlyBuffer(); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy