diff --git a/README.md b/README.md index 86dc78e..588a849 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,25 @@ tidesdb-java is the official Java binding for TidesDB. -TidesDB is a fast and efficient key-value storage engine library written in C. The underlying data structure is based on a log-structured merge-tree (LSM-tree). This Java binding provides a safe, idiomatic Java interface to TidesDB with full support for all features. +TidesDB is a fast and efficient key-value storage engine library. The underlying data structure is based on a log-structured merge-tree (LSM-tree). This Java binding provides a safe, idiomatic Java interface to TidesDB with full support for all features. ## Features - MVCC with five isolation levels from READ UNCOMMITTED to SERIALIZABLE +- Snapshots and point-in-time reads at a named sequence +- Two-phase commit with recovery of transactions left in doubt - Column families (isolated key-value stores with independent configuration) -- Bidirectional iterators with forward/backward traversal and seek support +- Bidirectional iterators with forward/backward traversal, seek, and range-scoped scans +- Range and prefix deletes that cost one entry however many keys they cover - TTL (time to live) support with automatic key expiration -- LZ4, LZ4 Fast, ZSTD, Snappy, or no compression -- Bloom filters with configurable false positive rates -- Global block CLOCK cache for hot blocks +- Stacked encoding pipelines: LZ4, LZ4 Fast, ZSTD, Snappy, or none +- Key/value separation with a shared value log and background reclamation +- Partition range filters with configurable false positive rates +- Global block cache for hot blocks - Savepoints for partial transaction rollback -- Six built-in comparators plus custom registration - -For Java usage you can go to the TidesDB Java Reference [here](https://tidesdb.com/reference/java/). +- Transaction timeouts and cross-thread aborts +- Commit hooks for change data capture +- Statistics for column families, the database, the cache, write stalls, device I/O, encoding chains, and key ranges ## License diff --git a/doc/java.md b/doc/java.md new file mode 100644 index 0000000..f8b005f --- /dev/null +++ b/doc/java.md @@ -0,0 +1,1155 @@ +--- +title: TidesDB Java API Reference +description: Complete Java API reference for TidesDB +--- + +
Shared-memtable, value-log, and MVCC figures are database-level and live in + * {@link DbStats} instead. + * + *
The per-level arrays are indexed by level minus one, and only the first + * {@link #getNumLevels()} entries are meaningful. + */ +public class CfStats { + + /** + * The maximum number of SSTable levels a column family keeps, which is the + * length of every per-level array on this class. + */ + public static final int MAX_LEVELS = 8; + + private final int numLevels; + private final ColumnFamilyConfig config; + private final long[] levelSizes; + private final int[] levelNumSstables; + private final long[] levelKeyCounts; + private final long[] levelTombstoneCounts; + private final long totalKeys; + private final long totalDataSize; + private final double avgKeySize; + private final double avgValueSize; + private final double readAmp; + private final long btreeTotalNodes; + private final long btreeMaxHeight; + private final double btreeAvgHeight; + private final long totalTombstones; + private final double tombstoneRatio; + private final double maxSstDensity; + private final int maxSstDensityLevel; + private final long walBytesWritten; + private final long flushBytesWritten; + private final long compactionBytesWritten; + private final long compactionBytesRead; + private final long userBytesWritten; + private final long compactionCount; + private final long unflushedKeyCount; + private final long filterResidentBytes; + + /** + * Creates a new {@code CfStats}. Typically called by the JNI bridge rather + * than application code. + * + * @param numLevels number of levels in the column family + * @param config a copy of the column family configuration + * @param levelSizes on-disk size of each level, indexed by level minus one + * @param levelNumSstables SSTable count of each level + * @param levelKeyCounts key count of each level + * @param levelTombstoneCounts tombstone count of each level + * @param totalKeys total distinct keys across every SSTable + * @param totalDataSize the family's own on-disk size + * @param avgKeySize average key length in bytes, over distinct keys + * @param avgValueSize average value length in bytes, over distinct keys + * @param readAmp point-lookup read amplification + * @param btreeTotalNodes total btree nodes across the column family + * @param btreeMaxHeight maximum btree height + * @param btreeAvgHeight average btree height + * @param totalTombstones sum of tombstone counts across every SSTable + * @param tombstoneRatio tombstones over total keys + * @param maxSstDensity worst per-SSTable tombstone density observed + * @param maxSstDensityLevel 1-based level where that density was observed + * @param walBytesWritten this family's share of the shared write-ahead log + * @param flushBytesWritten on-disk bytes this family's flushes wrote to L1 + * @param compactionBytesWritten on-disk bytes this family's compactions wrote + * @param compactionBytesRead on-disk bytes this family's compactions read + * @param userBytesWritten logical key and value bytes committed + * @param compactionCount compactions this family has run + * @param unflushedKeyCount distinct keys resident in the shared memtables + * @param filterResidentBytes memory this family's filters hold outside the cache + */ + public CfStats(int numLevels, ColumnFamilyConfig config, long[] levelSizes, + int[] levelNumSstables, long[] levelKeyCounts, long[] levelTombstoneCounts, + long totalKeys, long totalDataSize, double avgKeySize, double avgValueSize, + double readAmp, long btreeTotalNodes, long btreeMaxHeight, double btreeAvgHeight, + long totalTombstones, double tombstoneRatio, double maxSstDensity, + int maxSstDensityLevel, long walBytesWritten, long flushBytesWritten, + long compactionBytesWritten, long compactionBytesRead, long userBytesWritten, + long compactionCount, long unflushedKeyCount, long filterResidentBytes) { + this.numLevels = numLevels; + this.config = config; + this.levelSizes = levelSizes == null ? new long[MAX_LEVELS] : levelSizes.clone(); + this.levelNumSstables = levelNumSstables == null ? new int[MAX_LEVELS] : levelNumSstables.clone(); + this.levelKeyCounts = levelKeyCounts == null ? new long[MAX_LEVELS] : levelKeyCounts.clone(); + this.levelTombstoneCounts = + levelTombstoneCounts == null ? new long[MAX_LEVELS] : levelTombstoneCounts.clone(); + this.totalKeys = totalKeys; + this.totalDataSize = totalDataSize; + this.avgKeySize = avgKeySize; + this.avgValueSize = avgValueSize; + this.readAmp = readAmp; + this.btreeTotalNodes = btreeTotalNodes; + this.btreeMaxHeight = btreeMaxHeight; + this.btreeAvgHeight = btreeAvgHeight; + this.totalTombstones = totalTombstones; + this.tombstoneRatio = tombstoneRatio; + this.maxSstDensity = maxSstDensity; + this.maxSstDensityLevel = maxSstDensityLevel; + this.walBytesWritten = walBytesWritten; + this.flushBytesWritten = flushBytesWritten; + this.compactionBytesWritten = compactionBytesWritten; + this.compactionBytesRead = compactionBytesRead; + this.userBytesWritten = userBytesWritten; + this.compactionCount = compactionCount; + this.unflushedKeyCount = unflushedKeyCount; + this.filterResidentBytes = filterResidentBytes; + } + + /** + * Returns the number of levels in the column family. Only the first this + * many entries of each per-level array are meaningful. + * + * @return the level count + */ + public int getNumLevels() { + return numLevels; + } + + /** + * Returns a copy of the column family's configuration as the engine holds + * it, including its persisted name. + * + * @return the configuration + */ + public ColumnFamilyConfig getConfig() { + return config; + } + + /** + * Returns the on-disk size of each level, indexed by level minus one. + * + * @return a copy of the per-level sizes in bytes + */ + public long[] getLevelSizes() { + return levelSizes.clone(); + } + + /** + * Returns the SSTable count of each level, indexed by level minus one. + * + * @return a copy of the per-level SSTable counts + */ + public int[] getLevelNumSstables() { + return levelNumSstables.clone(); + } + + /** + * Returns the key count of each level, indexed by level minus one. + * + * @return a copy of the per-level key counts + */ + public long[] getLevelKeyCounts() { + return levelKeyCounts.clone(); + } + + /** + * Returns the tombstone count of each level, indexed by level minus one. + * + * @return a copy of the per-level tombstone counts + */ + public long[] getLevelTombstoneCounts() { + return levelTombstoneCounts.clone(); + } + + /** + * Returns the total distinct keys across every SSTable in the column family. + * Add {@link #getUnflushedKeyCount()} for the live logical key count + * including what is still in memory. + * + * @return the key count + */ + public long getTotalKeys() { + return totalKeys; + } + + /** + * Returns the family's own on-disk size, the sum of its key logs. Values + * below the separation threshold are inside those bytes already; what + * spilled lives in the shared value log, reported by + * {@link DbStats#getVlogFileSize()}. + * + * @return the on-disk size in bytes + */ + public long getTotalDataSize() { + return totalDataSize; + } + + /** + * Returns the average key length in bytes, over distinct keys. + * + * @return the average key size + */ + public double getAvgKeySize() { + return avgKeySize; + } + + /** + * Returns the average value length in bytes, over distinct keys. + * Tombstones contribute zero. + * + * @return the average value size + */ + public double getAvgValueSize() { + return avgValueSize; + } + + /** + * Returns the point-lookup read amplification, the SSTables a worst-case get + * may probe. + * + * @return the read amplification + */ + public double getReadAmp() { + return readAmp; + } + + /** + * Returns the total btree nodes across the column family. + * + * @return the node count + */ + public long getBtreeTotalNodes() { + return btreeTotalNodes; + } + + /** + * Returns the maximum btree height. + * + * @return the maximum height + */ + public long getBtreeMaxHeight() { + return btreeMaxHeight; + } + + /** + * Returns the average btree height. + * + * @return the average height + */ + public double getBtreeAvgHeight() { + return btreeAvgHeight; + } + + /** + * Returns the sum of tombstone counts across every SSTable. + * + * @return the tombstone count + */ + public long getTotalTombstones() { + return totalTombstones; + } + + /** + * Returns tombstones over total keys, or 0 when there are no keys. + * + * @return the tombstone ratio + */ + public double getTombstoneRatio() { + return tombstoneRatio; + } + + /** + * Returns the worst per-SSTable tombstone density observed. + * + * @return the maximum density + */ + public double getMaxSstDensity() { + return maxSstDensity; + } + + /** + * Returns the 1-based level where {@link #getMaxSstDensity()} was observed. + * + * @return the level, or 0 if none + */ + public int getMaxSstDensityLevel() { + return maxSstDensityLevel; + } + + /** + * Returns this family's share of the shared write-ahead log, as the encoded + * size of its own entries. This is an attribution rather than a measurement: + * the batch header and the block framing belong to no single family, so + * these do not sum to what the log wrote. {@link DbStats#getWalBytesWritten()} + * is the measured figure. + * + * @return the attributed WAL byte count + */ + public long getWalBytesWritten() { + return walBytesWritten; + } + + /** + * Returns the on-disk bytes this family's flushes wrote to L1. + * + * @return the flush byte count + */ + public long getFlushBytesWritten() { + return flushBytesWritten; + } + + /** + * Returns the on-disk bytes this family's compactions wrote. + * + * @return the compaction output byte count + */ + public long getCompactionBytesWritten() { + return compactionBytesWritten; + } + + /** + * Returns the on-disk bytes this family's compactions read as input. + * + * @return the compaction input byte count + */ + public long getCompactionBytesRead() { + return compactionBytesRead; + } + + /** + * Returns the logical key and value bytes committed to this family. + * + * @return the user byte count + */ + public long getUserBytesWritten() { + return userBytesWritten; + } + + /** + * Returns the number of compactions this family has run. + * + * @return the compaction count + */ + public long getCompactionCount() { + return compactionCount; + } + + /** + * Returns the distinct keys resident in the shared memtables for this family + * and not yet in any SSTable. + * + * @return the unflushed key count + */ + public long getUnflushedKeyCount() { + return unflushedKeyCount; + } + + /** + * Returns the memory this family's partition range filters hold outside the + * block cache. The filter bit arrays themselves are not counted here: those + * are fetched per probe and live in the cache. A directory is built on a + * table's first probe, so this reads zero for a family nothing has read from + * yet and rises as tables are touched. + * + * @return the resident filter bytes + */ + public long getFilterResidentBytes() { + return filterResidentBytes; + } + + @Override + public String toString() { + return "CfStats{" + + "numLevels=" + numLevels + + ", levelSizes=" + Arrays.toString(levelSizes) + + ", levelNumSstables=" + Arrays.toString(levelNumSstables) + + ", levelKeyCounts=" + Arrays.toString(levelKeyCounts) + + ", levelTombstoneCounts=" + Arrays.toString(levelTombstoneCounts) + + ", totalKeys=" + totalKeys + + ", totalDataSize=" + totalDataSize + + ", avgKeySize=" + avgKeySize + + ", avgValueSize=" + avgValueSize + + ", readAmp=" + readAmp + + ", btreeTotalNodes=" + btreeTotalNodes + + ", btreeMaxHeight=" + btreeMaxHeight + + ", btreeAvgHeight=" + btreeAvgHeight + + ", totalTombstones=" + totalTombstones + + ", tombstoneRatio=" + tombstoneRatio + + ", maxSstDensity=" + maxSstDensity + + ", maxSstDensityLevel=" + maxSstDensityLevel + + ", walBytesWritten=" + walBytesWritten + + ", flushBytesWritten=" + flushBytesWritten + + ", compactionBytesWritten=" + compactionBytesWritten + + ", compactionBytesRead=" + compactionBytesRead + + ", userBytesWritten=" + userBytesWritten + + ", compactionCount=" + compactionCount + + ", unflushedKeyCount=" + unflushedKeyCount + + ", filterResidentBytes=" + filterResidentBytes + + ", config=" + config + + '}'; + } +} diff --git a/src/main/java/com/tidesdb/ColumnFamily.java b/src/main/java/com/tidesdb/ColumnFamily.java index 7065111..9a57b49 100644 --- a/src/main/java/com/tidesdb/ColumnFamily.java +++ b/src/main/java/com/tidesdb/ColumnFamily.java @@ -19,37 +19,35 @@ package com.tidesdb; /** - * Represents a column family in TidesDB. A column family is an isolated - * key-value store within a database, with its own independent configuration. + * A column family in TidesDB: an isolated key-value store within a database, + * with its own independent configuration. * - *
A {@code ColumnFamily} is a handle returned by {@link TidesDB#getColumnFamily(String)} - * and is not independently closeable. There is no Java-side guard against using - * a column family after its owning database has been closed; callers must manage - * the lifecycle externally. + *
A {@code ColumnFamily} is a handle returned by + * {@link TidesDB#getColumnFamily(String)} and is not independently closeable. It + * is invalid once its owning database is closed or the family is dropped. + * + *
The memtable and write-ahead log are shared across every family, so + * flushing and WAL syncing live on {@link TidesDB} rather than here. * *
This class is not guaranteed to be thread-safe. */ public class ColumnFamily { - + static { NativeLibrary.load(); } - + private final long nativeHandle; private final String name; private long commitHookCtxHandle = 0; private final TidesDB owner; - - ColumnFamily(long nativeHandle, String name) { - this(nativeHandle, name, null); - } - + ColumnFamily(long nativeHandle, String name, TidesDB owner) { this.nativeHandle = nativeHandle; this.name = name; this.owner = owner; } - + /** * Checks that the owning database is open. Throws if the owner is closed. */ @@ -58,204 +56,192 @@ void checkOwnerOpen() { throw new IllegalStateException("TidesDB instance is closed"); } } - + + private long ownerHandle() { + checkOwnerOpen(); + return owner == null ? 0 : owner.getNativeHandle(); + } + /** - * Gets the name of this column family. + * Returns the name of this column family. * * @return the column family name, never {@code null} */ public String getName() { return name; } - + /** - * Retrieves statistics about this column family. + * Collects statistics for this column family. * - * @return column family statistics, never {@code null} - * @throws TidesDBException if the native stats retrieval fails + * @return the statistics, never {@code null} + * @throws IllegalStateException if the owning database is closed + * @throws TidesDBException if the native stats retrieval fails, including + * {@link TidesDBException#ERR_LOCKED} when descriptor pressure kept a + * level from being read */ - public Stats getStats() throws TidesDBException { + public CfStats getStats() throws TidesDBException { checkOwnerOpen(); return nativeGetStats(nativeHandle); } - + /** - * Manually triggers compaction for this column family. + * Estimates the distinct key count of this column family. * - * @throws TidesDBException if the native compaction fails + * @return the estimated distinct key count + * @throws IllegalStateException if the owning database is closed + * @throws TidesDBException if the estimate fails, including + * {@link TidesDBException#ERR_LOCKED} when descriptor pressure kept a + * level from being read */ - public void compact() throws TidesDBException { + public long estimateCardinality() throws TidesDBException { checkOwnerOpen(); - nativeCompact(nativeHandle); + return nativeEstimateCardinality(nativeHandle); } /** - * Synchronously compacts every SSTable whose key range overlaps {@code [startKey, endKey)}. - * Blocks the calling thread until the merge commits or fails - does not enqueue work - * onto the compaction thread pool. - * - *
A {@code null} or empty endpoint means unbounded on that side. Both endpoints - * being {@code null} or empty is rejected with {@link TidesDBException}; callers - * wanting full-CF compaction must use {@link #compact()}.
+ * Synchronously runs one forced compaction pass on this column family, + * merging even when no trigger is due. * - * @param startKey lower bound of the range, or {@code null}/empty for unbounded - * @param endKey upper bound (exclusive), or {@code null}/empty for unbounded - * @throws TidesDBException if the range is invalid, another compaction is running, - * or the merge fails + * @throws IllegalStateException if the owning database is closed + * @throws TidesDBException with {@link TidesDBException#ERR_LOCKED} if a + * compaction is already running, in which case the work asked for is + * usually already under way */ - public void compactRange(byte[] startKey, byte[] endKey) throws TidesDBException { - checkOwnerOpen(); - nativeCompactRange(nativeHandle, startKey, endKey); + public void compact() throws TidesDBException { + nativeCompact(ownerHandle(), nativeHandle); } - + /** - * Manually triggers a memtable flush for this column family. + * Synchronously compacts every SSTable overlapping + * {@code [startKey, endKey)}, merging toward the largest level affected. + * Blocks the calling thread until the merge commits or fails. * - * @throws TidesDBException if the native flush fails - */ - public void flushMemtable() throws TidesDBException { - checkOwnerOpen(); - nativeFlushMemtable(nativeHandle); - } - - /** - * Checks if a flush operation is currently in progress for this column family. + *A {@code null} or empty endpoint is unbounded on that side. Both being + * unbounded is rejected in favour of {@link #compact()}. * - * @return true if flushing is in progress + * @param startKey the range start, or {@code null}/empty for unbounded + * @param endKey the range end, or {@code null}/empty for unbounded + * @throws IllegalStateException if the owning database is closed + * @throws TidesDBException if the range is invalid, a compaction is already + * running, or the merge fails */ - public boolean isFlushing() { - checkOwnerOpen(); - return nativeIsFlushing(nativeHandle); + public void compactRange(byte[] startKey, byte[] endKey) throws TidesDBException { + nativeCompactRange(ownerHandle(), nativeHandle, startKey, endKey); } - + /** - * Checks if a compaction operation is currently in progress for this column family. + * Reports whether a compaction is in progress on this column family. * - * @return true if compaction is in progress + * @return {@code true} if compacting + * @throws IllegalStateException if the owning database is closed */ public boolean isCompacting() { checkOwnerOpen(); return nativeIsCompacting(nativeHandle); } - + /** - * Updates runtime-safe configuration settings for this column family. - * Configuration changes are applied to new operations only. - * - *
Updatable settings (safe to change at runtime):
- *The count is memtable-aware. A range small enough to walk is counted + * exactly and reports {@link RangeStats#isKeysExact()}; a wider one is + * estimated from SSTable metadata without walking, so the call stays cheap + * enough for plan time whatever the range covers. * - * @param keyA first key (bound of range) - * @param keyB second key (bound of range) - * @return estimated traversal cost (higher = more expensive), 0.0 if no overlapping data - * @throws TidesDBException if the estimation fails + * @param keyA the range start, inclusive; must not be {@code null} or empty + * @param keyB the range end, exclusive; must not be {@code null} or empty + * @return the range statistics, never {@code null} + * @throws IllegalArgumentException if either bound is {@code null} or empty + * @throws IllegalStateException if the owning database is closed + * @throws TidesDBException if the call fails, including + * {@link TidesDBException#ERR_LOCKED} if the layout moved mid-scan */ - public double rangeCost(byte[] keyA, byte[] keyB) throws TidesDBException { - checkOwnerOpen(); + public RangeStats rangeStats(byte[] keyA, byte[] keyB) throws TidesDBException { if (keyA == null || keyA.length == 0) { throw new IllegalArgumentException("keyA cannot be null or empty"); } if (keyB == null || keyB.length == 0) { throw new IllegalArgumentException("keyB cannot be null or empty"); } - return nativeRangeCost(nativeHandle, keyA, keyB); + return nativeRangeStats(ownerHandle(), nativeHandle, keyA, keyB); } - + /** - * Sets a commit hook (Change Data Capture) for this column family. - * The hook fires synchronously after every transaction commit, receiving the full - * batch of committed operations atomically. Keep the callback fast to avoid - * stalling writers. + * Sets a commit hook for this column family. The hook fires synchronously + * after every transaction commit, receiving the full batch of committed + * operations atomically. Keep the callback fast to avoid stalling writers. * *
Hooks are runtime-only and not persisted. After a database restart, - * hooks must be re-registered by the application.
+ * hooks must be re-registered by the application. * - * @param hook the commit hook callback + * @param hook the commit hook callback; must not be {@code null} + * @throws IllegalArgumentException if {@code hook} is {@code null} + * @throws IllegalStateException if the owning database is closed * @throws TidesDBException if the hook cannot be set */ public void setCommitHook(CommitHook hook) throws TidesDBException { - checkOwnerOpen(); if (hook == null) { throw new IllegalArgumentException("Hook cannot be null, use clearCommitHook() instead"); } + long dbHandle = ownerHandle(); boolean firstInstall = (commitHookCtxHandle == 0); - commitHookCtxHandle = nativeSetCommitHook(nativeHandle, hook, commitHookCtxHandle); + commitHookCtxHandle = nativeSetCommitHook(dbHandle, nativeHandle, hook, commitHookCtxHandle); if (firstInstall && owner != null) { owner.registerHookColumnFamily(this); } } - + /** - * Clears the commit hook for this column family. - * After this call, no further commit callbacks will fire. + * Clears the commit hook for this column family. After this call, no further + * commit callbacks fire. * + * @throws IllegalStateException if the owning database is closed * @throws TidesDBException if the hook cannot be cleared */ public void clearCommitHook() throws TidesDBException { - checkOwnerOpen(); - commitHookCtxHandle = nativeSetCommitHook(nativeHandle, null, commitHookCtxHandle); + long dbHandle = ownerHandle(); + commitHookCtxHandle = nativeSetCommitHook(dbHandle, nativeHandle, null, commitHookCtxHandle); if (owner != null) { owner.unregisterHookColumnFamily(this); } } - + /** * Best-effort hook detach during database close. The hook is removed from * the engine without caller interaction. Errors are swallowed. @@ -263,29 +249,44 @@ public void clearCommitHook() throws TidesDBException { void clearHookOnClose() { if (commitHookCtxHandle != 0) { try { - nativeSetCommitHook(nativeHandle, null, commitHookCtxHandle); + long dbHandle = owner == null ? 0 : owner.getNativeHandle(); + nativeSetCommitHook(dbHandle, nativeHandle, null, commitHookCtxHandle); } catch (TidesDBException ignored) { // Best-effort during shutdown. } commitHookCtxHandle = 0; } } - + long getNativeHandle() { return nativeHandle; } - - private static native Stats nativeGetStats(long handle) throws TidesDBException; - private static native void nativeCompact(long handle) throws TidesDBException; - private static native void nativeCompactRange(long handle, byte[] startKey, byte[] endKey) throws TidesDBException; - private static native void nativeFlushMemtable(long handle) throws TidesDBException; - private static native boolean nativeIsFlushing(long handle); - private static native boolean nativeIsCompacting(long handle); - private static native void nativeUpdateRuntimeConfig(long handle, long writeBufferSize, - int skipListMaxLevel, float skipListProbability, double bloomFPR, int indexSampleRatio, - int syncMode, long syncIntervalUs, boolean persistToDisk) throws TidesDBException; - private static native double nativeRangeCost(long handle, byte[] keyA, byte[] keyB) throws TidesDBException; - private static native long nativeSetCommitHook(long handle, CommitHook hook, long oldCtxHandle) throws TidesDBException; - private static native void nativePurge(long handle) throws TidesDBException; - private static native void nativeSyncWal(long handle) throws TidesDBException; + + @Override + public String toString() { + return "ColumnFamily{name='" + name + "'}"; + } + + private static native CfStats nativeGetStats(long cfHandle) throws TidesDBException; + + private static native long nativeEstimateCardinality(long cfHandle) throws TidesDBException; + + private static native void nativeCompact(long dbHandle, long cfHandle) throws TidesDBException; + + private static native void nativeCompactRange(long dbHandle, long cfHandle, byte[] startKey, + byte[] endKey) throws TidesDBException; + + private static native boolean nativeIsCompacting(long cfHandle); + + private static native void nativeUpdateRuntimeConfig(long dbHandle, long cfHandle, + long levelSizeRatio, int minLevels, int dividingLevelOffset, boolean keepValuesInline, + long btreeKlogBlockSize, int[] encodingPipeline, boolean enableBloomFilter, double bloomFpr, + int defaultIsolationLevel, int l1FileCountTrigger, double tombstoneDensityTrigger, + long tombstoneDensityMinEntries, boolean persistToDisk) throws TidesDBException; + + private static native RangeStats nativeRangeStats(long dbHandle, long cfHandle, byte[] keyA, + byte[] keyB) throws TidesDBException; + + private static native long nativeSetCommitHook(long dbHandle, long cfHandle, CommitHook hook, + long oldCtxHandle) throws TidesDBException; } diff --git a/src/main/java/com/tidesdb/ColumnFamilyConfig.java b/src/main/java/com/tidesdb/ColumnFamilyConfig.java index 809038f..828a90c 100644 --- a/src/main/java/com/tidesdb/ColumnFamilyConfig.java +++ b/src/main/java/com/tidesdb/ColumnFamilyConfig.java @@ -18,14 +18,19 @@ */ package com.tidesdb; +import java.util.Arrays; + /** - * Configuration for a column family. Use {@link #builder()} to construct a - * configuration with custom values, or {@link #defaultConfig()} to obtain a - * configuration with default settings. + * Per-column-family configuration. Every field is mutable at runtime via + * {@link ColumnFamily#updateRuntimeConfig(ColumnFamilyConfig, boolean)}, since + * keys are ordered byte-wise and SSTables are therefore always mergeable. + * + *The memtable, write-ahead log, and their sync and skip-list settings are + * database-level and live on {@link Config}, not here. * - *
Instances are immutable once built. Unlike {@link Config.Builder}, the - * {@link Builder#build()} method does not perform Java-side validation of the - * field values. + *
Use {@link #builder()} to construct a configuration with custom values, or + * {@link #defaultConfig()} to obtain one carrying the native library's own + * defaults. Instances are immutable once built. */ public class ColumnFamilyConfig { @@ -33,424 +38,328 @@ public class ColumnFamilyConfig { NativeLibrary.load(); } - private long writeBufferSize; - private long levelSizeRatio; - private int minLevels; - private int dividingLevelOffset; - private long klogValueThreshold; - private CompressionAlgorithm compressionAlgorithm; - private boolean enableBloomFilter; - private double bloomFPR; - private boolean enableBlockIndexes; - private int indexSampleRatio; - private int blockIndexPrefixLen; - private SyncMode syncMode; - private long syncIntervalUs; - private String comparatorName; - private int skipListMaxLevel; - private float skipListProbability; - private IsolationLevel defaultIsolationLevel; - private long minDiskSpace; - private int l1FileCountTrigger; - private int l0QueueStallThreshold; - private double tombstoneDensityTrigger; - private long tombstoneDensityMinEntries; - private boolean useBtree; - private boolean objectLazyCompaction; - private boolean objectPrefetchCompaction; + /** + * The longest a column family name may be, including its terminator. + */ + public static final int MAX_NAME_LENGTH = 128; + + /** + * The most stacked encodings a column family may apply. + */ + public static final int MAX_ENCODING_PIPELINE = 8; + + private final String name; + private final long levelSizeRatio; + private final int minLevels; + private final int dividingLevelOffset; + private final boolean keepValuesInline; + private final long btreeKlogBlockSize; + private final int[] encodingPipeline; + private final boolean enableBloomFilter; + private final double bloomFpr; + private final IsolationLevel defaultIsolationLevel; + private final int l1FileCountTrigger; + private final double tombstoneDensityTrigger; + private final long tombstoneDensityMinEntries; private ColumnFamilyConfig(Builder builder) { - this.writeBufferSize = builder.writeBufferSize; + this.name = builder.name; this.levelSizeRatio = builder.levelSizeRatio; this.minLevels = builder.minLevels; this.dividingLevelOffset = builder.dividingLevelOffset; - this.klogValueThreshold = builder.klogValueThreshold; - this.compressionAlgorithm = builder.compressionAlgorithm; + this.keepValuesInline = builder.keepValuesInline; + this.btreeKlogBlockSize = builder.btreeKlogBlockSize; + this.encodingPipeline = builder.encodingPipeline.clone(); this.enableBloomFilter = builder.enableBloomFilter; - this.bloomFPR = builder.bloomFPR; - this.enableBlockIndexes = builder.enableBlockIndexes; - this.indexSampleRatio = builder.indexSampleRatio; - this.blockIndexPrefixLen = builder.blockIndexPrefixLen; - this.syncMode = builder.syncMode; - this.syncIntervalUs = builder.syncIntervalUs; - this.comparatorName = builder.comparatorName; - this.skipListMaxLevel = builder.skipListMaxLevel; - this.skipListProbability = builder.skipListProbability; + this.bloomFpr = builder.bloomFpr; this.defaultIsolationLevel = builder.defaultIsolationLevel; - this.minDiskSpace = builder.minDiskSpace; this.l1FileCountTrigger = builder.l1FileCountTrigger; - this.l0QueueStallThreshold = builder.l0QueueStallThreshold; this.tombstoneDensityTrigger = builder.tombstoneDensityTrigger; this.tombstoneDensityMinEntries = builder.tombstoneDensityMinEntries; - this.useBtree = builder.useBtree; - this.objectLazyCompaction = builder.objectLazyCompaction; - this.objectPrefetchCompaction = builder.objectPrefetchCompaction; } /** - * Creates a default column family configuration. The tombstone density defaults - * are sourced from the underlying C library so that this binding tracks the - * engine's defaults automatically. + * Returns a configuration carrying the native library's own defaults, as + * returned by {@code tidesdb_default_column_family_config()}. Use + * {@link #toBuilder()} to adjust individual fields. * - * @return a new {@code ColumnFamilyConfig} with default values + * @return a new {@code ColumnFamilyConfig} holding the native defaults */ public static ColumnFamilyConfig defaultConfig() { - return new Builder() - .writeBufferSize(128 * 1024 * 1024) - .levelSizeRatio(10) - .minLevels(5) - .dividingLevelOffset(2) - .klogValueThreshold(512) - .compressionAlgorithm(CompressionAlgorithm.LZ4_COMPRESSION) - .enableBloomFilter(true) - .bloomFPR(0.01) - .enableBlockIndexes(true) - .indexSampleRatio(1) - .blockIndexPrefixLen(16) - .syncMode(SyncMode.SYNC_FULL) - .syncIntervalUs(1000000) - .comparatorName("") - .skipListMaxLevel(12) - .skipListProbability(0.25f) - .defaultIsolationLevel(IsolationLevel.READ_COMMITTED) - .minDiskSpace(100 * 1024 * 1024) - .l1FileCountTrigger(4) - .l0QueueStallThreshold(20) - .tombstoneDensityTrigger(nativeDefaultTombstoneDensityTrigger()) - .tombstoneDensityMinEntries(nativeDefaultTombstoneDensityMinEntries()) - .useBtree(false) - .objectLazyCompaction(false) - .objectPrefetchCompaction(true) - .build(); + return nativeDefaultConfig(); } /** - * Creates a new builder with default values. - * - * @return a new {@code Builder} + * Reads {@code tidesdb_default_column_family_config()}. */ - public static Builder builder() { - return new Builder(); - } + private static native ColumnFamilyConfig nativeDefaultConfig(); /** - * Constructs a ColumnFamilyConfig from raw native primitives. Used by the JNI - * layer when reading back the configuration embedded in tidesdb_stats_t. + * Assembles a configuration from the flat field list the JNI bridge reads + * out of {@code tidesdb_column_family_config_t}. Called from native code + * only. */ - static ColumnFamilyConfig fromNative(long writeBufferSize, long levelSizeRatio, int minLevels, - int dividingLevelOffset, long klogValueThreshold, - int compressionAlgorithm, boolean enableBloomFilter, - double bloomFPR, boolean enableBlockIndexes, - int indexSampleRatio, int blockIndexPrefixLen, - int syncMode, long syncIntervalUs, String comparatorName, - int skipListMaxLevel, float skipListProbability, - int defaultIsolationLevel, long minDiskSpace, - int l1FileCountTrigger, int l0QueueStallThreshold, + static ColumnFamilyConfig fromNative(String name, long levelSizeRatio, int minLevels, + int dividingLevelOffset, boolean keepValuesInline, + long btreeKlogBlockSize, int[] encodingPipeline, + boolean enableBloomFilter, double bloomFpr, + int defaultIsolationLevel, int l1FileCountTrigger, double tombstoneDensityTrigger, - long tombstoneDensityMinEntries, boolean useBtree, - boolean objectLazyCompaction, - boolean objectPrefetchCompaction) { + long tombstoneDensityMinEntries) { return new Builder() - .writeBufferSize(writeBufferSize) + .name(name) .levelSizeRatio(levelSizeRatio) .minLevels(minLevels) .dividingLevelOffset(dividingLevelOffset) - .klogValueThreshold(klogValueThreshold) - .compressionAlgorithm(CompressionAlgorithm.fromValue(compressionAlgorithm)) + .keepValuesInline(keepValuesInline) + .btreeKlogBlockSize(btreeKlogBlockSize) + .encodingPipelineIds(encodingPipeline) .enableBloomFilter(enableBloomFilter) - .bloomFPR(bloomFPR) - .enableBlockIndexes(enableBlockIndexes) - .indexSampleRatio(indexSampleRatio) - .blockIndexPrefixLen(blockIndexPrefixLen) - .syncMode(SyncMode.fromValue(syncMode)) - .syncIntervalUs(syncIntervalUs) - .comparatorName(comparatorName == null ? "" : comparatorName) - .skipListMaxLevel(skipListMaxLevel) - .skipListProbability(skipListProbability) + .bloomFpr(bloomFpr) .defaultIsolationLevel(IsolationLevel.fromValue(defaultIsolationLevel)) - .minDiskSpace(minDiskSpace) .l1FileCountTrigger(l1FileCountTrigger) - .l0QueueStallThreshold(l0QueueStallThreshold) .tombstoneDensityTrigger(tombstoneDensityTrigger) .tombstoneDensityMinEntries(tombstoneDensityMinEntries) - .useBtree(useBtree) - .objectLazyCompaction(objectLazyCompaction) - .objectPrefetchCompaction(objectPrefetchCompaction) .build(); } /** - * Returns the write-buffer (memtable) size in bytes. - * - * @return the write-buffer size in bytes - */ - public long getWriteBufferSize() { return writeBufferSize; } - - /** - * Returns the size ratio between adjacent LSM levels. + * Creates a new builder with the native library's defaults as its starting + * point. * - * @return the level size ratio - */ - public long getLevelSizeRatio() { return levelSizeRatio; } - - /** - * Returns the minimum number of LSM levels. - * - * @return the minimum level count - */ - public int getMinLevels() { return minLevels; } - - /** - * Returns the dividing level offset used for tiering decisions. - * - * @return the dividing level offset - */ - public int getDividingLevelOffset() { return dividingLevelOffset; } - - /** - * Returns the key-log value threshold in bytes. Values at or below this - * size are stored inline in the key log. - * - * @return the key-log value threshold in bytes + * @return a new {@code Builder} */ - public long getKlogValueThreshold() { return klogValueThreshold; } + public static Builder builder() { + return defaultConfig().toBuilder(); + } /** - * Returns the compression algorithm used for SSTables. + * Returns a builder pre-populated with this configuration's values. * - * @return the compression algorithm + * @return a new {@code Builder} carrying these values */ - public CompressionAlgorithm getCompressionAlgorithm() { return compressionAlgorithm; } + public Builder toBuilder() { + return new Builder() + .name(name) + .levelSizeRatio(levelSizeRatio) + .minLevels(minLevels) + .dividingLevelOffset(dividingLevelOffset) + .keepValuesInline(keepValuesInline) + .btreeKlogBlockSize(btreeKlogBlockSize) + .encodingPipelineIds(encodingPipeline) + .enableBloomFilter(enableBloomFilter) + .bloomFpr(bloomFpr) + .defaultIsolationLevel(defaultIsolationLevel) + .l1FileCountTrigger(l1FileCountTrigger) + .tombstoneDensityTrigger(tombstoneDensityTrigger) + .tombstoneDensityMinEntries(tombstoneDensityMinEntries); + } /** - * Returns whether Bloom filters are enabled for this column family. + * Returns the column family's persisted identity. Empty on a configuration + * you built yourself: the name passed to + * {@link TidesDB#createColumnFamily(String, ColumnFamilyConfig)} is + * authoritative and this field is ignored there. It carries the family's + * name on a configuration read back from {@link CfStats#getConfig()}. * - * @return {@code true} if Bloom filters are enabled + * @return the column family name, never {@code null} */ - public boolean isEnableBloomFilter() { return enableBloomFilter; } + public String getName() { + return name; + } /** - * Returns the Bloom filter false-positive rate. + * Returns the target size ratio between successive levels. * - * @return the false-positive rate + * @return the level size ratio */ - public double getBloomFPR() { return bloomFPR; } + public long getLevelSizeRatio() { + return levelSizeRatio; + } /** - * Returns whether block indexes are enabled for this column family. + * Returns the floor on the level count. The tree deepens as it fills and + * sheds levels again as data is deleted, and this is the depth it will not + * shed below. The engine keeps its own floor of a flush tier plus one level + * for merges to land in, so a smaller value has no further effect. * - * @return {@code true} if block indexes are enabled + * @return the minimum level count */ - public boolean isEnableBlockIndexes() { return enableBlockIndexes; } + public int getMinLevels() { + return minLevels; + } /** - * Returns the index sample ratio for block indexes. + * Returns how far above the largest level the dividing level sits, so 1 + * means X = L - 2. The dividing level is where a merge writes output + * partitioned to the largest level's file boundaries, which is what lets + * later merges take one group of overlapping files at a time. * - * @return the index sample ratio + * @return the dividing level offset */ - public int getIndexSampleRatio() { return indexSampleRatio; } + public int getDividingLevelOffset() { + return dividingLevelOffset; + } /** - * Returns the block index prefix length. + * Returns whether every value is held in the key log whatever its size, + * ignoring the database's {@link Config#getValueSeparationThreshold()}. * - * @return the block index prefix length - */ - public int getBlockIndexPrefixLen() { return blockIndexPrefixLen; } - - /** - * Returns the sync mode for durability control. + *
A separated value costs a scan one value-log read per row, so a family + * that is scanned far more than it is merged can be worth keeping whole even + * though its values are large. The cost is the one the threshold exists to + * avoid, that compaction rewrites those bytes on every merge. * - * @return the sync mode + * @return {@code true} when values stay inline regardless of size */ - public SyncMode getSyncMode() { return syncMode; } + public boolean isKeepValuesInline() { + return keepValuesInline; + } /** - * Returns the sync interval in microseconds. Only meaningful when - * {@code syncMode} is {@link SyncMode#SYNC_INTERVAL}. + * Returns the target size in bytes of a btree key-log node. Raise it + * alongside the database's {@link Config#getValueSeparationThreshold()} + * rather than on its own. * - * @return the sync interval in microseconds + * @return the block size in bytes, or 0 to leave the choice to the btree */ - public long getSyncIntervalUs() { return syncIntervalUs; } + public long getBtreeKlogBlockSize() { + return btreeKlogBlockSize; + } /** - * Returns the custom comparator name. An empty string indicates the - * default lexicographic comparator. + * Returns the encoding ids applied in order to btree key-log nodes and + * undone in reverse on read. Ids in the range of {@link CompressionAlgorithm} + * name a built-in compression codec. * - * @return the comparator name, or an empty string for the default + * @return a copy of the pipeline, empty when data is stored verbatim */ - public String getComparatorName() { return comparatorName; } + public int[] getEncodingPipeline() { + return encodingPipeline.clone(); + } /** - * Returns the maximum level for the skip-list memtable. + * Returns whether a partition-range filter is built for point-get pruning. * - * @return the skip-list maximum level + * @return {@code true} when the bloom filter is enabled */ - public int getSkipListMaxLevel() { return skipListMaxLevel; } + public boolean isEnableBloomFilter() { + return enableBloomFilter; + } /** - * Returns the probability parameter for skip-list level promotion. + * Returns the target bloom false-positive rate when the filter is enabled. * - * @return the skip-list probability + * @return the false-positive rate */ - public float getSkipListProbability() { return skipListProbability; } + public double getBloomFpr() { + return bloomFpr; + } /** - * Returns the default isolation level for transactions on this column - * family. + * Returns the isolation applied to a transaction opened against this family + * without an explicit level. * * @return the default isolation level */ - public IsolationLevel getDefaultIsolationLevel() { return defaultIsolationLevel; } - - /** - * Returns the minimum disk space in bytes required before writes are - * rejected. - * - * @return the minimum disk space in bytes - */ - public long getMinDiskSpace() { return minDiskSpace; } + public IsolationLevel getDefaultIsolationLevel() { + return defaultIsolationLevel; + } /** - * Returns the L1 file-count trigger for compaction. + * Returns the L1 SSTable count that triggers compaction. * * @return the L1 file count trigger */ - public int getL1FileCountTrigger() { return l1FileCountTrigger; } - - /** - * Returns the L0 queue stall threshold. When the L0 file count reaches - * this threshold, writes are stalled until compaction reduces the count. - * - * @return the L0 queue stall threshold - */ - public int getL0QueueStallThreshold() { return l0QueueStallThreshold; } - public double getTombstoneDensityTrigger() { return tombstoneDensityTrigger; } - public long getTombstoneDensityMinEntries() { return tombstoneDensityMinEntries; } - public boolean isUseBtree() { return useBtree; } - public boolean isObjectLazyCompaction() { return objectLazyCompaction; } - public boolean isObjectPrefetchCompaction() { return objectPrefetchCompaction; } + public int getL1FileCountTrigger() { + return l1FileCountTrigger; + } /** - * Saves this column family configuration to an INI file under the given section. - * If the file already exists it is overwritten. The written file can be read back - * with {@link #loadFromIni(String, String)}. - * - *
Note: not every field round-trips. The persisted fields are the ones the engine - * stores in a column family's {@code config.ini} (write buffer size, level ratios, - * compression, bloom/index settings, sync mode, skip list parameters, isolation level, - * compaction triggers, tombstone density, B+tree and object-store flags, and the - * comparator name). Runtime-only fields such as commit hooks are not persisted.
+ * Returns the ratio in [0, 1] above which an SSTable's tombstone density + * escalates compaction. * - * @param iniFile path to the INI file to write - * @param sectionName section name to write the configuration under - * @throws TidesDBException if the file cannot be written + * @return the density trigger, or 0 to disable it */ - public void saveToIni(String iniFile, String sectionName) throws TidesDBException { - if (iniFile == null || iniFile.isEmpty()) { - throw new IllegalArgumentException("INI file path cannot be null or empty"); - } - if (sectionName == null || sectionName.isEmpty()) { - throw new IllegalArgumentException("Section name cannot be null or empty"); - } - nativeSaveToIni(iniFile, sectionName, - writeBufferSize, levelSizeRatio, minLevels, dividingLevelOffset, klogValueThreshold, - compressionAlgorithm.getValue(), enableBloomFilter, bloomFPR, enableBlockIndexes, - indexSampleRatio, blockIndexPrefixLen, syncMode.getValue(), syncIntervalUs, - comparatorName, skipListMaxLevel, skipListProbability, - defaultIsolationLevel.getValue(), minDiskSpace, l1FileCountTrigger, - l0QueueStallThreshold, tombstoneDensityTrigger, tombstoneDensityMinEntries, - useBtree, objectLazyCompaction, objectPrefetchCompaction); + public double getTombstoneDensityTrigger() { + return tombstoneDensityTrigger; } /** - * Loads a column family configuration from an INI file section previously written by - * {@link #saveToIni(String, String)} (or produced by the engine for an existing column - * family). Fields absent from the section fall back to the engine defaults. + * Returns the minimum entry count for an SSTable to be judged by the density + * trigger, filtering tiny-SSTable noise. * - * @param iniFile path to the INI file to read - * @param sectionName section name to read the configuration from - * @return the loaded configuration - * @throws TidesDBException if the file cannot be read or the section is missing + * @return the minimum entry count, or 0 to impose no minimum */ - public static ColumnFamilyConfig loadFromIni(String iniFile, String sectionName) throws TidesDBException { - if (iniFile == null || iniFile.isEmpty()) { - throw new IllegalArgumentException("INI file path cannot be null or empty"); - } - if (sectionName == null || sectionName.isEmpty()) { - throw new IllegalArgumentException("Section name cannot be null or empty"); - } - return nativeLoadFromIni(iniFile, sectionName); + public long getTombstoneDensityMinEntries() { + return tombstoneDensityMinEntries; } - private static native double nativeDefaultTombstoneDensityTrigger(); - private static native long nativeDefaultTombstoneDensityMinEntries(); - private static native void nativeSaveToIni(String iniFile, String sectionName, - long writeBufferSize, long levelSizeRatio, int minLevels, int dividingLevelOffset, - long klogValueThreshold, int compressionAlgorithm, boolean enableBloomFilter, - double bloomFPR, boolean enableBlockIndexes, int indexSampleRatio, int blockIndexPrefixLen, - int syncMode, long syncIntervalUs, String comparatorName, int skipListMaxLevel, - float skipListProbability, int defaultIsolationLevel, long minDiskSpace, - int l1FileCountTrigger, int l0QueueStallThreshold, double tombstoneDensityTrigger, - long tombstoneDensityMinEntries, boolean useBtree, boolean objectLazyCompaction, - boolean objectPrefetchCompaction) throws TidesDBException; - private static native ColumnFamilyConfig nativeLoadFromIni(String iniFile, String sectionName) throws TidesDBException; + @Override + public String toString() { + return "ColumnFamilyConfig{" + + "name='" + name + '\'' + + ", levelSizeRatio=" + levelSizeRatio + + ", minLevels=" + minLevels + + ", dividingLevelOffset=" + dividingLevelOffset + + ", keepValuesInline=" + keepValuesInline + + ", btreeKlogBlockSize=" + btreeKlogBlockSize + + ", encodingPipeline=" + Arrays.toString(encodingPipeline) + + ", enableBloomFilter=" + enableBloomFilter + + ", bloomFpr=" + bloomFpr + + ", defaultIsolationLevel=" + defaultIsolationLevel + + ", l1FileCountTrigger=" + l1FileCountTrigger + + ", tombstoneDensityTrigger=" + tombstoneDensityTrigger + + ", tombstoneDensityMinEntries=" + tombstoneDensityMinEntries + + '}'; + } /** - * Builder for {@link ColumnFamilyConfig}. All fields have sensible - * defaults matching {@link #defaultConfig()}. Call {@link #build()} to - * create the immutable configuration. - * - *Unlike {@link Config.Builder}, the {@link #build()} method does not - * perform Java-side validation of field values. + * Builder for {@link ColumnFamilyConfig}. Call {@link #build()} to create + * the immutable configuration; {@code build()} validates all fields. */ public static class Builder { - private long writeBufferSize = 128 * 1024 * 1024; + + private String name = ""; + private long levelSizeRatio = 0; + private int minLevels = 0; + private int dividingLevelOffset = 0; + private boolean keepValuesInline = false; + private long btreeKlogBlockSize = 0; + private int[] encodingPipeline = new int[0]; + private boolean enableBloomFilter = false; + private double bloomFpr = 0.0; + private IsolationLevel defaultIsolationLevel = IsolationLevel.READ_COMMITTED; + private int l1FileCountTrigger = 0; + private double tombstoneDensityTrigger = 0.0; + private long tombstoneDensityMinEntries = 0; /** - * Creates a new builder with default values. + * Creates a new builder with zeroed values. Prefer + * {@link ColumnFamilyConfig#builder()}, which starts from the native + * library's defaults. */ public Builder() { } - private long levelSizeRatio = 10; - private int minLevels = 5; - private int dividingLevelOffset = 2; - private long klogValueThreshold = 512; - private CompressionAlgorithm compressionAlgorithm = CompressionAlgorithm.LZ4_COMPRESSION; - private boolean enableBloomFilter = true; - private double bloomFPR = 0.01; - private boolean enableBlockIndexes = true; - private int indexSampleRatio = 1; - private int blockIndexPrefixLen = 16; - private SyncMode syncMode = SyncMode.SYNC_FULL; - private long syncIntervalUs = 1000000; - private String comparatorName = ""; - private int skipListMaxLevel = 12; - private float skipListProbability = 0.25f; - private IsolationLevel defaultIsolationLevel = IsolationLevel.READ_COMMITTED; - private long minDiskSpace = 100 * 1024 * 1024; - private int l1FileCountTrigger = 4; - private int l0QueueStallThreshold = 20; - private double tombstoneDensityTrigger = 0.0; - private long tombstoneDensityMinEntries = 1024; - private boolean useBtree = false; - private boolean objectLazyCompaction = false; - private boolean objectPrefetchCompaction = true; /** - * Sets the write-buffer (memtable) size in bytes. + * Sets the column family name. Ignored by + * {@link TidesDB#createColumnFamily(String, ColumnFamilyConfig)} and by + * {@link ColumnFamily#updateRuntimeConfig(ColumnFamilyConfig, boolean)}, + * both of which take the name from elsewhere. * - * @param writeBufferSize the size in bytes + * @param name the name; {@code null} is treated as empty * @return this builder */ - public Builder writeBufferSize(long writeBufferSize) { - this.writeBufferSize = writeBufferSize; + public Builder name(String name) { + this.name = name == null ? "" : name; return this; } /** - * Sets the size ratio between adjacent LSM levels. + * Sets the target size ratio between successive levels. * - * @param levelSizeRatio the level size ratio + * @param levelSizeRatio the ratio * @return this builder */ public Builder levelSizeRatio(long levelSizeRatio) { @@ -459,7 +368,7 @@ public Builder levelSizeRatio(long levelSizeRatio) { } /** - * Sets the minimum number of LSM levels. + * Sets the floor on the level count. * * @param minLevels the minimum level count * @return this builder @@ -470,7 +379,7 @@ public Builder minLevels(int minLevels) { } /** - * Sets the dividing level offset for tiering decisions. + * Sets how far above the largest level the dividing level sits. * * @param dividingLevelOffset the offset * @return this builder @@ -481,146 +390,110 @@ public Builder dividingLevelOffset(int dividingLevelOffset) { } /** - * Sets the key-log value threshold in bytes. - * - * @param klogValueThreshold the threshold in bytes - * @return this builder - */ - public Builder klogValueThreshold(long klogValueThreshold) { - this.klogValueThreshold = klogValueThreshold; - return this; - } - - /** - * Sets the compression algorithm for SSTables. - * - * @param compressionAlgorithm the compression algorithm; must not be - * {@code null} - * @return this builder - */ - public Builder compressionAlgorithm(CompressionAlgorithm compressionAlgorithm) { - this.compressionAlgorithm = compressionAlgorithm; - return this; - } - - /** - * Enables or disables Bloom filters. - * - * @param enableBloomFilter {@code true} to enable - * @return this builder - */ - public Builder enableBloomFilter(boolean enableBloomFilter) { - this.enableBloomFilter = enableBloomFilter; - return this; - } - - /** - * Sets the Bloom filter false-positive rate. - * - * @param bloomFPR the false-positive rate - * @return this builder - */ - public Builder bloomFPR(double bloomFPR) { - this.bloomFPR = bloomFPR; - return this; - } - - /** - * Enables or disables block indexes. - * - * @param enableBlockIndexes {@code true} to enable - * @return this builder - */ - public Builder enableBlockIndexes(boolean enableBlockIndexes) { - this.enableBlockIndexes = enableBlockIndexes; - return this; - } - - /** - * Sets the index sample ratio for block indexes. + * Sets whether every value is held in the key log whatever its size. * - * @param indexSampleRatio the sample ratio + * @param keepValuesInline {@code true} to keep values inline * @return this builder */ - public Builder indexSampleRatio(int indexSampleRatio) { - this.indexSampleRatio = indexSampleRatio; + public Builder keepValuesInline(boolean keepValuesInline) { + this.keepValuesInline = keepValuesInline; return this; } /** - * Sets the block index prefix length. + * Sets the target size of a btree key-log node. * - * @param blockIndexPrefixLen the prefix length + * @param btreeKlogBlockSize the size in bytes, or 0 to leave the choice + * to the btree * @return this builder */ - public Builder blockIndexPrefixLen(int blockIndexPrefixLen) { - this.blockIndexPrefixLen = blockIndexPrefixLen; + public Builder btreeKlogBlockSize(long btreeKlogBlockSize) { + this.btreeKlogBlockSize = btreeKlogBlockSize; return this; } /** - * Sets the sync mode for durability control. + * Sets the encoding pipeline from built-in compression algorithms, + * applied in the order given. * - * @param syncMode the sync mode; must not be {@code null} + * @param algorithms the algorithms; an empty list stores data verbatim * @return this builder */ - public Builder syncMode(SyncMode syncMode) { - this.syncMode = syncMode; + public Builder encodingPipeline(CompressionAlgorithm... algorithms) { + if (algorithms == null) { + this.encodingPipeline = new int[0]; + return this; + } + int[] ids = new int[algorithms.length]; + for (int i = 0; i < algorithms.length; i++) { + if (algorithms[i] == null) { + throw new IllegalArgumentException("Encoding pipeline entry cannot be null"); + } + ids[i] = algorithms[i].getValue(); + } + this.encodingPipeline = ids; return this; } /** - * Sets the sync interval in microseconds. Only meaningful when - * {@code syncMode} is {@link SyncMode#SYNC_INTERVAL}. + * Sets the encoding pipeline from raw encoding ids, applied in the order + * given. Use this for an encoding the {@link CompressionAlgorithm} enum + * does not name. * - * @param syncIntervalUs the interval in microseconds + * @param ids the encoding ids, each in [0, 255]; an empty array stores + * data verbatim * @return this builder */ - public Builder syncIntervalUs(long syncIntervalUs) { - this.syncIntervalUs = syncIntervalUs; + public Builder encodingPipelineIds(int... ids) { + this.encodingPipeline = ids == null ? new int[0] : ids.clone(); return this; } /** - * Sets the custom comparator name. An empty string selects the - * default lexicographic comparator. + * Sets a single-codec encoding pipeline, the common case. Passing + * {@link CompressionAlgorithm#NONE} clears the pipeline so + * data is stored verbatim. * - * @param comparatorName the comparator name + * @param algorithm the algorithm; must not be {@code null} * @return this builder */ - public Builder comparatorName(String comparatorName) { - this.comparatorName = comparatorName; - return this; + public Builder compression(CompressionAlgorithm algorithm) { + if (algorithm == null) { + throw new IllegalArgumentException("Compression algorithm cannot be null"); + } + if (algorithm == CompressionAlgorithm.NONE) { + return encodingPipelineIds(); + } + return encodingPipeline(algorithm); } /** - * Sets the maximum level for the skip-list memtable. + * Sets whether a partition-range filter is built for point-get pruning. * - * @param skipListMaxLevel the maximum level + * @param enableBloomFilter {@code true} to enable the filter * @return this builder */ - public Builder skipListMaxLevel(int skipListMaxLevel) { - this.skipListMaxLevel = skipListMaxLevel; + public Builder enableBloomFilter(boolean enableBloomFilter) { + this.enableBloomFilter = enableBloomFilter; return this; } /** - * Sets the probability parameter for skip-list level promotion. + * Sets the target bloom false-positive rate. * - * @param skipListProbability the promotion probability + * @param bloomFpr the rate, in (0.0, 1.0) when the filter is enabled * @return this builder */ - public Builder skipListProbability(float skipListProbability) { - this.skipListProbability = skipListProbability; + public Builder bloomFpr(double bloomFpr) { + this.bloomFpr = bloomFpr; return this; } /** - * Sets the default isolation level for transactions on this column - * family. + * Sets the isolation applied to a transaction opened against this family + * without an explicit level. * - * @param defaultIsolationLevel the isolation level; must not be - * {@code null} + * @param defaultIsolationLevel the level; must not be {@code null} * @return this builder */ public Builder defaultIsolationLevel(IsolationLevel defaultIsolationLevel) { @@ -629,21 +502,9 @@ public Builder defaultIsolationLevel(IsolationLevel defaultIsolationLevel) { } /** - * Sets the minimum disk space in bytes required before writes are - * rejected. - * - * @param minDiskSpace the minimum disk space in bytes - * @return this builder - */ - public Builder minDiskSpace(long minDiskSpace) { - this.minDiskSpace = minDiskSpace; - return this; - } - - /** - * Sets the L1 file-count trigger for compaction. + * Sets the L1 SSTable count that triggers compaction. * - * @param l1FileCountTrigger the file count trigger + * @param l1FileCountTrigger the trigger count * @return this builder */ public Builder l1FileCountTrigger(int l1FileCountTrigger) { @@ -652,47 +513,34 @@ public Builder l1FileCountTrigger(int l1FileCountTrigger) { } /** - * Sets the L0 queue stall threshold. When the L0 file count reaches - * this threshold, writes are stalled until compaction reduces the count. + * Sets the tombstone density above which compaction is escalated. * - * @param l0QueueStallThreshold the stall threshold + * @param tombstoneDensityTrigger the ratio in [0.0, 1.0], or 0 to disable * @return this builder */ - public Builder l0QueueStallThreshold(int l0QueueStallThreshold) { - this.l0QueueStallThreshold = l0QueueStallThreshold; - return this; - } - public Builder tombstoneDensityTrigger(double tombstoneDensityTrigger) { this.tombstoneDensityTrigger = tombstoneDensityTrigger; return this; } + /** + * Sets the minimum entry count for an SSTable to be judged by the + * density trigger. + * + * @param tombstoneDensityMinEntries the minimum, or 0 for no minimum + * @return this builder + */ public Builder tombstoneDensityMinEntries(long tombstoneDensityMinEntries) { this.tombstoneDensityMinEntries = tombstoneDensityMinEntries; return this; } - public Builder useBtree(boolean useBtree) { - this.useBtree = useBtree; - return this; - } - - public Builder objectLazyCompaction(boolean objectLazyCompaction) { - this.objectLazyCompaction = objectLazyCompaction; - return this; - } - - public Builder objectPrefetchCompaction(boolean objectPrefetchCompaction) { - this.objectPrefetchCompaction = objectPrefetchCompaction; - return this; - } - /** - * Creates the immutable {@link ColumnFamilyConfig}. No Java-side - * validation of field values is performed. + * Validates all fields and creates the immutable + * {@link ColumnFamilyConfig}. * * @return a new {@code ColumnFamilyConfig} + * @throws IllegalArgumentException if any field is invalid */ public ColumnFamilyConfig build() { validate(); @@ -700,69 +548,60 @@ public ColumnFamilyConfig build() { } private void validate() { - // Nullable-enum checks (do first, before field-level checks) - if (compressionAlgorithm == null) { - throw new IllegalArgumentException("compressionAlgorithm must not be null"); - } - if (syncMode == null) { - throw new IllegalArgumentException("syncMode must not be null"); - } - if (defaultIsolationLevel == null) { - throw new IllegalArgumentException("defaultIsolationLevel must not be null"); - } - - // Non-negative (zero sentinel OK) - if (klogValueThreshold < 0) { - throw new IllegalArgumentException("klogValueThreshold must not be negative, was: " + klogValueThreshold); + if (name.length() >= MAX_NAME_LENGTH) { + throw new IllegalArgumentException( + "name must be shorter than " + MAX_NAME_LENGTH + " characters, was: " + + name.length()); } - if (syncIntervalUs < 0) { - throw new IllegalArgumentException("syncIntervalUs must not be negative, was: " + syncIntervalUs); + if (levelSizeRatio < 0) { + throw new IllegalArgumentException( + "levelSizeRatio must not be negative, was: " + levelSizeRatio); } - if (minDiskSpace < 0) { - throw new IllegalArgumentException("minDiskSpace must not be negative, was: " + minDiskSpace); + if (minLevels < 0) { + throw new IllegalArgumentException( + "minLevels must not be negative, was: " + minLevels); } if (dividingLevelOffset < 0) { - throw new IllegalArgumentException("dividingLevelOffset must not be negative, was: " + dividingLevelOffset); - } - if (blockIndexPrefixLen < 0) { - throw new IllegalArgumentException("blockIndexPrefixLen must not be negative, was: " + blockIndexPrefixLen); - } - if (skipListMaxLevel < 0) { - throw new IllegalArgumentException("skipListMaxLevel must not be negative, was: " + skipListMaxLevel); + throw new IllegalArgumentException( + "dividingLevelOffset must not be negative, was: " + dividingLevelOffset); } - - // Positive-required (zero rejected) - if (writeBufferSize <= 0) { - throw new IllegalArgumentException("writeBufferSize must be positive, was: " + writeBufferSize); - } - if (levelSizeRatio <= 0) { - throw new IllegalArgumentException("levelSizeRatio must be positive, was: " + levelSizeRatio); - } - if (minLevels <= 0) { - throw new IllegalArgumentException("minLevels must be positive, was: " + minLevels); + if (btreeKlogBlockSize < 0) { + throw new IllegalArgumentException( + "btreeKlogBlockSize must not be negative, was: " + btreeKlogBlockSize); } - if (indexSampleRatio <= 0) { - throw new IllegalArgumentException("indexSampleRatio must be positive, was: " + indexSampleRatio); + if (encodingPipeline.length > MAX_ENCODING_PIPELINE) { + throw new IllegalArgumentException( + "encodingPipeline must hold at most " + MAX_ENCODING_PIPELINE + + " entries, was: " + encodingPipeline.length); } - if (l1FileCountTrigger <= 0) { - throw new IllegalArgumentException("l1FileCountTrigger must be positive, was: " + l1FileCountTrigger); + for (int id : encodingPipeline) { + if (id < 0 || id > 255) { + throw new IllegalArgumentException( + "encoding id must be in [0, 255], was: " + id); + } } - if (l0QueueStallThreshold <= 0) { - throw new IllegalArgumentException("l0QueueStallThreshold must be positive, was: " + l0QueueStallThreshold); + if (Double.isNaN(bloomFpr) || Double.isInfinite(bloomFpr) + || bloomFpr < 0.0 || bloomFpr >= 1.0) { + throw new IllegalArgumentException( + "bloomFpr must be finite and in [0.0, 1.0), was: " + bloomFpr); } - if (tombstoneDensityMinEntries <= 0) { - throw new IllegalArgumentException("tombstoneDensityMinEntries must be positive, was: " + tombstoneDensityMinEntries); + if (defaultIsolationLevel == null) { + throw new IllegalArgumentException("defaultIsolationLevel cannot be null"); } - - // Float/double range and finiteness - if (Double.isNaN(bloomFPR) || Double.isInfinite(bloomFPR) || bloomFPR < 0.0 || bloomFPR > 1.0) { - throw new IllegalArgumentException("bloomFPR must be finite and in [0.0, 1.0], was: " + bloomFPR); + if (l1FileCountTrigger < 0) { + throw new IllegalArgumentException( + "l1FileCountTrigger must not be negative, was: " + l1FileCountTrigger); } - if (Float.isNaN(skipListProbability) || Float.isInfinite(skipListProbability) || skipListProbability < 0.0f || skipListProbability > 1.0f) { - throw new IllegalArgumentException("skipListProbability must be finite and in [0.0, 1.0], was: " + skipListProbability); + if (Double.isNaN(tombstoneDensityTrigger) || Double.isInfinite(tombstoneDensityTrigger) + || tombstoneDensityTrigger < 0.0 || tombstoneDensityTrigger > 1.0) { + throw new IllegalArgumentException( + "tombstoneDensityTrigger must be finite and in [0.0, 1.0], was: " + + tombstoneDensityTrigger); } - if (Double.isNaN(tombstoneDensityTrigger) || Double.isInfinite(tombstoneDensityTrigger) || tombstoneDensityTrigger < 0.0 || tombstoneDensityTrigger > 1.0) { - throw new IllegalArgumentException("tombstoneDensityTrigger must be finite and in [0.0, 1.0], was: " + tombstoneDensityTrigger); + if (tombstoneDensityMinEntries < 0) { + throw new IllegalArgumentException( + "tombstoneDensityMinEntries must not be negative, was: " + + tombstoneDensityMinEntries); } } } diff --git a/src/main/java/com/tidesdb/CompressionAlgorithm.java b/src/main/java/com/tidesdb/CompressionAlgorithm.java index 1d2f58f..5c445f2 100644 --- a/src/main/java/com/tidesdb/CompressionAlgorithm.java +++ b/src/main/java/com/tidesdb/CompressionAlgorithm.java @@ -19,59 +19,68 @@ package com.tidesdb; /** - * Compression algorithm for column family SSTables. Each constant maps to an - * integer used by the JNI bridge. + * A built-in compression codec, usable as an entry in a column family's encoding + * pipeline. Each constant's {@link #getValue()} is its encoding id, which is + * persisted in SSTable and value-log metadata. + * + *
Every constant is always defined, but whether a backend can actually be + * used is a build-time choice of the native library. Ask + * {@link TidesDB#isCompressionAvailable(CompressionAlgorithm)} before choosing + * one, rather than discovering it when a node fails to decode. {@link #NONE} is + * always available. */ public enum CompressionAlgorithm { /** - * No compression. + * No compression; data is stored verbatim. */ - NO_COMPRESSION(0), + NONE(0), /** * Snappy compression. */ - SNAPPY_COMPRESSION(1), + SNAPPY(1), /** * LZ4 compression with default settings. */ - LZ4_COMPRESSION(2), + LZ4(2), /** * Zstandard compression. */ - ZSTD_COMPRESSION(3), + ZSTD(3), /** - * LZ4 compression optimized for speed. + * LZ4 compression optimised for speed. */ - LZ4_FAST_COMPRESSION(4); - + LZ4_FAST(4); + private final int value; - + CompressionAlgorithm(int value) { this.value = value; } - + /** - * Returns the JNI numeric mapping for this compression algorithm. + * Returns this codec's encoding id, the value stored in SSTable and + * value-log metadata and accepted by + * {@link ColumnFamilyConfig.Builder#encodingPipelineIds(int...)}. * - * @return the integer value passed to the native library + * @return the encoding id */ public int getValue() { return value; } - + /** * Returns the {@link CompressionAlgorithm} constant matching the given - * JNI integer value. + * encoding id. * - * @param value the JNI integer value + * @param value the encoding id * @return the matching constant * @throws IllegalArgumentException if {@code value} does not map to any - * known constant + * built-in codec */ public static CompressionAlgorithm fromValue(int value) { for (CompressionAlgorithm algo : values()) { diff --git a/src/main/java/com/tidesdb/Config.java b/src/main/java/com/tidesdb/Config.java index 19c8173..3618240 100644 --- a/src/main/java/com/tidesdb/Config.java +++ b/src/main/java/com/tidesdb/Config.java @@ -19,12 +19,25 @@ package com.tidesdb; /** - * Configuration for opening a {@link TidesDB} instance. Use {@link #builder(String)} - * to construct a configuration with custom values, or {@link #defaultConfig()} to - * obtain a configuration with default settings. + * Database-level configuration for opening a {@link TidesDB} instance. The + * memtable, write-ahead log, block cache, value log, and worker pool are + * database-level and shared by every column family, so their settings live here + * rather than on {@link ColumnFamilyConfig}. * - *
Instances are immutable once built. {@link Builder#build()} validates all fields - * and throws {@link IllegalArgumentException} for invalid values. + *
Use {@link #builder(String)} to construct a configuration with custom + * values, or {@link #defaultConfig(String)} to obtain one carrying the native + * library's own defaults. + * + *
Instances are immutable once built. {@link Builder#build()} validates all + * fields and throws {@link IllegalArgumentException} for invalid values. + * + *
A field left at zero is resolved to its default by the engine rather than + * taken literally, for {@code numFlushThreads}, {@code numCompactionThreads}, + * {@code blockCacheSize}, {@code maxOpenSSTables}, {@code vlogSegmentSize}, + * {@code memtableWriteBufferSize}, {@code memtableSkipListMaxLevel} and + * {@code memtableSkipListProbability}. The rest are used as given, + * {@code memtableSyncMode} included, where zero is the meaningful value + * {@link SyncMode#SYNC_NONE}. */ public class Config { @@ -32,26 +45,24 @@ public class Config { NativeLibrary.load(); } - private String dbPath; - private int numFlushThreads; - private int numCompactionThreads; - private LogLevel logLevel; - private long blockCacheSize; - private long maxOpenSSTables; - private boolean logToFile; - private long logTruncationAt; - private long maxMemoryUsage; - private boolean unifiedMemtable; - private long unifiedMemtableWriteBufferSize; - private int unifiedMemtableSkipListMaxLevel; - private float unifiedMemtableSkipListProbability; - private int unifiedMemtableSyncMode; - private long unifiedMemtableSyncIntervalUs; - private String objectStoreFsPath; - private ObjectStoreConfig objectStoreConfig; - private S3Config objectStoreS3Config; - private int maxConcurrentFlushes; - private boolean finishCompactionsOnClose; + private final String dbPath; + private final int numFlushThreads; + private final int numCompactionThreads; + private final LogLevel logLevel; + private final long blockCacheSize; + private final long maxOpenSSTables; + private final boolean logToFile; + private final long logTruncationAt; + private final long memtableWriteBufferSize; + private final int memtableSkipListMaxLevel; + private final float memtableSkipListProbability; + private final SyncMode memtableSyncMode; + private final long memtableSyncIntervalUs; + private final long valueSeparationThreshold; + private final long vlogSegmentSize; + private final int memtableL0QueueStallThreshold; + private final int memtableIdleFlushSeconds; + private final long txnTimeoutSeconds; private Config(Builder builder) { this.dbPath = builder.dbPath; @@ -62,52 +73,76 @@ private Config(Builder builder) { this.maxOpenSSTables = builder.maxOpenSSTables; this.logToFile = builder.logToFile; this.logTruncationAt = builder.logTruncationAt; - this.maxMemoryUsage = builder.maxMemoryUsage; - this.unifiedMemtable = builder.unifiedMemtable; - this.unifiedMemtableWriteBufferSize = builder.unifiedMemtableWriteBufferSize; - this.unifiedMemtableSkipListMaxLevel = builder.unifiedMemtableSkipListMaxLevel; - this.unifiedMemtableSkipListProbability = builder.unifiedMemtableSkipListProbability; - this.unifiedMemtableSyncMode = builder.unifiedMemtableSyncMode; - this.unifiedMemtableSyncIntervalUs = builder.unifiedMemtableSyncIntervalUs; - this.objectStoreFsPath = builder.objectStoreFsPath; - this.objectStoreConfig = builder.objectStoreConfig; - this.objectStoreS3Config = builder.objectStoreS3Config; - this.maxConcurrentFlushes = builder.maxConcurrentFlushes; - this.finishCompactionsOnClose = builder.finishCompactionsOnClose; + this.memtableWriteBufferSize = builder.memtableWriteBufferSize; + this.memtableSkipListMaxLevel = builder.memtableSkipListMaxLevel; + this.memtableSkipListProbability = builder.memtableSkipListProbability; + this.memtableSyncMode = builder.memtableSyncMode; + this.memtableSyncIntervalUs = builder.memtableSyncIntervalUs; + this.valueSeparationThreshold = builder.valueSeparationThreshold; + this.vlogSegmentSize = builder.vlogSegmentSize; + this.memtableL0QueueStallThreshold = builder.memtableL0QueueStallThreshold; + this.memtableIdleFlushSeconds = builder.memtableIdleFlushSeconds; + this.txnTimeoutSeconds = builder.txnTimeoutSeconds; } /** - * Creates a default configuration with the following values: - *
Keep it at or under a quarter of a family's + * {@link ColumnFamilyConfig#getBtreeKlogBlockSize()}: an inlined value + * approaching the node size leaves a node holding one entry and spends the + * btree fan-out that makes a lookup cheap. The pairing is advisory, and a + * config that breaks it only logs a warning. + * + * @return the threshold in bytes, or 0 for the engine default + */ + public long getValueSeparationThreshold() { + return valueSeparationThreshold; } - public ObjectStoreConfig getObjectStoreConfig() { - return objectStoreConfig; + /** + * Returns the size at which the value log seals its active segment and opens + * a fresh one. A reclaim drains every segment worth draining, so this does + * not change how much space the store settles at; it changes what reclaiming + * costs. + * + * @return the segment size in bytes, or 0 for the engine default + */ + public long getVlogSegmentSize() { + return vlogSegmentSize; } /** - * Returns the S3-compatible object store connector configuration, or null if the database - * is not backed by S3. + * Returns the immutable-queue depth at which writes stall for backpressure. + * Left at 0 the queue is unbounded and a writer outrunning the flush threads + * is never paced, so it is a value to set deliberately rather than leave. * - * @return the S3 connector config, or null + * @return the stall threshold, or 0 to never stall */ - public S3Config getObjectStoreS3Config() { - return objectStoreS3Config; + public int getMemtableL0QueueStallThreshold() { + return memtableL0QueueStallThreshold; } - public int getMaxConcurrentFlushes() { - return maxConcurrentFlushes; + /** + * Returns how long the active memtable may sit unwritten before the engine + * rotates it on its own. A database that stops taking writes otherwise holds + * that data in memory indefinitely. + * + * @return the idle flush interval in seconds, or 0 to never rotate on idle + */ + public int getMemtableIdleFlushSeconds() { + return memtableIdleFlushSeconds; } /** - * Returns the close behavior for in-flight compactions. + * Returns how long a transaction may stay active before the next operation + * on it expires it. An abandoned transaction holds its snapshot and its + * write reservations, which keeps the reclamation floor down and stops + * compaction dropping old versions. * - * @return true if {@code close()} waits for in-flight compactions to finish; - * false (default) cancels them at their next checkpoint for a fast shutdown + * @return the timeout in seconds, or 0 for no timeout */ - public boolean isFinishCompactionsOnClose() { - return finishCompactionsOnClose; + public long getTxnTimeoutSeconds() { + return txnTimeoutSeconds; + } + + @Override + public String toString() { + return "Config{" + + "dbPath='" + dbPath + '\'' + + ", numFlushThreads=" + numFlushThreads + + ", numCompactionThreads=" + numCompactionThreads + + ", logLevel=" + logLevel + + ", blockCacheSize=" + blockCacheSize + + ", maxOpenSSTables=" + maxOpenSSTables + + ", logToFile=" + logToFile + + ", logTruncationAt=" + logTruncationAt + + ", memtableWriteBufferSize=" + memtableWriteBufferSize + + ", memtableSkipListMaxLevel=" + memtableSkipListMaxLevel + + ", memtableSkipListProbability=" + memtableSkipListProbability + + ", memtableSyncMode=" + memtableSyncMode + + ", memtableSyncIntervalUs=" + memtableSyncIntervalUs + + ", valueSeparationThreshold=" + valueSeparationThreshold + + ", vlogSegmentSize=" + vlogSegmentSize + + ", memtableL0QueueStallThreshold=" + memtableL0QueueStallThreshold + + ", memtableIdleFlushSeconds=" + memtableIdleFlushSeconds + + ", txnTimeoutSeconds=" + txnTimeoutSeconds + + '}'; } /** - * Builder for {@link Config}. All fields have sensible defaults. Call + * Builder for {@link Config}. Every field starts at zero, which the engine + * resolves to its own default where {@link Config} says so. Call * {@link #build()} to create the immutable configuration; {@code build()} * validates all fields. */ public static class Builder { + private String dbPath = ""; + private int numFlushThreads = 0; + private int numCompactionThreads = 0; + private LogLevel logLevel = LogLevel.INFO; + private long blockCacheSize = 0; + private long maxOpenSSTables = 0; + private boolean logToFile = false; + private long logTruncationAt = 0; + private long memtableWriteBufferSize = 0; + private int memtableSkipListMaxLevel = 0; + private float memtableSkipListProbability = 0.0f; + private SyncMode memtableSyncMode = SyncMode.SYNC_NONE; + private long memtableSyncIntervalUs = 0; + private long valueSeparationThreshold = 0; + private long vlogSegmentSize = 0; + private int memtableL0QueueStallThreshold = 0; + private int memtableIdleFlushSeconds = 0; + private long txnTimeoutSeconds = 0; /** * Creates a new builder with default values. */ public Builder() { } - private int numFlushThreads = 2; - private int numCompactionThreads = 2; - private LogLevel logLevel = LogLevel.INFO; - private long blockCacheSize = 64 * 1024 * 1024; - private long maxOpenSSTables = 256; - private boolean logToFile = false; - private long logTruncationAt = 24 * 1024 * 1024; - private long maxMemoryUsage = 0; - private boolean unifiedMemtable = false; - private long unifiedMemtableWriteBufferSize = 0; - private int unifiedMemtableSkipListMaxLevel = 0; - private float unifiedMemtableSkipListProbability = 0; - private int unifiedMemtableSyncMode = 0; - private long unifiedMemtableSyncIntervalUs = 0; - private String objectStoreFsPath = null; - private ObjectStoreConfig objectStoreConfig = null; - private S3Config objectStoreS3Config = null; - private int maxConcurrentFlushes = 0; - private boolean finishCompactionsOnClose = false; /** * Sets the database file-system path. @@ -281,31 +436,31 @@ public Builder dbPath(String dbPath) { this.dbPath = dbPath; return this; } - + /** - * Sets the number of flush threads. + * Sets the number of flush worker threads. * - * @param numFlushThreads the thread count; must be positive + * @param numFlushThreads the thread count, or 0 for the engine default * @return this builder */ public Builder numFlushThreads(int numFlushThreads) { this.numFlushThreads = numFlushThreads; return this; } - + /** - * Sets the number of compaction threads. + * Sets the number of compaction worker threads. * - * @param numCompactionThreads the thread count; must be positive + * @param numCompactionThreads the thread count, or 0 for the engine default * @return this builder */ public Builder numCompactionThreads(int numCompactionThreads) { this.numCompactionThreads = numCompactionThreads; return this; } - + /** - * Sets the log level. + * Sets the minimum severity to emit. * * @param logLevel the log level; must not be {@code null} * @return this builder @@ -314,114 +469,167 @@ public Builder logLevel(LogLevel logLevel) { this.logLevel = logLevel; return this; } - + /** - * Sets the block cache size in bytes. + * Sets the size of the database-level block cache for hot SSTable blocks. * - * @param blockCacheSize the size in bytes; must not be negative + * @param blockCacheSize the size in bytes, or 0 for the engine default * @return this builder */ public Builder blockCacheSize(long blockCacheSize) { this.blockCacheSize = blockCacheSize; return this; } - + /** - * Sets the maximum number of open SSTables. + * Sets the maximum number of concurrently open SSTable file handles. * - * @param maxOpenSSTables the maximum count; must be positive + * @param maxOpenSSTables the maximum count, or 0 for the engine default * @return this builder */ public Builder maxOpenSSTables(long maxOpenSSTables) { this.maxOpenSSTables = maxOpenSSTables; return this; } - + + /** + * Sets whether the log is written to a file named {@code LOG} inside the + * database directory rather than to stderr. + * + * @param logToFile {@code true} to log to a file + * @return this builder + */ public Builder logToFile(boolean logToFile) { this.logToFile = logToFile; return this; } - + + /** + * Sets the size past which the log file is truncated and reopened. + * + * @param logTruncationAt the threshold in bytes, or 0 for never + * @return this builder + */ public Builder logTruncationAt(long logTruncationAt) { this.logTruncationAt = logTruncationAt; return this; } - - public Builder maxMemoryUsage(long maxMemoryUsage) { - this.maxMemoryUsage = maxMemoryUsage; - return this; - } - - public Builder unifiedMemtable(boolean unifiedMemtable) { - this.unifiedMemtable = unifiedMemtable; - return this; - } - public Builder unifiedMemtableWriteBufferSize(long unifiedMemtableWriteBufferSize) { - this.unifiedMemtableWriteBufferSize = unifiedMemtableWriteBufferSize; + /** + * Sets the memory the active memtable may occupy before it is rotated. + * + * @param memtableWriteBufferSize the size in bytes, or 0 for the engine default + * @return this builder + */ + public Builder memtableWriteBufferSize(long memtableWriteBufferSize) { + this.memtableWriteBufferSize = memtableWriteBufferSize; return this; } - public Builder unifiedMemtableSkipListMaxLevel(int unifiedMemtableSkipListMaxLevel) { - this.unifiedMemtableSkipListMaxLevel = unifiedMemtableSkipListMaxLevel; + /** + * Sets the skip list max level for the memtable. + * + * @param memtableSkipListMaxLevel the max level, or 0 for the engine default + * @return this builder + */ + public Builder memtableSkipListMaxLevel(int memtableSkipListMaxLevel) { + this.memtableSkipListMaxLevel = memtableSkipListMaxLevel; return this; } - public Builder unifiedMemtableSkipListProbability(float unifiedMemtableSkipListProbability) { - this.unifiedMemtableSkipListProbability = unifiedMemtableSkipListProbability; + /** + * Sets the skip list level probability for the memtable. + * + * @param memtableSkipListProbability the probability in [0.0, 1.0], or 0 + * for the engine default + * @return this builder + */ + public Builder memtableSkipListProbability(float memtableSkipListProbability) { + this.memtableSkipListProbability = memtableSkipListProbability; return this; } - public Builder unifiedMemtableSyncMode(int unifiedMemtableSyncMode) { - this.unifiedMemtableSyncMode = unifiedMemtableSyncMode; + /** + * Sets the durability mode for the write-ahead log. + * + * @param memtableSyncMode the sync mode; must not be {@code null} + * @return this builder + */ + public Builder memtableSyncMode(SyncMode memtableSyncMode) { + this.memtableSyncMode = memtableSyncMode; return this; } - public Builder unifiedMemtableSyncIntervalUs(long unifiedMemtableSyncIntervalUs) { - this.unifiedMemtableSyncIntervalUs = unifiedMemtableSyncIntervalUs; + /** + * Sets the fsync interval for {@link SyncMode#SYNC_INTERVAL}. + * + * @param memtableSyncIntervalUs the interval in microseconds, or 0 for a + * one second default + * @return this builder + */ + public Builder memtableSyncIntervalUs(long memtableSyncIntervalUs) { + this.memtableSyncIntervalUs = memtableSyncIntervalUs; return this; } - public Builder objectStoreFsPath(String objectStoreFsPath) { - this.objectStoreFsPath = objectStoreFsPath; + /** + * Sets the size at or above which values are stored in the shared value + * log and referenced from the key log. + * + * @param valueSeparationThreshold the threshold in bytes, or 0 for the + * engine default + * @return this builder + */ + public Builder valueSeparationThreshold(long valueSeparationThreshold) { + this.valueSeparationThreshold = valueSeparationThreshold; return this; } - public Builder objectStoreConfig(ObjectStoreConfig objectStoreConfig) { - this.objectStoreConfig = objectStoreConfig; + /** + * Sets the size at which the value log seals its active segment. + * + * @param vlogSegmentSize the size in bytes, or 0 for the engine default + * @return this builder + */ + public Builder vlogSegmentSize(long vlogSegmentSize) { + this.vlogSegmentSize = vlogSegmentSize; return this; } /** - * Backs the database with an S3-compatible object store connector (AWS S3, MinIO, etc.). - * Takes precedence over {@link #objectStoreFsPath(String)} when both are set. Pair with - * {@link #objectStoreConfig(ObjectStoreConfig)} to tune cache, multipart, and replication - * behavior. Requires the native library to be built with {@code TIDESDB_WITH_S3=ON}. + * Sets the immutable-queue depth at which writes stall for backpressure. * - * @param objectStoreS3Config the S3 connector configuration, or null for none + * @param memtableL0QueueStallThreshold the depth, or 0 to never stall * @return this builder */ - public Builder objectStoreS3Config(S3Config objectStoreS3Config) { - this.objectStoreS3Config = objectStoreS3Config; + public Builder memtableL0QueueStallThreshold(int memtableL0QueueStallThreshold) { + this.memtableL0QueueStallThreshold = memtableL0QueueStallThreshold; return this; } - public Builder maxConcurrentFlushes(int maxConcurrentFlushes) { - this.maxConcurrentFlushes = maxConcurrentFlushes; + /** + * Sets how long the active memtable may sit unwritten before the engine + * rotates it on its own. + * + * @param memtableIdleFlushSeconds the interval in seconds, or 0 to never + * rotate on idle + * @return this builder + */ + public Builder memtableIdleFlushSeconds(int memtableIdleFlushSeconds) { + this.memtableIdleFlushSeconds = memtableIdleFlushSeconds; return this; } /** - * Sets the close behavior for in-flight compactions. + * Sets how long a transaction may stay active before the next operation + * on it expires it. A single transaction can override this with + * {@link Transaction#setTimeout(long)}. * - * @param finishCompactionsOnClose false (default) cancels in-flight compactions at their - * next checkpoint for a fast shutdown (no data is lost; recovery handles a mid-merge - * state). true lets in-flight compactions run to completion before {@code close()} - * returns. + * @param txnTimeoutSeconds the timeout in seconds, or 0 for no timeout * @return this builder */ - public Builder finishCompactionsOnClose(boolean finishCompactionsOnClose) { - this.finishCompactionsOnClose = finishCompactionsOnClose; + public Builder txnTimeoutSeconds(long txnTimeoutSeconds) { + this.txnTimeoutSeconds = txnTimeoutSeconds; return this; } @@ -436,52 +644,76 @@ public Config build() { return new Config(this); } - private void validate() throws IllegalArgumentException { + private void validate() { if (dbPath == null) { throw new IllegalArgumentException("Database path cannot be null"); } - if (dbPath.isEmpty()) { - dbPath = ""; - } - if (numFlushThreads <= 0) { - throw new IllegalArgumentException("Number of flush threads must be positive"); + if (numFlushThreads < 0) { + throw new IllegalArgumentException( + "numFlushThreads must not be negative, was: " + numFlushThreads); } - if (numCompactionThreads <= 0) { - throw new IllegalArgumentException("Number of compaction threads must be positive"); + if (numCompactionThreads < 0) { + throw new IllegalArgumentException( + "numCompactionThreads must not be negative, was: " + numCompactionThreads); } if (logLevel == null) { throw new IllegalArgumentException("Log level cannot be null"); } if (blockCacheSize < 0) { - throw new IllegalArgumentException("Block cache size cannot be negative"); + throw new IllegalArgumentException( + "blockCacheSize must not be negative, was: " + blockCacheSize); } - if (maxOpenSSTables <= 0) { - throw new IllegalArgumentException("Max open SSTables must be positive"); + if (maxOpenSSTables < 0) { + throw new IllegalArgumentException( + "maxOpenSSTables must not be negative, was: " + maxOpenSSTables); } if (logTruncationAt < 0) { - throw new IllegalArgumentException("logTruncationAt must not be negative, was: " + logTruncationAt); + throw new IllegalArgumentException( + "logTruncationAt must not be negative, was: " + logTruncationAt); + } + if (memtableWriteBufferSize < 0) { + throw new IllegalArgumentException( + "memtableWriteBufferSize must not be negative, was: " + memtableWriteBufferSize); + } + if (memtableSkipListMaxLevel < 0) { + throw new IllegalArgumentException( + "memtableSkipListMaxLevel must not be negative, was: " + memtableSkipListMaxLevel); + } + if (Float.isNaN(memtableSkipListProbability) + || Float.isInfinite(memtableSkipListProbability) + || memtableSkipListProbability < 0.0f + || memtableSkipListProbability > 1.0f) { + throw new IllegalArgumentException( + "memtableSkipListProbability must be finite and in [0.0, 1.0], was: " + + memtableSkipListProbability); } - if (maxMemoryUsage < 0) { - throw new IllegalArgumentException("maxMemoryUsage must not be negative, was: " + maxMemoryUsage); + if (memtableSyncMode == null) { + throw new IllegalArgumentException("memtableSyncMode cannot be null"); } - if (unifiedMemtableWriteBufferSize < 0) { - throw new IllegalArgumentException("unifiedMemtableWriteBufferSize must not be negative, was: " + unifiedMemtableWriteBufferSize); + if (memtableSyncIntervalUs < 0) { + throw new IllegalArgumentException( + "memtableSyncIntervalUs must not be negative, was: " + memtableSyncIntervalUs); } - if (unifiedMemtableSyncIntervalUs < 0) { - throw new IllegalArgumentException("unifiedMemtableSyncIntervalUs must not be negative, was: " + unifiedMemtableSyncIntervalUs); + if (valueSeparationThreshold < 0) { + throw new IllegalArgumentException( + "valueSeparationThreshold must not be negative, was: " + valueSeparationThreshold); } - if (unifiedMemtableSkipListMaxLevel < 0) { - throw new IllegalArgumentException("unifiedMemtableSkipListMaxLevel must not be negative, was: " + unifiedMemtableSkipListMaxLevel); + if (vlogSegmentSize < 0) { + throw new IllegalArgumentException( + "vlogSegmentSize must not be negative, was: " + vlogSegmentSize); } - if (Float.isNaN(unifiedMemtableSkipListProbability) || Float.isInfinite(unifiedMemtableSkipListProbability) - || unifiedMemtableSkipListProbability < 0.0f || unifiedMemtableSkipListProbability > 1.0f) { - throw new IllegalArgumentException("unifiedMemtableSkipListProbability must be finite and in [0.0, 1.0], was: " + unifiedMemtableSkipListProbability); + if (memtableL0QueueStallThreshold < 0) { + throw new IllegalArgumentException( + "memtableL0QueueStallThreshold must not be negative, was: " + + memtableL0QueueStallThreshold); } - if (unifiedMemtableSyncMode < 0) { - throw new IllegalArgumentException("unifiedMemtableSyncMode must not be negative, was: " + unifiedMemtableSyncMode); + if (memtableIdleFlushSeconds < 0) { + throw new IllegalArgumentException( + "memtableIdleFlushSeconds must not be negative, was: " + memtableIdleFlushSeconds); } - if (maxConcurrentFlushes < 0) { - throw new IllegalArgumentException("maxConcurrentFlushes must not be negative, was: " + maxConcurrentFlushes); + if (txnTimeoutSeconds < 0) { + throw new IllegalArgumentException( + "txnTimeoutSeconds must not be negative, was: " + txnTimeoutSeconds); } } } diff --git a/src/main/java/com/tidesdb/DbStats.java b/src/main/java/com/tidesdb/DbStats.java index 98a280d..ae1823c 100644 --- a/src/main/java/com/tidesdb/DbStats.java +++ b/src/main/java/com/tidesdb/DbStats.java @@ -19,378 +19,553 @@ package com.tidesdb; /** - * Database-level aggregate statistics across the entire TidesDB instance. + * Database-level statistics returned by {@link TidesDB#getDbStats()}. The + * memtable, the value log, and the MVCC clock are database-level and shared, so + * their figures live here rather than on {@link CfStats}. */ public class DbStats { private final int numColumnFamilies; - private final long totalMemory; - private final long availableMemory; - private final long resolvedMemoryLimit; - private final int memoryPressureLevel; - private final int flushPendingCount; - private final long totalMemtableBytes; - private final int totalImmutableCount; + private final int immutableMemtableCount; + private final int compactionPendingCount; private final int totalSstableCount; private final long totalDataSizeBytes; private final int numOpenSstables; private final long globalSeq; + private final long minSnapshotSeq; + private final int activeTxnCount; private final long txnMemoryBytes; - private final long compactionQueueSize; - private final long flushQueueSize; - private final boolean unifiedMemtableEnabled; - private final long unifiedMemtableBytes; - private final int unifiedImmutableCount; - private final boolean unifiedIsFlushing; - private final int unifiedNextCfIndex; - private final long unifiedWalGeneration; - private final boolean objectStoreEnabled; - private final String objectStoreConnector; - private final long localCacheBytesUsed; - private final long localCacheBytesMax; - private final int localCacheNumFiles; - private final long lastUploadedGeneration; - private final long uploadQueueDepth; - private final long totalUploads; - private final long totalUploadFailures; - private final boolean replicaMode; - private final long primaryEpoch; - private final long seenEpoch; - private final long uwalBytesWritten; - private final long walBytesWritten; + private final long memtableBytes; + private final boolean flushing; + private final long nextCfIndex; + private final long walGeneration; + private final long flushCount; + private final long compactionCount; private final long flushBytesWritten; private final long compactionBytesWritten; private final long compactionBytesRead; + private final long walBytesWritten; private final long userBytesWritten; - private final long flushCount; - private final long compactionCount; + private final long vlogFileSize; + private final long vlogValueCount; + private final long vlogUsedBytes; + private final long vlogStoredBytes; + private final long vlogLiveBytes; + private final long vlogSegmentCount; + private final long vlogBytesWritten; + private final long vlogDeadBytes; + private final long vlogReclaimCalls; + private final long vlogReclaimPasses; + private final long vlogSegmentsRetired; + private final long vlogSegmentsDrainable; + private final long writesThrottled; + private final long writesBlocked; + private final long writeStallUs; + private final long writeStallCeilingHits; - public DbStats(int numColumnFamilies, long totalMemory, long availableMemory, - long resolvedMemoryLimit, int memoryPressureLevel, int flushPendingCount, - long totalMemtableBytes, int totalImmutableCount, int totalSstableCount, - long totalDataSizeBytes, int numOpenSstables, long globalSeq, - long txnMemoryBytes, long compactionQueueSize, long flushQueueSize, - boolean unifiedMemtableEnabled, long unifiedMemtableBytes, - int unifiedImmutableCount, boolean unifiedIsFlushing, - int unifiedNextCfIndex, long unifiedWalGeneration, - boolean objectStoreEnabled, String objectStoreConnector, - long localCacheBytesUsed, long localCacheBytesMax, int localCacheNumFiles, - long lastUploadedGeneration, long uploadQueueDepth, - long totalUploads, long totalUploadFailures, boolean replicaMode, - long primaryEpoch, long seenEpoch, - long uwalBytesWritten, long walBytesWritten, long flushBytesWritten, - long compactionBytesWritten, long compactionBytesRead, long userBytesWritten, - long flushCount, long compactionCount) { + /** + * Creates a new {@code DbStats}. Typically called by the JNI bridge rather + * than application code. + * + * @param numColumnFamilies number of column families + * @param immutableMemtableCount immutable memtables awaiting flush + * @param compactionPendingCount compaction jobs queued for the worker pool + * @param totalSstableCount total SSTables across every family and level + * @param totalDataSizeBytes on-disk key-log bytes summed across every family + * @param numOpenSstables currently open SSTable file handles + * @param globalSeq current database-global sequence number + * @param minSnapshotSeq the oldest live-transaction snapshot + * @param activeTxnCount live transactions joined to the registry + * @param txnMemoryBytes bytes held by in-flight transactions + * @param memtableBytes memory the active memtable occupies + * @param flushing whether an immutable is queued or flushing + * @param nextCfIndex next column family id to be assigned + * @param walGeneration current write-ahead-log generation counter + * @param flushCount SSTables flushed across every family + * @param compactionCount compactions run across every family + * @param flushBytesWritten flush output bytes summed across every family + * @param compactionBytesWritten compaction output bytes summed across every family + * @param compactionBytesRead compaction input bytes summed across every family + * @param walBytesWritten framed write-ahead-log bytes, counted at the append + * @param userBytesWritten logical committed bytes summed across every family + * @param vlogFileSize total value-log file size in bytes + * @param vlogValueCount values currently indexed in the value log + * @param vlogUsedBytes uncompressed length the indexed values represent + * @param vlogStoredBytes the on-disk length those same values occupy + * @param vlogLiveBytes value-log bytes the live SSTables still reference + * @param vlogSegmentCount value-log segment files currently open + * @param vlogBytesWritten value-log bytes ever appended + * @param vlogDeadBytes value-log bytes beyond what the live values account for + * @param vlogReclaimCalls value-log reclaims attempted, lifetime + * @param vlogReclaimPasses value-log reclaim passes that drained a segment + * @param vlogSegmentsRetired value-log segment files a reclaim has unlinked + * @param vlogSegmentsDrainable sealed segments worth rewriting tables for + * @param writesThrottled commits the L0 admission policy made dwell + * @param writesBlocked commits the L0 admission policy made wait + * @param writeStallUs total microseconds commits were held in L0 admission + * @param writeStallCeilingHits commits admitted only because the wait ceiling expired + */ + public DbStats(int numColumnFamilies, int immutableMemtableCount, int compactionPendingCount, + int totalSstableCount, long totalDataSizeBytes, int numOpenSstables, + long globalSeq, long minSnapshotSeq, int activeTxnCount, long txnMemoryBytes, + long memtableBytes, boolean flushing, long nextCfIndex, long walGeneration, + long flushCount, long compactionCount, long flushBytesWritten, + long compactionBytesWritten, long compactionBytesRead, long walBytesWritten, + long userBytesWritten, long vlogFileSize, long vlogValueCount, + long vlogUsedBytes, long vlogStoredBytes, long vlogLiveBytes, + long vlogSegmentCount, long vlogBytesWritten, long vlogDeadBytes, + long vlogReclaimCalls, long vlogReclaimPasses, long vlogSegmentsRetired, + long vlogSegmentsDrainable, long writesThrottled, long writesBlocked, + long writeStallUs, long writeStallCeilingHits) { this.numColumnFamilies = numColumnFamilies; - this.totalMemory = totalMemory; - this.availableMemory = availableMemory; - this.resolvedMemoryLimit = resolvedMemoryLimit; - this.memoryPressureLevel = memoryPressureLevel; - this.flushPendingCount = flushPendingCount; - this.totalMemtableBytes = totalMemtableBytes; - this.totalImmutableCount = totalImmutableCount; + this.immutableMemtableCount = immutableMemtableCount; + this.compactionPendingCount = compactionPendingCount; this.totalSstableCount = totalSstableCount; this.totalDataSizeBytes = totalDataSizeBytes; this.numOpenSstables = numOpenSstables; this.globalSeq = globalSeq; + this.minSnapshotSeq = minSnapshotSeq; + this.activeTxnCount = activeTxnCount; this.txnMemoryBytes = txnMemoryBytes; - this.compactionQueueSize = compactionQueueSize; - this.flushQueueSize = flushQueueSize; - this.unifiedMemtableEnabled = unifiedMemtableEnabled; - this.unifiedMemtableBytes = unifiedMemtableBytes; - this.unifiedImmutableCount = unifiedImmutableCount; - this.unifiedIsFlushing = unifiedIsFlushing; - this.unifiedNextCfIndex = unifiedNextCfIndex; - this.unifiedWalGeneration = unifiedWalGeneration; - this.objectStoreEnabled = objectStoreEnabled; - this.objectStoreConnector = objectStoreConnector; - this.localCacheBytesUsed = localCacheBytesUsed; - this.localCacheBytesMax = localCacheBytesMax; - this.localCacheNumFiles = localCacheNumFiles; - this.lastUploadedGeneration = lastUploadedGeneration; - this.uploadQueueDepth = uploadQueueDepth; - this.totalUploads = totalUploads; - this.totalUploadFailures = totalUploadFailures; - this.replicaMode = replicaMode; - this.primaryEpoch = primaryEpoch; - this.seenEpoch = seenEpoch; - this.uwalBytesWritten = uwalBytesWritten; - this.walBytesWritten = walBytesWritten; + this.memtableBytes = memtableBytes; + this.flushing = flushing; + this.nextCfIndex = nextCfIndex; + this.walGeneration = walGeneration; + this.flushCount = flushCount; + this.compactionCount = compactionCount; this.flushBytesWritten = flushBytesWritten; this.compactionBytesWritten = compactionBytesWritten; this.compactionBytesRead = compactionBytesRead; + this.walBytesWritten = walBytesWritten; this.userBytesWritten = userBytesWritten; - this.flushCount = flushCount; - this.compactionCount = compactionCount; + this.vlogFileSize = vlogFileSize; + this.vlogValueCount = vlogValueCount; + this.vlogUsedBytes = vlogUsedBytes; + this.vlogStoredBytes = vlogStoredBytes; + this.vlogLiveBytes = vlogLiveBytes; + this.vlogSegmentCount = vlogSegmentCount; + this.vlogBytesWritten = vlogBytesWritten; + this.vlogDeadBytes = vlogDeadBytes; + this.vlogReclaimCalls = vlogReclaimCalls; + this.vlogReclaimPasses = vlogReclaimPasses; + this.vlogSegmentsRetired = vlogSegmentsRetired; + this.vlogSegmentsDrainable = vlogSegmentsDrainable; + this.writesThrottled = writesThrottled; + this.writesBlocked = writesBlocked; + this.writeStallUs = writeStallUs; + this.writeStallCeilingHits = writeStallCeilingHits; } + /** + * Returns the number of column families. + * + * @return the column family count + */ public int getNumColumnFamilies() { return numColumnFamilies; } - public long getTotalMemory() { - return totalMemory; - } - - public long getAvailableMemory() { - return availableMemory; - } - - public long getResolvedMemoryLimit() { - return resolvedMemoryLimit; - } - - public int getMemoryPressureLevel() { - return memoryPressureLevel; - } - - public int getFlushPendingCount() { - return flushPendingCount; - } - - public long getTotalMemtableBytes() { - return totalMemtableBytes; + /** + * Returns the number of immutable memtables awaiting flush, which is the L0 + * queue depth. + * + * @return the immutable memtable count + */ + public int getImmutableMemtableCount() { + return immutableMemtableCount; } - public int getTotalImmutableCount() { - return totalImmutableCount; + /** + * Returns the number of compaction jobs queued for the worker pool. + * + * @return the pending compaction count + */ + public int getCompactionPendingCount() { + return compactionPendingCount; } + /** + * Returns the total SSTables across every column family and level. + * + * @return the SSTable count + */ public int getTotalSstableCount() { return totalSstableCount; } + /** + * Returns the on-disk key-log bytes summed across every column family and + * level. The value log is reported separately by {@link #getVlogFileSize()}, + * since it is shared rather than owned by any one family. + * + * @return the on-disk data size in bytes + */ public long getTotalDataSizeBytes() { return totalDataSizeBytes; } + /** + * Returns the number of currently open SSTable file handles. + * + * @return the open SSTable count + */ public int getNumOpenSstables() { return numOpenSstables; } + /** + * Returns the current database-global sequence number, the MVCC clock. + * + * @return the global sequence + */ public long getGlobalSeq() { return globalSeq; } - public long getTxnMemoryBytes() { - return txnMemoryBytes; + /** + * Returns the oldest live-transaction snapshot, which is the compaction + * garbage-collection floor. + * + *
The engine's sequence is an unsigned 64-bit value, so when no snapshot + * is registered the floor sits at {@code UINT64_MAX} and arrives here as + * {@code -1}. Compare sequences with + * {@link Long#compareUnsigned(long, long)} rather than {@code <}. + * + * @return the minimum snapshot sequence, or {@code -1} when nothing holds + * the floor + */ + public long getMinSnapshotSeq() { + return minSnapshotSeq; } - public long getCompactionQueueSize() { - return compactionQueueSize; + /** + * Returns the live transactions joined to the registry, which is + * repeatable-read and stronger only. Read-uncommitted and read-committed + * transactions need no snapshot reservation and so are not counted. + * + * @return the active transaction count + */ + public int getActiveTxnCount() { + return activeTxnCount; } - public long getFlushQueueSize() { - return flushQueueSize; + /** + * Returns the bytes held by in-flight transactions, over the same registered + * set as {@link #getActiveTxnCount()}. + * + * @return the transaction memory in bytes + */ + public long getTxnMemoryBytes() { + return txnMemoryBytes; } - public boolean isUnifiedMemtableEnabled() { - return unifiedMemtableEnabled; + /** + * Returns the memory the active memtable occupies, the figure + * {@link Config#getMemtableWriteBufferSize()} is compared against, so it + * counts skip list nodes and version structs as well as key and value bytes. + * + * @return the memtable size in bytes + */ + public long getMemtableBytes() { + return memtableBytes; } - public long getUnifiedMemtableBytes() { - return unifiedMemtableBytes; + /** + * Returns whether an immutable memtable is queued or flushing. + * + * @return {@code true} while flushing + */ + public boolean isFlushing() { + return flushing; } - public int getUnifiedImmutableCount() { - return unifiedImmutableCount; + /** + * Returns the next column family id to be assigned. + * + * @return the next column family index + */ + public long getNextCfIndex() { + return nextCfIndex; } - public boolean isUnifiedIsFlushing() { - return unifiedIsFlushing; + /** + * Returns the current write-ahead-log generation counter. + * + * @return the WAL generation + */ + public long getWalGeneration() { + return walGeneration; } - public int getUnifiedNextCfIndex() { - return unifiedNextCfIndex; + /** + * Returns the SSTables flushed across every column family. + * + * @return the flush count + */ + public long getFlushCount() { + return flushCount; } - public long getUnifiedWalGeneration() { - return unifiedWalGeneration; + /** + * Returns the compactions run across every column family. + * + * @return the compaction count + */ + public long getCompactionCount() { + return compactionCount; } - public boolean isObjectStoreEnabled() { - return objectStoreEnabled; + /** + * Returns the flush output bytes summed across every column family. + * + * @return the flush byte count + */ + public long getFlushBytesWritten() { + return flushBytesWritten; } - public String getObjectStoreConnector() { - return objectStoreConnector; + /** + * Returns the compaction output bytes summed across every column family. + * + * @return the compaction output byte count + */ + public long getCompactionBytesWritten() { + return compactionBytesWritten; } - public long getLocalCacheBytesUsed() { - return localCacheBytesUsed; + /** + * Returns the compaction input bytes summed across every column family. + * + * @return the compaction input byte count + */ + public long getCompactionBytesRead() { + return compactionBytesRead; } - public long getLocalCacheBytesMax() { - return localCacheBytesMax; + /** + * Returns the framed write-ahead-log bytes, counted at the append. The log + * is reclaimed with its memtable and never appears in the on-disk totals, + * but the device was still asked to write it, and a write-amplification + * figure that omits it understates by a whole copy of the data. + * + * @return the WAL byte count + */ + public long getWalBytesWritten() { + return walBytesWritten; } - public int getLocalCacheNumFiles() { - return localCacheNumFiles; + /** + * Returns the logical committed bytes summed across every column family. + * + * @return the user byte count + */ + public long getUserBytesWritten() { + return userBytesWritten; } - public long getLastUploadedGeneration() { - return lastUploadedGeneration; + /** + * Returns the total value-log file size in bytes. + * + * @return the value-log size + */ + public long getVlogFileSize() { + return vlogFileSize; } - public long getUploadQueueDepth() { - return uploadQueueDepth; + /** + * Returns the values currently indexed in the value log. + * + * @return the indexed value count + */ + public long getVlogValueCount() { + return vlogValueCount; } - public long getTotalUploads() { - return totalUploads; + /** + * Returns the uncompressed length the indexed values represent. This counts + * everything the index still names, reachable or not, so it is not a measure + * of live data. + * + * @return the used byte count + */ + public long getVlogUsedBytes() { + return vlogUsedBytes; } - public long getTotalUploadFailures() { - return totalUploadFailures; + /** + * Returns the on-disk length those same indexed values occupy. Read against + * {@link #getVlogUsedBytes()} it is the encoding pipeline's realised ratio. + * + * @return the stored byte count + */ + public long getVlogStoredBytes() { + return vlogStoredBytes; } - public boolean isReplicaMode() { - return replicaMode; + /** + * Returns the value-log bytes the live SSTables still reference. This is the + * figure space amplification is against: a store can hold many gigabytes of + * values no tree can reach, and only this tells them apart from the ones + * still worth keeping. + * + * @return the live byte count + */ + public long getVlogLiveBytes() { + return vlogLiveBytes; } /** - * Gets the lease epoch this primary currently holds (object-store single-writer fencing). - * Returns 0 when this node is not a primary or holds no lease. A promotion that takes - * effect bumps this value. + * Returns the value-log segment files currently open, the one taking appends + * included. * - * @return the current primary lease epoch, or 0 if not a primary + * @return the segment count */ - public long getPrimaryEpoch() { - return primaryEpoch; + public long getVlogSegmentCount() { + return vlogSegmentCount; } /** - * Gets the highest lease epoch this node has observed (object-store single-writer fencing). - * A fenced primary sees {@link #isReplicaMode()} flip back to true once a newer epoch is seen. + * Returns the value-log bytes ever appended, reclamation's own rewrites + * included. On a store that separates its values most of the writing happens + * here. * - * @return the highest observed lease epoch + * @return the appended byte count */ - public long getSeenEpoch() { - return seenEpoch; + public long getVlogBytesWritten() { + return vlogBytesWritten; } /** - * Gets the framed bytes appended to the shared unified WAL (lifetime since open). - * Returns 0 when unified memtable mode is off. + * Returns the value-log bytes beyond what the live values account for, the + * space a reclaim could recover. * - * @return unified WAL bytes written + * @return the dead byte count */ - public long getUwalBytesWritten() { - return uwalBytesWritten; + public long getVlogDeadBytes() { + return vlogDeadBytes; } /** - * Gets the per-column-family WAL bytes summed across all column families - * (lifetime since open). + * Returns the value-log reclaims attempted, lifetime. Read beside + * {@link #getVlogReclaimPasses()} this separates a reclaim that never runs + * from one that runs and finds nothing worth draining. * - * @return WAL bytes written across all CFs + * @return the reclaim call count */ - public long getWalBytesWritten() { - return walBytesWritten; + public long getVlogReclaimCalls() { + return vlogReclaimCalls; } /** - * Gets the flush output bytes summed across all column families (lifetime since open). + * Returns the value-log reclaim passes that drained a segment, since this + * handle opened. * - * @return flush output bytes written across all CFs + * @return the reclaim pass count */ - public long getFlushBytesWritten() { - return flushBytesWritten; + public long getVlogReclaimPasses() { + return vlogReclaimPasses; } /** - * Gets the compaction output bytes summed across all column families (lifetime since open). + * Returns the value-log segment files a reclaim has unlinked. * - * @return compaction output bytes written across all CFs + * @return the retired segment count */ - public long getCompactionBytesWritten() { - return compactionBytesWritten; + public long getVlogSegmentsRetired() { + return vlogSegmentsRetired; } /** - * Gets the compaction input bytes summed across all column families (lifetime since open). + * Returns the sealed segments holding so little live data that rewriting the + * tables referencing them would free most of a file. Read against + * {@link #getVlogDeadBytes()} this says how much of the garbage is currently + * actionable, and a figure that stays high is reclamation falling behind. * - * @return compaction input bytes read across all CFs + * @return the drainable segment count */ - public long getCompactionBytesRead() { - return compactionBytesRead; + public long getVlogSegmentsDrainable() { + return vlogSegmentsDrainable; } /** - * Gets the logical committed bytes summed across all column families (lifetime since open). - * This is the database-wide write-amplification denominator: - * {@code (uwal + wal + flush + compaction) / userBytesWritten}. + * Returns the commits the L0 admission policy made dwell before admitting. * - * @return user bytes written across all CFs + * @return the throttled write count */ - public long getUserBytesWritten() { - return userBytesWritten; + public long getWritesThrottled() { + return writesThrottled; } /** - * Gets the number of flushed SSTables summed across all column families - * (lifetime since open). + * Returns the commits the L0 admission policy made wait for the flush queue + * to drain. * - * @return flush count across all CFs + * @return the blocked write count */ - public long getFlushCount() { - return flushCount; + public long getWritesBlocked() { + return writesBlocked; } /** - * Gets the number of compaction output SSTables summed across all column families - * (lifetime since open). + * Returns the total microseconds commits were held in L0 admission, dwell + * plus wait. * - * @return compaction count across all CFs + * @return the write stall time in microseconds */ - public long getCompactionCount() { - return compactionCount; + public long getWriteStallUs() { + return writeStallUs; + } + + /** + * Returns the commits admitted only because the admission wait ceiling + * expired. Any of these means flush did not keep up with ingestion. + * + * @return the ceiling hit count + */ + public long getWriteStallCeilingHits() { + return writeStallCeilingHits; } @Override public String toString() { return "DbStats{" + "numColumnFamilies=" + numColumnFamilies + - ", totalMemory=" + totalMemory + - ", availableMemory=" + availableMemory + - ", resolvedMemoryLimit=" + resolvedMemoryLimit + - ", memoryPressureLevel=" + memoryPressureLevel + - ", flushPendingCount=" + flushPendingCount + - ", totalMemtableBytes=" + totalMemtableBytes + - ", totalImmutableCount=" + totalImmutableCount + + ", immutableMemtableCount=" + immutableMemtableCount + + ", compactionPendingCount=" + compactionPendingCount + ", totalSstableCount=" + totalSstableCount + ", totalDataSizeBytes=" + totalDataSizeBytes + ", numOpenSstables=" + numOpenSstables + ", globalSeq=" + globalSeq + + ", minSnapshotSeq=" + minSnapshotSeq + + ", activeTxnCount=" + activeTxnCount + ", txnMemoryBytes=" + txnMemoryBytes + - ", compactionQueueSize=" + compactionQueueSize + - ", flushQueueSize=" + flushQueueSize + - ", unifiedMemtableEnabled=" + unifiedMemtableEnabled + - ", unifiedMemtableBytes=" + unifiedMemtableBytes + - ", unifiedImmutableCount=" + unifiedImmutableCount + - ", unifiedIsFlushing=" + unifiedIsFlushing + - ", unifiedNextCfIndex=" + unifiedNextCfIndex + - ", unifiedWalGeneration=" + unifiedWalGeneration + - ", objectStoreEnabled=" + objectStoreEnabled + - ", objectStoreConnector='" + objectStoreConnector + '\'' + - ", localCacheBytesUsed=" + localCacheBytesUsed + - ", localCacheBytesMax=" + localCacheBytesMax + - ", localCacheNumFiles=" + localCacheNumFiles + - ", lastUploadedGeneration=" + lastUploadedGeneration + - ", uploadQueueDepth=" + uploadQueueDepth + - ", totalUploads=" + totalUploads + - ", totalUploadFailures=" + totalUploadFailures + - ", replicaMode=" + replicaMode + - ", primaryEpoch=" + primaryEpoch + - ", seenEpoch=" + seenEpoch + - ", uwalBytesWritten=" + uwalBytesWritten + - ", walBytesWritten=" + walBytesWritten + + ", memtableBytes=" + memtableBytes + + ", flushing=" + flushing + + ", nextCfIndex=" + nextCfIndex + + ", walGeneration=" + walGeneration + + ", flushCount=" + flushCount + + ", compactionCount=" + compactionCount + ", flushBytesWritten=" + flushBytesWritten + ", compactionBytesWritten=" + compactionBytesWritten + ", compactionBytesRead=" + compactionBytesRead + + ", walBytesWritten=" + walBytesWritten + ", userBytesWritten=" + userBytesWritten + - ", flushCount=" + flushCount + - ", compactionCount=" + compactionCount + + ", vlogFileSize=" + vlogFileSize + + ", vlogValueCount=" + vlogValueCount + + ", vlogUsedBytes=" + vlogUsedBytes + + ", vlogStoredBytes=" + vlogStoredBytes + + ", vlogLiveBytes=" + vlogLiveBytes + + ", vlogSegmentCount=" + vlogSegmentCount + + ", vlogBytesWritten=" + vlogBytesWritten + + ", vlogDeadBytes=" + vlogDeadBytes + + ", vlogReclaimCalls=" + vlogReclaimCalls + + ", vlogReclaimPasses=" + vlogReclaimPasses + + ", vlogSegmentsRetired=" + vlogSegmentsRetired + + ", vlogSegmentsDrainable=" + vlogSegmentsDrainable + + ", writesThrottled=" + writesThrottled + + ", writesBlocked=" + writesBlocked + + ", writeStallUs=" + writeStallUs + + ", writeStallCeilingHits=" + writeStallCeilingHits + '}'; } } diff --git a/src/main/java/com/tidesdb/EncodingStats.java b/src/main/java/com/tidesdb/EncodingStats.java new file mode 100644 index 0000000..83e12ce --- /dev/null +++ b/src/main/java/com/tidesdb/EncodingStats.java @@ -0,0 +1,125 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +import java.util.Arrays; + +/** + * What one encoding chain achieved on the data it wrote, returned from + * {@link TidesDB#getKlogEncodingStats()} and + * {@link TidesDB#getVlogEncodingStats()}. + * + *
Reported per chain rather than per column family because a family can + * change its codec, and compaction rewrites data under whichever pipeline is + * merging it, so a single figure for a family would average across settings + * that no longer apply and describe none of them. + */ +public class EncodingStats { + + /** + * The most encoding chains reported separately, for either the key logs or + * the value log. A collector never returns more than this many entries. + */ + public static final int MAX_CHAINS = 16; + + private final int[] ids; + private final long logicalBytes; + private final long storedBytes; + private final long itemCount; + + /** + * Creates a new {@code EncodingStats}. Typically called by the JNI bridge + * rather than application code. + * + * @param ids the codec ids in the order applied, empty when the data was + * stored verbatim + * @param logicalBytes what the data amounts to before encoding + * @param storedBytes what it occupies on disk + * @param itemCount values for the value log, SSTables for the key logs + */ + public EncodingStats(int[] ids, long logicalBytes, long storedBytes, long itemCount) { + this.ids = ids == null ? new int[0] : ids.clone(); + this.logicalBytes = logicalBytes; + this.storedBytes = storedBytes; + this.itemCount = itemCount; + } + + /** + * Returns the codec ids in the order applied, empty when the data was + * stored verbatim. Ids in the range of {@link CompressionAlgorithm} name a + * built-in compression codec. + * + * @return a copy of the codec ids + */ + public int[] getIds() { + return ids.clone(); + } + + /** + * Returns what the data amounts to before encoding. + * + * @return the logical byte count + */ + public long getLogicalBytes() { + return logicalBytes; + } + + /** + * Returns what the data occupies on disk. + * + * @return the stored byte count + */ + public long getStoredBytes() { + return storedBytes; + } + + /** + * Returns the number of items this chain wrote: values for the value log, + * SSTables for the key logs. + * + * @return the item count + */ + public long getItemCount() { + return itemCount; + } + + /** + * Returns the realised compression ratio, logical over stored, so a value + * above 1.0 means the data shrank. + * + * @return the ratio, or 0.0 when nothing has been stored + */ + public double getRatio() { + if (storedBytes == 0) { + return 0.0; + } + return (double) logicalBytes / (double) storedBytes; + } + + @Override + public String toString() { + return "EncodingStats{" + + "ids=" + Arrays.toString(ids) + + ", logicalBytes=" + logicalBytes + + ", storedBytes=" + storedBytes + + ", itemCount=" + itemCount + + ", ratio=" + getRatio() + + '}'; + } +} diff --git a/src/main/java/com/tidesdb/IoClass.java b/src/main/java/com/tidesdb/IoClass.java new file mode 100644 index 0000000..ab6f7b6 --- /dev/null +++ b/src/main/java/com/tidesdb/IoClass.java @@ -0,0 +1,96 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * The kinds of file the engine writes, so device time can be attributed rather + * than pooled. + * + *
The ordinal of each constant is its index into {@link IoStats}. + */ +public enum IoClass { + + /** + * Key logs, written by flush and compaction. + */ + SSTABLE(0), + + /** + * The write-ahead log, written by its own single flush thread. + */ + WAL(1), + + /** + * Value log segments, written by a commit that separates a value and by the + * reclaim that copies live values forward. This is the write cost of + * key/value separation, so it is counted apart from the key logs whose size + * that separation is what keeps down. + */ + VLOG(2); + + static { + NativeLibrary.load(); + } + + private final int value; + + IoClass(int value) { + this.value = value; + } + + /** + * Returns the JNI numeric mapping for this I/O class, which is also its + * index into {@link IoStats}. + * + * @return the integer value used by the native library + */ + public int getValue() { + return value; + } + + /** + * Returns the native library's stable short name for this class, suitable + * for a log line or a stats table's row label. + * + * @return the name, never {@code null} + */ + public String getNativeName() { + return nativeName(value); + } + + private static native String nativeName(int cls); + + /** + * Returns the {@link IoClass} constant matching the given JNI integer + * value. + * + * @param value the JNI integer value + * @return the matching constant + * @throws IllegalArgumentException if {@code value} does not map to any + * known constant + */ + public static IoClass fromValue(int value) { + for (IoClass cls : values()) { + if (cls.value == value) { + return cls; + } + } + throw new IllegalArgumentException("Unknown I/O class value: " + value); + } +} diff --git a/src/main/java/com/tidesdb/IoStat.java b/src/main/java/com/tidesdb/IoStat.java new file mode 100644 index 0000000..fe8dcf3 --- /dev/null +++ b/src/main/java/com/tidesdb/IoStat.java @@ -0,0 +1,107 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * What one {@link IoClass} asked of the device. Created by the native library + * and returned inside {@link IoStats}. + */ +public class IoStat { + + private final long ops; + private final long bytes; + private final long totalUs; + private final long maxUs; + + /** + * Creates a new {@code IoStat}. Typically called by the JNI bridge rather + * than application code. + * + * @param ops writes issued + * @param bytes bytes written + * @param totalUs the summed time inside those writes, in microseconds + * @param maxUs the slowest single write, in microseconds + */ + public IoStat(long ops, long bytes, long totalUs, long maxUs) { + this.ops = ops; + this.bytes = bytes; + this.totalUs = totalUs; + this.maxUs = maxUs; + } + + /** + * Returns the number of writes issued. + * + * @return the write count + */ + public long getOps() { + return ops; + } + + /** + * Returns the number of bytes written. + * + * @return the byte count + */ + public long getBytes() { + return bytes; + } + + /** + * Returns the summed time inside those writes. + * + * @return the total time in microseconds + */ + public long getTotalUs() { + return totalUs; + } + + /** + * Returns the slowest single write. + * + * @return the slowest write in microseconds + */ + public long getMaxUs() { + return maxUs; + } + + /** + * Returns the throughput this class actually achieved, in bytes per second. + * Compare it against what the storage can sustain, because a saturated + * device and a stalled engine look identical from the application. + * + * @return bytes per second, or 0.0 when no time has been spent writing + */ + public double getBytesPerSecond() { + if (totalUs == 0) { + return 0.0; + } + return (double) bytes * 1_000_000.0 / (double) totalUs; + } + + @Override + public String toString() { + return "IoStat{" + + "ops=" + ops + + ", bytes=" + bytes + + ", totalUs=" + totalUs + + ", maxUs=" + maxUs + + '}'; + } +} diff --git a/src/main/java/com/tidesdb/IoStats.java b/src/main/java/com/tidesdb/IoStats.java new file mode 100644 index 0000000..ab8c699 --- /dev/null +++ b/src/main/java/com/tidesdb/IoStats.java @@ -0,0 +1,99 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * What each class of file asked of the device, one entry per {@link IoClass}, + * returned from {@link TidesDB#getIoStats()}. + * + *
This is the other half of {@link TidesDB#getStallStats()}: that says + * writers waited on the log, this says whether the device was the reason. + * + *
Only handles the engine opens through its descriptor manager are counted, + * which is every key log and every write-ahead log. The value log and the + * manifest are not, so this measures the two classes that compete for the + * device under load rather than every byte the database writes. + */ +public class IoStats { + + private final IoStat[] classes; + + /** + * Creates a new {@code IoStats}. Typically called by the JNI bridge rather + * than application code. + * + * @param classes the per-class totals, indexed by {@link IoClass#getValue()}; + * must not be {@code null} and must hold one entry per class + */ + public IoStats(IoStat[] classes) { + if (classes == null || classes.length != IoClass.values().length) { + throw new IllegalArgumentException( + "classes must hold exactly " + IoClass.values().length + " entries"); + } + this.classes = classes.clone(); + } + + /** + * Returns the totals for one I/O class. + * + * @param cls the class; must not be {@code null} + * @return the totals for that class, never {@code null} + */ + public IoStat get(IoClass cls) { + if (cls == null) { + throw new IllegalArgumentException("I/O class cannot be null"); + } + return classes[cls.getValue()]; + } + + /** + * Returns the totals for every I/O class, indexed by + * {@link IoClass#getValue()}. + * + * @return a copy of the per-class totals + */ + public IoStat[] getClasses() { + return classes.clone(); + } + + /** + * Returns the bytes written across every class. + * + * @return the total byte count + */ + public long getTotalBytes() { + long total = 0; + for (IoStat stat : classes) { + total += stat.getBytes(); + } + return total; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("IoStats{"); + for (IoClass cls : IoClass.values()) { + if (cls.getValue() > 0) { + sb.append(", "); + } + sb.append(cls.name()).append('=').append(classes[cls.getValue()]); + } + return sb.append('}').toString(); + } +} diff --git a/src/main/java/com/tidesdb/LogLevel.java b/src/main/java/com/tidesdb/LogLevel.java index c25bfee..650836a 100644 --- a/src/main/java/com/tidesdb/LogLevel.java +++ b/src/main/java/com/tidesdb/LogLevel.java @@ -19,47 +19,45 @@ package com.tidesdb; /** - * Logging level for the native TidesDB library. Each constant maps to an - * integer used by the JNI bridge. + * Logging level for the native TidesDB library, used both as a message severity + * and as the sink threshold a message must meet or exceed to be emitted. A larger + * value is more severe, so a higher threshold emits fewer lines. + * + *
Each constant maps to an integer used by the JNI bridge. */ public enum LogLevel { /** - * Verbose debug output. + * No logging. */ - DEBUG(0), + NONE(0), /** - * Informational messages (default). + * Low severity, highly detailed messages for technical debugging. */ - INFO(1), + TRACE(1), /** - * Warning messages. + * Standard information describing engine status or operations. */ - WARN(2), + INFO(2), /** - * Error messages. + * Non-imminent errors that require awareness. */ - ERROR(3), + WARN(3), /** - * Fatal messages. + * An operation failed. */ - FATAL(4), + ERROR(4); - /** - * Logging disabled. - */ - NONE(99); - private final int value; - + LogLevel(int value) { this.value = value; } - + /** * Returns the JNI numeric mapping for this log level. * @@ -68,7 +66,7 @@ public enum LogLevel { public int getValue() { return value; } - + /** * Returns the {@link LogLevel} constant matching the given JNI integer * value. diff --git a/src/main/java/com/tidesdb/ObjectStoreConfig.java b/src/main/java/com/tidesdb/ObjectStoreConfig.java deleted file mode 100644 index 2dff8fd..0000000 --- a/src/main/java/com/tidesdb/ObjectStoreConfig.java +++ /dev/null @@ -1,227 +0,0 @@ -/** - * - * Copyright (C) TidesDB - * - * Original Author: Alex Gaetano Padula - * - * Licensed under the Mozilla Public License, v. 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 - * - * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; - -/** - * Configuration for object store mode behavior. - */ -public class ObjectStoreConfig { - - private String localCachePath; - private long localCacheMaxBytes; - private boolean cacheOnRead; - private boolean cacheOnWrite; - private int maxConcurrentUploads; - private int maxConcurrentDownloads; - private long multipartThreshold; - private long multipartPartSize; - private boolean syncManifestToObject; - private boolean replicateWal; - private boolean walUploadSync; - private long walSyncThresholdBytes; - private boolean walSyncOnCommit; - private boolean replicaMode; - private long replicaSyncIntervalUs; - private boolean replicaReplayWal; - - private ObjectStoreConfig(Builder builder) { - this.localCachePath = builder.localCachePath; - this.localCacheMaxBytes = builder.localCacheMaxBytes; - this.cacheOnRead = builder.cacheOnRead; - this.cacheOnWrite = builder.cacheOnWrite; - this.maxConcurrentUploads = builder.maxConcurrentUploads; - this.maxConcurrentDownloads = builder.maxConcurrentDownloads; - this.multipartThreshold = builder.multipartThreshold; - this.multipartPartSize = builder.multipartPartSize; - this.syncManifestToObject = builder.syncManifestToObject; - this.replicateWal = builder.replicateWal; - this.walUploadSync = builder.walUploadSync; - this.walSyncThresholdBytes = builder.walSyncThresholdBytes; - this.walSyncOnCommit = builder.walSyncOnCommit; - this.replicaMode = builder.replicaMode; - this.replicaSyncIntervalUs = builder.replicaSyncIntervalUs; - this.replicaReplayWal = builder.replicaReplayWal; - } - - /** - * Creates a default object store configuration matching tidesdb_objstore_default_config(). - * - * @return a new ObjectStoreConfig with default values - */ - public static ObjectStoreConfig defaultConfig() { - return new Builder().build(); - } - - /** - * Creates a new builder for ObjectStoreConfig. - * - * @return a new Builder - */ - public static Builder builder() { - return new Builder(); - } - - public String getLocalCachePath() { return localCachePath; } - public long getLocalCacheMaxBytes() { return localCacheMaxBytes; } - public boolean isCacheOnRead() { return cacheOnRead; } - public boolean isCacheOnWrite() { return cacheOnWrite; } - public int getMaxConcurrentUploads() { return maxConcurrentUploads; } - public int getMaxConcurrentDownloads() { return maxConcurrentDownloads; } - public long getMultipartThreshold() { return multipartThreshold; } - public long getMultipartPartSize() { return multipartPartSize; } - public boolean isSyncManifestToObject() { return syncManifestToObject; } - public boolean isReplicateWal() { return replicateWal; } - public boolean isWalUploadSync() { return walUploadSync; } - public long getWalSyncThresholdBytes() { return walSyncThresholdBytes; } - public boolean isWalSyncOnCommit() { return walSyncOnCommit; } - public boolean isReplicaMode() { return replicaMode; } - public long getReplicaSyncIntervalUs() { return replicaSyncIntervalUs; } - public boolean isReplicaReplayWal() { return replicaReplayWal; } - - /** - * Builder for ObjectStoreConfig. - */ - public static class Builder { - private String localCachePath = null; - private long localCacheMaxBytes = 0; - private boolean cacheOnRead = true; - private boolean cacheOnWrite = true; - private int maxConcurrentUploads = 4; - private int maxConcurrentDownloads = 8; - private long multipartThreshold = 64 * 1024 * 1024; - private long multipartPartSize = 8 * 1024 * 1024; - private boolean syncManifestToObject = true; - private boolean replicateWal = true; - private boolean walUploadSync = false; - private long walSyncThresholdBytes = 1048576; - private boolean walSyncOnCommit = false; - private boolean replicaMode = false; - private long replicaSyncIntervalUs = 5000000; - private boolean replicaReplayWal = true; - - public Builder localCachePath(String localCachePath) { - this.localCachePath = localCachePath; - return this; - } - - public Builder localCacheMaxBytes(long localCacheMaxBytes) { - this.localCacheMaxBytes = localCacheMaxBytes; - return this; - } - - public Builder cacheOnRead(boolean cacheOnRead) { - this.cacheOnRead = cacheOnRead; - return this; - } - - public Builder cacheOnWrite(boolean cacheOnWrite) { - this.cacheOnWrite = cacheOnWrite; - return this; - } - - public Builder maxConcurrentUploads(int maxConcurrentUploads) { - this.maxConcurrentUploads = maxConcurrentUploads; - return this; - } - - public Builder maxConcurrentDownloads(int maxConcurrentDownloads) { - this.maxConcurrentDownloads = maxConcurrentDownloads; - return this; - } - - public Builder multipartThreshold(long multipartThreshold) { - this.multipartThreshold = multipartThreshold; - return this; - } - - public Builder multipartPartSize(long multipartPartSize) { - this.multipartPartSize = multipartPartSize; - return this; - } - - public Builder syncManifestToObject(boolean syncManifestToObject) { - this.syncManifestToObject = syncManifestToObject; - return this; - } - - public Builder replicateWal(boolean replicateWal) { - this.replicateWal = replicateWal; - return this; - } - - public Builder walUploadSync(boolean walUploadSync) { - this.walUploadSync = walUploadSync; - return this; - } - - public Builder walSyncThresholdBytes(long walSyncThresholdBytes) { - this.walSyncThresholdBytes = walSyncThresholdBytes; - return this; - } - - public Builder walSyncOnCommit(boolean walSyncOnCommit) { - this.walSyncOnCommit = walSyncOnCommit; - return this; - } - - public Builder replicaMode(boolean replicaMode) { - this.replicaMode = replicaMode; - return this; - } - - public Builder replicaSyncIntervalUs(long replicaSyncIntervalUs) { - this.replicaSyncIntervalUs = replicaSyncIntervalUs; - return this; - } - - public Builder replicaReplayWal(boolean replicaReplayWal) { - this.replicaReplayWal = replicaReplayWal; - return this; - } - - public ObjectStoreConfig build() { - validate(); - return new ObjectStoreConfig(this); - } - - private void validate() { - if (localCacheMaxBytes < 0) { - throw new IllegalArgumentException("localCacheMaxBytes must not be negative, was: " + localCacheMaxBytes); - } - if (maxConcurrentUploads <= 0) { - throw new IllegalArgumentException("maxConcurrentUploads must be positive, was: " + maxConcurrentUploads); - } - if (maxConcurrentDownloads <= 0) { - throw new IllegalArgumentException("maxConcurrentDownloads must be positive, was: " + maxConcurrentDownloads); - } - if (multipartThreshold < 0) { - throw new IllegalArgumentException("multipartThreshold must not be negative, was: " + multipartThreshold); - } - if (multipartPartSize < 0) { - throw new IllegalArgumentException("multipartPartSize must not be negative, was: " + multipartPartSize); - } - if (walSyncThresholdBytes < 0) { - throw new IllegalArgumentException("walSyncThresholdBytes must not be negative, was: " + walSyncThresholdBytes); - } - if (replicaSyncIntervalUs < 0) { - throw new IllegalArgumentException("replicaSyncIntervalUs must not be negative, was: " + replicaSyncIntervalUs); - } - } - } -} diff --git a/src/main/java/com/tidesdb/PreparedTransaction.java b/src/main/java/com/tidesdb/PreparedTransaction.java new file mode 100644 index 0000000..7a167c1 --- /dev/null +++ b/src/main/java/com/tidesdb/PreparedTransaction.java @@ -0,0 +1,71 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * One transaction that was durably prepared before a restart and has no decision + * recorded after it, returned from {@link TidesDB#recoverPrepared()}. + * + *
Resolve it with {@link Transaction#commitPrepared()} or + * {@link Transaction#rollbackPrepared()}, then free the transaction like any + * other. A transaction that was decided in the log is settled during open and + * never appears here. + */ +public class PreparedTransaction { + + private final Transaction transaction; + private final byte[] xid; + + /** + * Creates a new {@code PreparedTransaction}. Typically called by the JNI + * bridge rather than application code. + * + * @param transaction a handle in the {@link TransactionState#PREPARED} state + * @param xid the transaction id the coordinator prepared it under + */ + public PreparedTransaction(Transaction transaction, byte[] xid) { + this.transaction = transaction; + this.xid = xid == null ? new byte[0] : xid.clone(); + } + + /** + * Returns the in-doubt transaction, in the {@link TransactionState#PREPARED} + * state. The caller owns it and must free it once resolved. + * + * @return the transaction handle + */ + public Transaction getTransaction() { + return transaction; + } + + /** + * Returns the transaction id the coordinator prepared this transaction + * under. + * + * @return a copy of the xid bytes + */ + public byte[] getXid() { + return xid.clone(); + } + + @Override + public String toString() { + return "PreparedTransaction{xidSize=" + xid.length + '}'; + } +} diff --git a/src/main/java/com/tidesdb/RangeStats.java b/src/main/java/com/tidesdb/RangeStats.java new file mode 100644 index 0000000..d1b4d6e --- /dev/null +++ b/src/main/java/com/tidesdb/RangeStats.java @@ -0,0 +1,86 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * What a query planner needs to know about a key range, returned from + * {@link ColumnFamily#rangeStats(byte[], byte[])}. Both figures are taken from + * one layout snapshot, so they describe the same instant. + */ +public class RangeStats { + + private final long sstablesOverlapping; + private final long estimatedKeys; + private final boolean keysExact; + + /** + * Creates a new {@code RangeStats}. Typically called by the JNI bridge + * rather than application code. + * + * @param sstablesOverlapping sorted runs a scan of the range would merge + * @param estimatedKeys live keys the range holds + * @param keysExact whether {@code estimatedKeys} was counted rather than + * estimated from metadata + */ + public RangeStats(long sstablesOverlapping, long estimatedKeys, boolean keysExact) { + this.sstablesOverlapping = sstablesOverlapping; + this.estimatedKeys = estimatedKeys; + this.keysExact = keysExact; + } + + /** + * Returns the number of sorted runs a scan of the range would merge, which + * is the shape of its cost. + * + * @return the overlapping SSTable count + */ + public long getSstablesOverlapping() { + return sstablesOverlapping; + } + + /** + * Returns the live keys the range holds, with tombstoned and superseded + * versions excluded. + * + * @return the key count + */ + public long getEstimatedKeys() { + return estimatedKeys; + } + + /** + * Returns whether {@link #getEstimatedKeys()} was counted rather than + * estimated from metadata, so a planner can trust it outright instead of + * hedging. + * + * @return {@code true} when the count is exact + */ + public boolean isKeysExact() { + return keysExact; + } + + @Override + public String toString() { + return "RangeStats{" + + "sstablesOverlapping=" + sstablesOverlapping + + ", estimatedKeys=" + estimatedKeys + + ", keysExact=" + keysExact + + '}'; + } +} diff --git a/src/main/java/com/tidesdb/S3Config.java b/src/main/java/com/tidesdb/S3Config.java deleted file mode 100644 index 4b070da..0000000 --- a/src/main/java/com/tidesdb/S3Config.java +++ /dev/null @@ -1,169 +0,0 @@ -/** - * - * Copyright (C) TidesDB - * - * Original Author: Alex Gaetano Padula - * - * Licensed under the Mozilla Public License, v. 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 - * - * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; - -/** - * Configuration for an S3-compatible object store connector (AWS S3, MinIO, etc.). - * - *
Mirrors {@code tidesdb_objstore_s3_config_t}. Set it on a {@link Config} via - * {@link Config.Builder#objectStoreS3Config(S3Config)} to back a database with object storage. - * Combine it with an {@link ObjectStoreConfig} (cache, multipart, WAL replication, replica mode) - * for full control over object store behavior. - * - *
Requires the native TidesDB library to have been built with {@code TIDESDB_WITH_S3=ON}; - * otherwise opening the database throws a {@link TidesDBException}. Use - * {@link TidesDB#isS3Available()} to probe support at runtime. - */ -public class S3Config { - - private final String endpoint; - private final String bucket; - private final String prefix; - private final String accessKey; - private final String secretKey; - private final String region; - private final boolean useSsl; - private final boolean usePathStyle; - private final String tlsCaPath; - private final boolean tlsInsecureSkipVerify; - private final long multipartThreshold; - private final long multipartPartSize; - - private S3Config(Builder builder) { - this.endpoint = builder.endpoint; - this.bucket = builder.bucket; - this.prefix = builder.prefix; - this.accessKey = builder.accessKey; - this.secretKey = builder.secretKey; - this.region = builder.region; - this.useSsl = builder.useSsl; - this.usePathStyle = builder.usePathStyle; - this.tlsCaPath = builder.tlsCaPath; - this.tlsInsecureSkipVerify = builder.tlsInsecureSkipVerify; - this.multipartThreshold = builder.multipartThreshold; - this.multipartPartSize = builder.multipartPartSize; - } - - public static Builder builder() { - return new Builder(); - } - - public String getEndpoint() { return endpoint; } - public String getBucket() { return bucket; } - public String getPrefix() { return prefix; } - public String getAccessKey() { return accessKey; } - public String getSecretKey() { return secretKey; } - public String getRegion() { return region; } - public boolean isUseSsl() { return useSsl; } - public boolean isUsePathStyle() { return usePathStyle; } - public String getTlsCaPath() { return tlsCaPath; } - public boolean isTlsInsecureSkipVerify() { return tlsInsecureSkipVerify; } - public long getMultipartThreshold() { return multipartThreshold; } - public long getMultipartPartSize() { return multipartPartSize; } - - /** - * Builder for {@link S3Config}. {@code endpoint}, {@code bucket}, {@code accessKey}, and - * {@code secretKey} are required; the rest default to secure, AWS-friendly values - * (HTTPS on, virtual-hosted URLs, TLS verification enabled, built-in multipart sizing). - */ - public static class Builder { - private String endpoint; - private String bucket; - private String prefix = null; - private String accessKey; - private String secretKey; - private String region = null; - private boolean useSsl = true; - private boolean usePathStyle = false; - private String tlsCaPath = null; - private boolean tlsInsecureSkipVerify = false; - private long multipartThreshold = 0; // 0 = library default - private long multipartPartSize = 0; // 0 = library default - - /** S3 endpoint, e.g. "s3.amazonaws.com" or "minio.local:9000" (required). */ - public Builder endpoint(String endpoint) { this.endpoint = endpoint; return this; } - - /** Bucket name (required). */ - public Builder bucket(String bucket) { this.bucket = bucket; return this; } - - /** Key prefix, e.g. "production/db1/" (optional). */ - public Builder prefix(String prefix) { this.prefix = prefix; return this; } - - /** AWS access key ID (required). */ - public Builder accessKey(String accessKey) { this.accessKey = accessKey; return this; } - - /** AWS secret access key (required). */ - public Builder secretKey(String secretKey) { this.secretKey = secretKey; return this; } - - /** AWS region, e.g. "us-east-1"; null for MinIO/default. */ - public Builder region(String region) { this.region = region; return this; } - - /** 1 for HTTPS (default), 0 for HTTP. */ - public Builder useSsl(boolean useSsl) { this.useSsl = useSsl; return this; } - - /** Path-style URLs (MinIO) when true; virtual-hosted (AWS) when false (default). */ - public Builder usePathStyle(boolean usePathStyle) { this.usePathStyle = usePathStyle; return this; } - - /** Custom CA bundle file path, or null for the system bundle. */ - public Builder tlsCaPath(String tlsCaPath) { this.tlsCaPath = tlsCaPath; return this; } - - /** - * Disable TLS peer and host verification when true (test only, insecure). Default false - * keeps verification on. - */ - public Builder tlsInsecureSkipVerify(boolean tlsInsecureSkipVerify) { - this.tlsInsecureSkipVerify = tlsInsecureSkipVerify; - return this; - } - - /** Object size at/above which multipart upload is used; 0 uses the library default. */ - public Builder multipartThreshold(long multipartThreshold) { - this.multipartThreshold = multipartThreshold; - return this; - } - - /** Multipart chunk size in bytes; 0 uses the library default. */ - public Builder multipartPartSize(long multipartPartSize) { - this.multipartPartSize = multipartPartSize; - return this; - } - - public S3Config build() { - if (endpoint == null || endpoint.isEmpty()) { - throw new IllegalArgumentException("S3 endpoint is required"); - } - if (bucket == null || bucket.isEmpty()) { - throw new IllegalArgumentException("S3 bucket is required"); - } - if (accessKey == null || accessKey.isEmpty()) { - throw new IllegalArgumentException("S3 access key is required"); - } - if (secretKey == null || secretKey.isEmpty()) { - throw new IllegalArgumentException("S3 secret key is required"); - } - if (multipartThreshold < 0) { - throw new IllegalArgumentException("multipartThreshold must not be negative, was: " + multipartThreshold); - } - if (multipartPartSize < 0) { - throw new IllegalArgumentException("multipartPartSize must not be negative, was: " + multipartPartSize); - } - return new S3Config(this); - } - } -} diff --git a/src/main/java/com/tidesdb/Snapshot.java b/src/main/java/com/tidesdb/Snapshot.java new file mode 100644 index 0000000..719514a --- /dev/null +++ b/src/main/java/com/tidesdb/Snapshot.java @@ -0,0 +1,112 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +import java.io.Closeable; + +/** + * Names the database as it stood at a point in time, so it can be read again + * later through {@link TidesDB#beginTransactionAtSnapshot(Snapshot)}. + * + *
A snapshot holds the reclamation floor at its own sequence for as long as + * it lives, which is what keeps the versions it resolves to from being compacted + * away, and is also its cost: the same cost a long-running transaction has. + * Release it as soon as the point in time is no longer wanted. + * + *
{@code Snapshot} implements {@link java.io.Closeable} for use with + * try-with-resources. Every {@link Transaction} opened against a snapshot must + * be freed before the snapshot is released, since they read versions only it + * keeps alive. + * + *
This class is not guaranteed to be thread-safe. + */ +public class Snapshot implements Closeable { + + static { + NativeLibrary.load(); + } + + private long nativeHandle; + private final long seq; + private boolean released = false; + + Snapshot(long nativeHandle) { + this.nativeHandle = nativeHandle; + this.seq = nativeSeq(nativeHandle); + } + + /** + * Returns the sequence this snapshot reads at, for reporting and for + * comparing against {@link DbStats#getMinSnapshotSeq()}. + * + *
The sequence is read once at creation and stays available after the + * snapshot is released. + * + * @return the snapshot sequence + */ + public long getSeq() { + return seq; + } + + /** + * Releases the snapshot and the reclamation floor it was holding. + * + *
This method is idempotent; subsequent calls are no-ops. + */ + public void release() { + if (!released && nativeHandle != 0) { + nativeRelease(nativeHandle); + nativeHandle = 0; + released = true; + } + } + + /** + * Releases this snapshot. Equivalent to {@link #release()}. + */ + @Override + public void close() { + release(); + } + + /** + * Reports whether this snapshot has been released. + * + * @return {@code true} once released + */ + public boolean isReleased() { + return released; + } + + long getNativeHandle() { + if (released) { + throw new IllegalStateException("Snapshot has been released"); + } + return nativeHandle; + } + + @Override + public String toString() { + return "Snapshot{seq=" + seq + ", released=" + released + '}'; + } + + private static native long nativeSeq(long handle); + + private static native void nativeRelease(long handle); +} diff --git a/src/main/java/com/tidesdb/StallReason.java b/src/main/java/com/tidesdb/StallReason.java new file mode 100644 index 0000000..782555f --- /dev/null +++ b/src/main/java/com/tidesdb/StallReason.java @@ -0,0 +1,109 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * The places a caller's own thread can be made to wait inside a write. A commit + * that took far longer than its peers was held at exactly one of these, and + * knowing which is the difference between a device that cannot keep up and an + * engine that is not letting it. + * + *
The ordinal of each constant is its index into {@link StallStats}. + */ +public enum StallReason { + + /** + * Waiting on the write-ahead log, either for staging-ring space or, under a + * syncing mode, for the record to reach the file. The two are one figure + * because both are the same wait on the same single writer. + */ + WAL_APPEND(0), + + /** + * Waiting to take the rotation lock, so another committer was rotating. + */ + ROTATE_LOCK(1), + + /** + * Performing the rotation, which this thread pays on everyone's behalf. + */ + ROTATE_WORK(2), + + /** + * Held by write admission because the unflushed backlog was too deep. + */ + ADMISSION(3), + + /** + * Inside a manifest commit, which every flush install, every compaction + * install and every DDL serialises through, so a database making no + * progress is often waiting here. + */ + MANIFEST_COMMIT(4); + + static { + NativeLibrary.load(); + } + + private final int value; + + StallReason(int value) { + this.value = value; + } + + /** + * Returns the JNI numeric mapping for this stall reason, which is also its + * index into {@link StallStats}. + * + * @return the integer value used by the native library + */ + public int getValue() { + return value; + } + + /** + * Returns the native library's stable short name for this reason, suitable + * for a log line or a stats table's row label. + * + * @return the name, never {@code null} + */ + public String getNativeName() { + return nativeName(value); + } + + private static native String nativeName(int reason); + + /** + * Returns the {@link StallReason} constant matching the given JNI integer + * value. + * + * @param value the JNI integer value + * @return the matching constant + * @throws IllegalArgumentException if {@code value} does not map to any + * known constant + */ + public static StallReason fromValue(int value) { + for (StallReason reason : values()) { + if (reason.value == value) { + return reason; + } + } + throw new IllegalArgumentException("Unknown stall reason value: " + value); + } +} diff --git a/src/main/java/com/tidesdb/StallStat.java b/src/main/java/com/tidesdb/StallStat.java new file mode 100644 index 0000000..e469321 --- /dev/null +++ b/src/main/java/com/tidesdb/StallStat.java @@ -0,0 +1,81 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * How much waiting one {@link StallReason} accounted for since the database + * opened. Created by the native library and returned inside {@link StallStats}. + */ +public class StallStat { + + private final long count; + private final long totalUs; + private final long maxUs; + + /** + * Creates a new {@code StallStat}. Typically called by the JNI bridge + * rather than application code. + * + * @param count how many times a thread waited here + * @param totalUs the summed wait in microseconds + * @param maxUs the longest single wait in microseconds + */ + public StallStat(long count, long totalUs, long maxUs) { + this.count = count; + this.totalUs = totalUs; + this.maxUs = maxUs; + } + + /** + * Returns how many times a thread waited here. + * + * @return the wait count + */ + public long getCount() { + return count; + } + + /** + * Returns the summed wait, so a reason's share of all waiting is + * comparable. + * + * @return the total wait in microseconds + */ + public long getTotalUs() { + return totalUs; + } + + /** + * Returns the longest single wait, which is what a latency tail is made of. + * + * @return the longest wait in microseconds + */ + public long getMaxUs() { + return maxUs; + } + + @Override + public String toString() { + return "StallStat{" + + "count=" + count + + ", totalUs=" + totalUs + + ", maxUs=" + maxUs + + '}'; + } +} diff --git a/src/main/java/com/tidesdb/StallStats.java b/src/main/java/com/tidesdb/StallStats.java new file mode 100644 index 0000000..e1dab4c --- /dev/null +++ b/src/main/java/com/tidesdb/StallStats.java @@ -0,0 +1,95 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * Where writers have been made to wait, one entry per {@link StallReason}, + * returned from {@link TidesDB#getStallStats()}. + * + *
A write latency tail is answerable from this alone: compare each reason's + * {@link StallStat#getMaxUs()} against the tail you measured, and its + * {@link StallStat#getTotalUs()} against the others. + */ +public class StallStats { + + private final StallStat[] reasons; + + /** + * Creates a new {@code StallStats}. Typically called by the JNI bridge + * rather than application code. + * + * @param reasons the per-reason totals, indexed by {@link StallReason#getValue()}; + * must not be {@code null} and must hold one entry per reason + */ + public StallStats(StallStat[] reasons) { + if (reasons == null || reasons.length != StallReason.values().length) { + throw new IllegalArgumentException( + "reasons must hold exactly " + StallReason.values().length + " entries"); + } + this.reasons = reasons.clone(); + } + + /** + * Returns the totals for one wait reason. + * + * @param reason the reason; must not be {@code null} + * @return the totals for that reason, never {@code null} + */ + public StallStat get(StallReason reason) { + if (reason == null) { + throw new IllegalArgumentException("Stall reason cannot be null"); + } + return reasons[reason.getValue()]; + } + + /** + * Returns the totals for every wait reason, indexed by + * {@link StallReason#getValue()}. + * + * @return a copy of the per-reason totals + */ + public StallStat[] getReasons() { + return reasons.clone(); + } + + /** + * Returns the summed wait across every reason. + * + * @return the total wait in microseconds + */ + public long getTotalUs() { + long total = 0; + for (StallStat stat : reasons) { + total += stat.getTotalUs(); + } + return total; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("StallStats{"); + for (StallReason reason : StallReason.values()) { + if (reason.getValue() > 0) { + sb.append(", "); + } + sb.append(reason.name()).append('=').append(reasons[reason.getValue()]); + } + return sb.append('}').toString(); + } +} diff --git a/src/main/java/com/tidesdb/Stats.java b/src/main/java/com/tidesdb/Stats.java deleted file mode 100644 index 5122b1c..0000000 --- a/src/main/java/com/tidesdb/Stats.java +++ /dev/null @@ -1,470 +0,0 @@ -/** - * - * Copyright (C) TidesDB - * - * Original Author: Alex Gaetano Padula - * - * Licensed under the Mozilla Public License, v. 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 - * - * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; - -/** - * Statistics about a column family, including level structure, memtable size, - * and the active configuration. Created by the native library and returned - * from {@link ColumnFamily#getStats()}. - * - *
The arrays returned by {@link #getLevelSizes()} and - * {@link #getLevelNumSSTables()} are the internal stored references, not - * defensive copies. Callers must not modify them. - */ -public class Stats { - - private final int numLevels; - private final long memtableSize; - private final long[] levelSizes; - private final int[] levelNumSSTables; - private final ColumnFamilyConfig config; - private final long totalKeys; - private final long totalDataSize; - private final double avgKeySize; - private final double avgValueSize; - private final long[] levelKeyCounts; - private final double readAmp; - private final double hitRate; - private final boolean useBtree; - private final long btreeTotalNodes; - private final int btreeMaxHeight; - private final double btreeAvgHeight; - private final long totalTombstones; - private final double tombstoneRatio; - private final long[] levelTombstoneCounts; - private final double maxSstDensity; - private final int maxSstDensityLevel; - private final long walBytesWritten; - private final long flushBytesWritten; - private final long compactionBytesWritten; - private final long compactionBytesRead; - private final long userBytesWritten; - private final long flushCount; - private final long compactionCount; - - /** - * Creates a new {@code Stats} instance. Typically called by the JNI - * bridge rather than application code. - * - * @param numLevels the number of LSM levels - * @param memtableSize the current memtable size in bytes - * @param levelSizes the size in bytes for each level (stored by reference) - * @param levelNumSSTables the SSTable count for each level (stored by reference) - * @param config the column family configuration - * @param totalKeys total number of keys across memtable and all SSTables - * @param totalDataSize total data size (klog + vlog) across all SSTables - * @param avgKeySize average key size in bytes - * @param avgValueSize average value size in bytes - * @param levelKeyCounts number of keys per level - * @param readAmp read amplification (point lookup cost multiplier) - * @param hitRate cache hit rate (0.0 to 1.0) - * @param useBtree whether this column family uses B+tree format - * @param btreeTotalNodes total number of B+tree nodes across all SSTables - * @param btreeMaxHeight maximum B+tree height across all SSTables - * @param btreeAvgHeight average B+tree height across all SSTables - * @param totalTombstones total number of tombstones across every SSTable - * @param tombstoneRatio tombstone ratio (totalTombstones / totalKeys) - * @param levelTombstoneCounts per-level tombstone counts - * @param maxSstDensity worst per-SSTable tombstone density - * @param maxSstDensityLevel 1-based level index where worst density was observed - * @param walBytesWritten framed bytes appended to WAL (lifetime since open) - * @param flushBytesWritten on-disk bytes flushes wrote to L0 SSTables - * @param compactionBytesWritten on-disk bytes compactions wrote - * @param compactionBytesRead on-disk bytes compactions read as input - * @param userBytesWritten logical key+value bytes committed - * @param flushCount number of flushed SSTables produced - * @param compactionCount number of compaction output SSTables produced - */ - public Stats(int numLevels, long memtableSize, long[] levelSizes, int[] levelNumSSTables, - ColumnFamilyConfig config, long totalKeys, long totalDataSize, - double avgKeySize, double avgValueSize, long[] levelKeyCounts, - double readAmp, double hitRate, boolean useBtree, long btreeTotalNodes, - int btreeMaxHeight, double btreeAvgHeight, - long totalTombstones, double tombstoneRatio, long[] levelTombstoneCounts, - double maxSstDensity, int maxSstDensityLevel, - long walBytesWritten, long flushBytesWritten, long compactionBytesWritten, - long compactionBytesRead, long userBytesWritten, long flushCount, - long compactionCount) { - this.numLevels = numLevels; - this.memtableSize = memtableSize; - this.levelSizes = levelSizes; - this.levelNumSSTables = levelNumSSTables; - this.config = config; - this.totalKeys = totalKeys; - this.totalDataSize = totalDataSize; - this.avgKeySize = avgKeySize; - this.avgValueSize = avgValueSize; - this.levelKeyCounts = levelKeyCounts; - this.readAmp = readAmp; - this.hitRate = hitRate; - this.useBtree = useBtree; - this.btreeTotalNodes = btreeTotalNodes; - this.btreeMaxHeight = btreeMaxHeight; - this.btreeAvgHeight = btreeAvgHeight; - this.totalTombstones = totalTombstones; - this.tombstoneRatio = tombstoneRatio; - this.levelTombstoneCounts = levelTombstoneCounts; - this.maxSstDensity = maxSstDensity; - this.maxSstDensityLevel = maxSstDensityLevel; - this.walBytesWritten = walBytesWritten; - this.flushBytesWritten = flushBytesWritten; - this.compactionBytesWritten = compactionBytesWritten; - this.compactionBytesRead = compactionBytesRead; - this.userBytesWritten = userBytesWritten; - this.flushCount = flushCount; - this.compactionCount = compactionCount; - } - - /** - * Returns the number of LSM levels. - * - * @return the number of levels - */ - public int getNumLevels() { - return numLevels; - } - - /** - * Returns the current memtable size in bytes. - * - * @return the memtable size in bytes - */ - public long getMemtableSize() { - return memtableSize; - } - - /** - * Returns the sizes of each LSM level in bytes. - * - *
The returned array is the stored internal reference, not a - * defensive copy. Callers must not modify it. - * - * @return the level sizes in bytes (internal reference) - */ - public long[] getLevelSizes() { - return levelSizes; - } - - /** - * Returns the number of SSTables at each LSM level. - * - *
The returned array is the stored internal reference, not a - * defensive copy. Callers must not modify it. - * - * @return the SSTable counts per level (internal reference) - */ - public int[] getLevelNumSSTables() { - return levelNumSSTables; - } - - /** - * Returns the column family configuration active when these statistics - * were captured. - * - * @return the configuration, never {@code null} - */ - public ColumnFamilyConfig getConfig() { - return config; - } - - /** - * Gets the total number of keys across memtable and all SSTables. - * - * @return total key count - */ - public long getTotalKeys() { - return totalKeys; - } - - /** - * Gets the total data size (klog + vlog) across all SSTables. - * - * @return total data size in bytes - */ - public long getTotalDataSize() { - return totalDataSize; - } - - /** - * Gets the average key size in bytes. - * - * @return average key size - */ - public double getAvgKeySize() { - return avgKeySize; - } - - /** - * Gets the average value size in bytes. - * - * @return average value size - */ - public double getAvgValueSize() { - return avgValueSize; - } - - /** - * Gets the number of keys per level. - * - * @return array of key counts per level - */ - public long[] getLevelKeyCounts() { - return levelKeyCounts; - } - - /** - * Gets the read amplification (point lookup cost multiplier). - * - * @return read amplification factor - */ - public double getReadAmp() { - return readAmp; - } - - /** - * Gets the cache hit rate for this column family. - * - * @return hit rate (0.0 to 1.0), or 0.0 if cache is disabled - */ - public double getHitRate() { - return hitRate; - } - - /** - * Returns whether this column family uses B+tree format. - * - * @return true if B+tree format is used - */ - public boolean isUseBtree() { - return useBtree; - } - - /** - * Gets the total number of B+tree nodes across all SSTables. - * Only populated when useBtree is true. - * - * @return total B+tree nodes - */ - public long getBtreeTotalNodes() { - return btreeTotalNodes; - } - - /** - * Gets the maximum B+tree height across all SSTables. - * Only populated when useBtree is true. - * - * @return maximum tree height - */ - public int getBtreeMaxHeight() { - return btreeMaxHeight; - } - - /** - * Gets the average B+tree height across all SSTables. - * Only populated when useBtree is true. - * - * @return average tree height - */ - public double getBtreeAvgHeight() { - return btreeAvgHeight; - } - - /** - * Gets the total number of tombstones across every SSTable in the column family. - * - * @return total tombstone count - */ - public long getTotalTombstones() { - return totalTombstones; - } - - /** - * Gets the tombstone ratio (totalTombstones / totalKeys). - * Returns 0.0 when totalKeys is 0. Always within [0.0, 1.0]. - * - * @return tombstone ratio - */ - public double getTombstoneRatio() { - return tombstoneRatio; - } - - /** - * Gets the per-level tombstone counts. Length matches numLevels and parallels - * {@link #getLevelKeyCounts()}. - * - * @return per-level tombstone counts - */ - public long[] getLevelTombstoneCounts() { - return levelTombstoneCounts; - } - - /** - * Gets the worst per-SSTable tombstone density (tombstone_count / num_entries) - * observed in this column family. Always within [0.0, 1.0]. - * - * @return max per-SSTable tombstone density - */ - public double getMaxSstDensity() { - return maxSstDensity; - } - - /** - * Gets the 1-based level index where the worst per-SSTable tombstone density - * was observed. Returns 0 if no SSTable contributed to the measurement. - * - * @return 1-based level index of the worst SSTable, or 0 if none - */ - public int getMaxSstDensityLevel() { - return maxSstDensityLevel; - } - - /** - * Gets the framed bytes appended to this column family's WAL (lifetime since open). - * Always 0 in unified memtable mode, where the shared WAL volume is reported db-wide - * via {@link DbStats#getUwalBytesWritten()}. - * - * @return WAL bytes written - */ - public long getWalBytesWritten() { - return walBytesWritten; - } - - /** - * Gets the on-disk bytes this column family's flushes wrote to L0 SSTables - * (lifetime since open). - * - * @return flush output bytes written - */ - public long getFlushBytesWritten() { - return flushBytesWritten; - } - - /** - * Gets the on-disk bytes this column family's compactions wrote (lifetime since open). - * - * @return compaction output bytes written - */ - public long getCompactionBytesWritten() { - return compactionBytesWritten; - } - - /** - * Gets the on-disk bytes this column family's compactions read as input - * (lifetime since open). - * - * @return compaction input bytes read - */ - public long getCompactionBytesRead() { - return compactionBytesRead; - } - - /** - * Gets the logical key+value bytes committed to this column family (lifetime since open). - * This is the write-amplification denominator: divide the WAL, flush, and compaction - * write totals by this value to compute write amplification. - * - * @return user bytes written - */ - public long getUserBytesWritten() { - return userBytesWritten; - } - - /** - * Gets the number of flushed SSTables produced by this column family (lifetime since open). - * - * @return flush count - */ - public long getFlushCount() { - return flushCount; - } - - /** - * Gets the number of compaction output SSTables produced by this column family - * (lifetime since open). - * - * @return compaction count - */ - public long getCompactionCount() { - return compactionCount; - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("Stats{numLevels=").append(numLevels); - sb.append(", memtableSize=").append(memtableSize); - sb.append(", totalKeys=").append(totalKeys); - sb.append(", totalDataSize=").append(totalDataSize); - sb.append(", avgKeySize=").append(avgKeySize); - sb.append(", avgValueSize=").append(avgValueSize); - sb.append(", readAmp=").append(readAmp); - sb.append(", hitRate=").append(hitRate); - sb.append(", useBtree=").append(useBtree); - if (useBtree) { - sb.append(", btreeTotalNodes=").append(btreeTotalNodes); - sb.append(", btreeMaxHeight=").append(btreeMaxHeight); - sb.append(", btreeAvgHeight=").append(btreeAvgHeight); - } - sb.append(", totalTombstones=").append(totalTombstones); - sb.append(", tombstoneRatio=").append(tombstoneRatio); - sb.append(", maxSstDensity=").append(maxSstDensity); - sb.append(", maxSstDensityLevel=").append(maxSstDensityLevel); - sb.append(", walBytesWritten=").append(walBytesWritten); - sb.append(", flushBytesWritten=").append(flushBytesWritten); - sb.append(", compactionBytesWritten=").append(compactionBytesWritten); - sb.append(", compactionBytesRead=").append(compactionBytesRead); - sb.append(", userBytesWritten=").append(userBytesWritten); - sb.append(", flushCount=").append(flushCount); - sb.append(", compactionCount=").append(compactionCount); - if (levelSizes != null) { - sb.append(", levelSizes=["); - for (int i = 0; i < levelSizes.length; i++) { - if (i > 0) sb.append(", "); - sb.append(levelSizes[i]); - } - sb.append("]"); - } - if (levelNumSSTables != null) { - sb.append(", levelNumSSTables=["); - for (int i = 0; i < levelNumSSTables.length; i++) { - if (i > 0) sb.append(", "); - sb.append(levelNumSSTables[i]); - } - sb.append("]"); - } - if (levelKeyCounts != null) { - sb.append(", levelKeyCounts=["); - for (int i = 0; i < levelKeyCounts.length; i++) { - if (i > 0) sb.append(", "); - sb.append(levelKeyCounts[i]); - } - sb.append("]"); - } - if (levelTombstoneCounts != null) { - sb.append(", levelTombstoneCounts=["); - for (int i = 0; i < levelTombstoneCounts.length; i++) { - if (i > 0) sb.append(", "); - sb.append(levelTombstoneCounts[i]); - } - sb.append("]"); - } - sb.append("}"); - return sb.toString(); - } -} diff --git a/src/main/java/com/tidesdb/TidesDB.java b/src/main/java/com/tidesdb/TidesDB.java index 3017e3e..419fc89 100644 --- a/src/main/java/com/tidesdb/TidesDB.java +++ b/src/main/java/com/tidesdb/TidesDB.java @@ -29,36 +29,44 @@ * library through JNI and owns the underlying database handle. * *
{@code TidesDB} implements {@link java.io.Closeable} and should be used with - * try-with-resources. Callers must close any {@link TidesDBIterator} and - * {@link Transaction} instances before closing the database. Closing an already-closed - * instance is a no-op. + * try-with-resources. Callers must close any {@link TidesDBIterator}, + * {@link Transaction}, and {@link Snapshot} derived from it before closing the + * database. Closing an already-closed instance is a no-op. * *
Operations on a closed instance throw {@link IllegalStateException}. * + *
The memtable, write-ahead log, block cache, and value log are + * database-level and shared by every column family, so the operations that act + * on them — {@link #flushMemtable()}, {@link #syncWal()}, {@link #checkpoint()} — + * live here rather than on {@link ColumnFamily}. + * *
This class is not guaranteed to be thread-safe.
*/
public class TidesDB implements Closeable {
-
+
static {
NativeLibrary.load();
}
-
+
private long nativeHandle;
private boolean closed = false;
private final Set {@link CompressionAlgorithm#NONE} is always available.
+ *
+ * @param algorithm the algorithm to query; must not be {@code null}
+ * @return {@code true} if this build can compress and decompress with it
+ */
+ public static boolean isCompressionAvailable(CompressionAlgorithm algorithm) {
+ if (algorithm == null) {
+ throw new IllegalArgumentException("Compression algorithm cannot be null");
+ }
+ return nativeCompressionAvailable(algorithm.getValue());
+ }
+
+ /**
+ * Returns the native library's short description of a result code. An
+ * unrecognised code describes itself as unknown rather than failing.
+ *
+ * @param code any {@code ERR_*} value from {@link TidesDBException}
+ * @return the description, never {@code null}
+ */
+ public static String strerror(int code) {
+ return nativeStrerror(code);
+ }
+
+ /**
+ * Raises this process's open-file ceiling toward {@code desired} descriptors
+ * so a database can keep more SSTables open. The engine sizes
+ * {@link Config#getMaxOpenSSTables()} to fit this at open time, so call it
+ * before {@link #open(Config)}. This is an explicit, opt-in
+ * operator action; TidesDB never raises the limit itself.
+ *
+ * On POSIX systems this raises the {@code RLIMIT_NOFILE} soft limit toward
+ * the hard limit; on Windows it raises the CRT stdio cap (max 8192). A failed
+ * or partial raise is non-fatal.
*
- * @return true if an S3-compatible connector can be created, false otherwise
+ * @param desired target descriptor count; values ≤ 0 just report the
+ * current ceiling
+ * @return the open-file ceiling in effect after the attempt
*/
- public static boolean isS3Available() {
- return nativeS3Available();
+ public static long raiseOpenFileLimit(long desired) {
+ return nativeRaiseOpenFileLimit(desired);
}
-
+
/**
- * Closes the database and releases all native resources.
+ * Flushes, quiesces the workers, and releases all native resources.
*
* This method is idempotent; subsequent calls are no-ops. After closing,
* all other operations on this instance throw {@link IllegalStateException}.
*
- * Callers should close all {@link TidesDBIterator} and {@link Transaction}
- * instances before calling this method.
+ * Callers should close all {@link TidesDBIterator}, {@link Transaction},
+ * and {@link Snapshot} instances before calling this method.
+ *
+ * The shutdown path reports no status, so an I/O error while writing the
+ * last of the data is logged rather than thrown. A caller that needs its data
+ * on the device asks for that before closing, with {@link #syncWal()},
+ * {@link #checkpoint()}, or by running under {@link SyncMode#SYNC_FULL}.
*/
@Override
public void close() {
if (!closed && nativeHandle != 0) {
closed = true;
- // Drain all registered hooks. Loop because a concurrent setCommitHook
+ // Drain all registered hooks. Loop because a concurrent setCommitHook
// may have passed checkOwnerOpen before closed=true but hasn't
// registered yet; its registration is rejected (closed is true), but a
// retry loop guarantees the set is empty before calling nativeClose.
@@ -178,12 +188,17 @@ public void close() {
nativeHandle = 0;
}
}
-
+
+ /* ===== column families ===== */
+
/**
- * Creates a new column family with the given configuration.
+ * Creates a column family and registers it in the manifest.
*
- * @param name the column family name; must not be {@code null} or empty
- * @param config the column family configuration; must not be {@code null}
+ * @param name the column family name; must not be {@code null} or empty, and
+ * must be shorter than {@link ColumnFamilyConfig#MAX_NAME_LENGTH}
+ * @param config the column family configuration; must not be {@code null}.
+ * Its {@code name} field is ignored, since the {@code name} argument
+ * is authoritative
* @throws IllegalArgumentException if {@code name} is {@code null} or empty,
* or {@code config} is {@code null}
* @throws IllegalStateException if this database is closed
@@ -197,38 +212,30 @@ public void createColumnFamily(String name, ColumnFamilyConfig config) throws Ti
if (config == null) {
throw new IllegalArgumentException("Column family config cannot be null");
}
-
+
nativeCreateColumnFamily(nativeHandle, name,
- config.getWriteBufferSize(),
config.getLevelSizeRatio(),
config.getMinLevels(),
config.getDividingLevelOffset(),
- config.getKlogValueThreshold(),
- config.getCompressionAlgorithm().getValue(),
+ config.isKeepValuesInline(),
+ config.getBtreeKlogBlockSize(),
+ config.getEncodingPipeline(),
config.isEnableBloomFilter(),
- config.getBloomFPR(),
- config.isEnableBlockIndexes(),
- config.getIndexSampleRatio(),
- config.getBlockIndexPrefixLen(),
- config.getSyncMode().getValue(),
- config.getSyncIntervalUs(),
- config.getComparatorName(),
- config.getSkipListMaxLevel(),
- config.getSkipListProbability(),
+ config.getBloomFpr(),
config.getDefaultIsolationLevel().getValue(),
- config.getMinDiskSpace(),
config.getL1FileCountTrigger(),
- config.getL0QueueStallThreshold(),
config.getTombstoneDensityTrigger(),
- config.getTombstoneDensityMinEntries(),
- config.isUseBtree(),
- config.isObjectLazyCompaction(),
- config.isObjectPrefetchCompaction()
+ config.getTombstoneDensityMinEntries()
);
}
-
+
/**
- * Drops a column family and all associated data.
+ * Drops a column family by name, deleting its SSTables and manifest records.
+ * Waits out any compaction already running on the family, so a heavily
+ * compacting family takes longer to drop.
+ *
+ * This destroys data and cannot be undone. Any {@link ColumnFamily} handle
+ * held for the family is invalid afterwards.
*
* @param name the column family name; must not be {@code null} or empty
* @throws IllegalArgumentException if {@code name} is {@code null} or empty
@@ -242,16 +249,67 @@ public void dropColumnFamily(String name) throws TidesDBException {
}
nativeDropColumnFamily(nativeHandle, name);
}
-
+
+ /**
+ * Atomically renames a column family. The family is claimed against the
+ * compaction scheduler, the whole database memtable is flushed — it is
+ * shared, so this is not just this family's data — and new flushes are frozen
+ * while the directory moves and the family reloads.
+ *
+ * @param oldName the current column family name
+ * @param newName the new column family name
+ * @throws IllegalArgumentException if either name is {@code null} or empty
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the rename fails, including
+ * {@link TidesDBException#ERR_LOCKED} when the family stayed under
+ * compaction for the whole quiesce window
+ */
+ public void renameColumnFamily(String oldName, String newName) throws TidesDBException {
+ checkNotClosed();
+ if (oldName == null || oldName.isEmpty()) {
+ throw new IllegalArgumentException("Old column family name cannot be null or empty");
+ }
+ if (newName == null || newName.isEmpty()) {
+ throw new IllegalArgumentException("New column family name cannot be null or empty");
+ }
+ nativeRenameColumnFamily(nativeHandle, oldName, newName);
+ }
+
/**
- * Retrieves a column family by name.
+ * Clones a column family to a new name, copying its SSTables. The source is
+ * claimed against the compaction scheduler and the whole database memtable is
+ * flushed first, so the copy carries everything written before the call
+ * rather than only what had already reached disk.
+ *
+ * The result is a point-in-time copy: later writes to the source do not
+ * appear in it.
+ *
+ * @param sourceName the source column family name
+ * @param destName the new cloned column family name
+ * @throws IllegalArgumentException if either name is {@code null} or empty
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the clone fails
+ */
+ public void cloneColumnFamily(String sourceName, String destName) throws TidesDBException {
+ checkNotClosed();
+ if (sourceName == null || sourceName.isEmpty()) {
+ throw new IllegalArgumentException("Source column family name cannot be null or empty");
+ }
+ if (destName == null || destName.isEmpty()) {
+ throw new IllegalArgumentException("Destination column family name cannot be null or empty");
+ }
+ nativeCloneColumnFamily(nativeHandle, sourceName, destName);
+ }
+
+ /**
+ * Looks up a column family handle by name.
*
* @param name the column family name; must not be {@code null} or empty
- * @return the column family handle
+ * @return the column family handle, never {@code null}
* @throws IllegalArgumentException if {@code name} is {@code null} or empty
* @throws IllegalStateException if this database is closed
- * @throws TidesDBException if the column family is not found or a native
- * error occurs
+ * @throws TidesDBException with {@link TidesDBException#ERR_NOT_FOUND} if no
+ * such column family exists
*/
public ColumnFamily getColumnFamily(String name) throws TidesDBException {
checkNotClosed();
@@ -261,11 +319,12 @@ public ColumnFamily getColumnFamily(String name) throws TidesDBException {
long cfHandle = nativeGetColumnFamily(nativeHandle, name);
return new ColumnFamily(cfHandle, name, this);
}
-
+
/**
- * Lists all column families in the database.
+ * Lists the names of every column family.
*
- * @return array of column family names, never {@code null}
+ * @return the column family names, never {@code null}; empty when there are
+ * none
* @throws IllegalStateException if this database is closed
* @throws TidesDBException if the native list operation fails
*/
@@ -273,9 +332,11 @@ public String[] listColumnFamilies() throws TidesDBException {
checkNotClosed();
return nativeListColumnFamilies(nativeHandle);
}
-
+
+ /* ===== transactions ===== */
+
/**
- * Begins a new transaction with the default isolation level.
+ * Begins a transaction at the database default isolation level.
*
* Close the returned transaction before closing this database.
*
@@ -285,12 +346,11 @@ public String[] listColumnFamilies() throws TidesDBException {
*/
public Transaction beginTransaction() throws TidesDBException {
checkNotClosed();
- long txnHandle = nativeBeginTransaction(nativeHandle);
- return new Transaction(txnHandle);
+ return new Transaction(nativeBeginTransaction(nativeHandle));
}
-
+
/**
- * Begins a new transaction with the specified isolation level.
+ * Begins a transaction at an explicit isolation level.
*
* Close the returned transaction before closing this database.
*
@@ -305,150 +365,206 @@ public Transaction beginTransaction(IsolationLevel isolationLevel) throws TidesD
if (isolationLevel == null) {
throw new IllegalArgumentException("Isolation level cannot be null");
}
- long txnHandle = nativeBeginTransactionWithIsolation(nativeHandle, isolationLevel.getValue());
- return new Transaction(txnHandle);
+ return new Transaction(
+ nativeBeginTransactionWithIsolation(nativeHandle, isolationLevel.getValue()));
}
-
+
/**
- * Retrieves statistics about the block cache.
+ * Begins a transaction at the given column family's default isolation level.
*
- * @return cache statistics, never {@code null}
+ * @param cf the column family whose default isolation to use; must not be
+ * {@code null}
+ * @return a new transaction
+ * @throws IllegalArgumentException if {@code cf} is {@code null}
* @throws IllegalStateException if this database is closed
- * @throws TidesDBException if the native stats retrieval fails
+ * @throws TidesDBException if the native transaction cannot be started
*/
- public CacheStats getCacheStats() throws TidesDBException {
+ public Transaction beginTransaction(ColumnFamily cf) throws TidesDBException {
checkNotClosed();
- return nativeGetCacheStats(nativeHandle);
+ if (cf == null) {
+ throw new IllegalArgumentException("Column family cannot be null");
+ }
+ return new Transaction(nativeBeginTransactionCf(nativeHandle, cf.getNativeHandle()));
}
-
+
/**
- * Registers a custom comparator with the database.
+ * Names the database as it stands now, so it can be read again later.
*
- * @param name the comparator name; must not be {@code null} or empty
- * @param context optional context string, may be {@code null}
- * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * The snapshot holds the reclamation floor at its own sequence for as long
+ * as it lives. Release it as soon as the point in time is no longer wanted.
+ *
+ * @return a new snapshot
* @throws IllegalStateException if this database is closed
- * @throws TidesDBException if the native comparator registration fails
+ * @throws TidesDBException if the native snapshot cannot be created
*/
- public void registerComparator(String name, String context) throws TidesDBException {
+ public Snapshot createSnapshot() throws TidesDBException {
checkNotClosed();
- if (name == null || name.isEmpty()) {
- throw new IllegalArgumentException("Comparator name cannot be null or empty");
- }
- nativeRegisterComparator(nativeHandle, name, context);
+ return new Snapshot(nativeSnapshotCreate(nativeHandle));
}
-
+
/**
- * Creates an on-disk snapshot of the database without blocking normal reads/writes.
+ * Begins a transaction whose reads resolve as of a snapshot rather than as of
+ * now: the same keys, the same families, answered as they stood when the
+ * snapshot was taken.
*
- * @param dir the backup directory (must be non-existent or empty)
- * @throws TidesDBException if the backup fails
+ * The snapshot must outlive the transaction, because it is what holds the
+ * floor under the versions being read.
+ *
+ * @param snapshot the snapshot to read at; must not be {@code null}
+ * @return a new transaction
+ * @throws IllegalArgumentException if {@code snapshot} is {@code null}
+ * @throws IllegalStateException if this database is closed or the snapshot
+ * has been released
+ * @throws TidesDBException if the native transaction cannot be started
*/
- public void backup(String dir) throws TidesDBException {
+ public Transaction beginTransactionAtSnapshot(Snapshot snapshot) throws TidesDBException {
checkNotClosed();
- if (dir == null || dir.isEmpty()) {
- throw new IllegalArgumentException("Backup directory cannot be null or empty");
+ if (snapshot == null) {
+ throw new IllegalArgumentException("Snapshot cannot be null");
}
- nativeBackup(nativeHandle, dir);
+ return new Transaction(
+ nativeBeginTransactionAtSnapshot(nativeHandle, snapshot.getNativeHandle()));
}
-
+
/**
- * Creates a lightweight, near-instant snapshot of an open database using hard links
- * instead of copying SSTable data.
+ * Begins a transaction reading as of an explicit sequence, for a point in
+ * time no snapshot was taken at — a sequence read back from
+ * {@link Transaction#getReadSnapshot()}, or one recorded elsewhere.
+ *
+ * It refuses rather than approximates. A sequence is readable only while
+ * something holds the reclamation floor under it: an open transaction, or a
+ * snapshot taken in advance. Once a collection has run past that sequence the
+ * call fails with {@link TidesDBException#ERR_TOO_OLD} and reads nothing.
*
- * @param dir the checkpoint directory (must be non-existent or empty)
- * @throws TidesDBException if the checkpoint fails
+ * @param seq the sequence to read at
+ * @return a new transaction
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException with {@link TidesDBException#ERR_TOO_OLD} when a
+ * collection has already run below {@code seq}
*/
- public void checkpoint(String dir) throws TidesDBException {
+ public Transaction beginTransactionAtSeq(long seq) throws TidesDBException {
checkNotClosed();
- if (dir == null || dir.isEmpty()) {
- throw new IllegalArgumentException("Checkpoint directory cannot be null or empty");
- }
- nativeCheckpoint(nativeHandle, dir);
+ return new Transaction(nativeBeginTransactionAtSeq(nativeHandle, seq));
}
-
+
/**
- * Atomically renames a column family and its underlying directory.
- * The operation waits for any in-progress flush or compaction to complete before renaming.
+ * Returns the oldest sequence {@link #beginTransactionAtSeq(long)} will still
+ * accept, which is the highest reclamation floor any collection has taken. It
+ * only ever rises, and a snapshot or an open transaction is what keeps it
+ * from rising past a point still wanted.
*
- * @param oldName the current column family name
- * @param newName the new column family name
- * @throws TidesDBException if the rename fails
+ * @return the oldest readable sequence
+ * @throws IllegalStateException if this database is closed
*/
- public void renameColumnFamily(String oldName, String newName) throws TidesDBException {
+ public long getOldestReadableSeq() {
checkNotClosed();
- if (oldName == null || oldName.isEmpty()) {
- throw new IllegalArgumentException("Old column family name cannot be null or empty");
- }
- if (newName == null || newName.isEmpty()) {
- throw new IllegalArgumentException("New column family name cannot be null or empty");
- }
- nativeRenameColumnFamily(nativeHandle, oldName, newName);
+ return nativeOldestReadableSeq(nativeHandle);
}
-
+
/**
- * Creates a complete copy of an existing column family with a new name.
- * The clone contains all the data from the source at the time of cloning.
- * The clone is completely independent - modifications to one do not affect the other.
+ * Lists the transactions that were durably prepared before the last shutdown
+ * and never committed or rolled back, so a coordinator can finish deciding
+ * them. One that was decided in the log is settled during open and never
+ * appears here.
*
- * @param sourceName the source column family name
- * @param destName the destination column family name
- * @throws TidesDBException if the clone fails
+ * The caller owns every returned {@link Transaction} and must resolve it
+ * with {@link Transaction#commitPrepared()} or
+ * {@link Transaction#rollbackPrepared()}, then free it.
+ *
+ * @return the in-doubt transactions, never {@code null}
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the recovery scan fails
*/
- public void cloneColumnFamily(String sourceName, String destName) throws TidesDBException {
+ public PreparedTransaction[] recoverPrepared() throws TidesDBException {
checkNotClosed();
- if (sourceName == null || sourceName.isEmpty()) {
- throw new IllegalArgumentException("Source column family name cannot be null or empty");
- }
- if (destName == null || destName.isEmpty()) {
- throw new IllegalArgumentException("Destination column family name cannot be null or empty");
- }
- nativeCloneColumnFamily(nativeHandle, sourceName, destName);
+ return nativeRecoverPrepared(nativeHandle);
+ }
+
+ /* ===== maintenance ===== */
+
+ /**
+ * Synchronously rotates and flushes the shared memtable to SSTables, waiting
+ * a bounded time for the immutable queue to drain.
+ *
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException with {@link TidesDBException#ERR_LOCKED} if the
+ * queue had not drained when the wait expired, which says flush is
+ * not keeping up rather than that anything is wrong with the call
+ */
+ public void flushMemtable() throws TidesDBException {
+ checkNotClosed();
+ nativeFlushMemtable(nativeHandle);
}
-
+
/**
- * Forces a synchronous flush and aggressive compaction for all column families,
- * then drains both the global flush and compaction queues.
- * This blocks until all work is complete.
+ * Reports whether the memtable is currently flushing or rotating.
*
- * @throws TidesDBException if the purge fails
+ * @return {@code true} if flushing
+ * @throws IllegalStateException if this database is closed
*/
- public void purge() throws TidesDBException {
+ public boolean isFlushing() {
checkNotClosed();
- nativePurge(nativeHandle);
+ return nativeIsFlushing(nativeHandle);
}
-
+
/**
- * Deletes a column family using its handle.
- * This is an alternative to {@link #dropColumnFamily(String)} that takes a column family
- * object instead of a name.
+ * Forces an fsync of the write-ahead log. Useful for explicit durability
+ * control under {@link SyncMode#SYNC_NONE} or {@link SyncMode#SYNC_INTERVAL}.
*
- * @param cf the column family to delete
- * @throws TidesDBException if the column family cannot be deleted
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the sync fails
*/
- public void deleteColumnFamily(ColumnFamily cf) throws TidesDBException {
+ public void syncWal() throws TidesDBException {
checkNotClosed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
+ nativeSyncWal(nativeHandle);
+ }
+
+ /**
+ * Writes a consistent, directly-openable copy of the database into
+ * {@code dir}: flushes the memtable, then copies the manifest, the shared
+ * value log, and every SSTable it references at a single manifest snapshot
+ * while compaction is held off, so the copy references no file that a merge
+ * could delete mid-copy.
+ *
+ * @param dir the destination directory, created if absent; must not be
+ * {@code null} or empty
+ * @throws IllegalArgumentException if {@code dir} is {@code null} or empty
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the backup fails, including
+ * {@link TidesDBException#ERR_LOCKED} if a family stayed under
+ * compaction for the whole freeze window
+ */
+ public void backup(String dir) throws TidesDBException {
+ checkNotClosed();
+ if (dir == null || dir.isEmpty()) {
+ throw new IllegalArgumentException("Backup directory cannot be null or empty");
}
- nativeDeleteColumnFamily(nativeHandle, cf.getNativeHandle());
+ nativeBackup(nativeHandle, dir);
}
/**
- * Switches a read-only replica database to primary mode.
+ * Establishes a durability barrier in the live database: flushes the memtable
+ * to L1, then forces the value log, the write-ahead log, and the manifest to
+ * disk regardless of the configured sync mode.
*
- * @throws TidesDBException if not in replica mode or promotion fails
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the checkpoint fails, including
+ * {@link TidesDBException#ERR_LOCKED} if the flush it begins with
+ * could not drain the immutable queue
*/
- public void promoteToPrimary() throws TidesDBException {
+ public void checkpoint() throws TidesDBException {
checkNotClosed();
- nativePromoteToPrimary(nativeHandle);
+ nativeCheckpoint(nativeHandle);
}
+ /* ===== statistics ===== */
+
/**
- * Retrieves aggregate statistics across the entire database instance.
+ * Collects database-level statistics.
*
- * @return database-level statistics
- * @throws TidesDBException if the stats cannot be retrieved
+ * @return the statistics, never {@code null}
+ * @throws IllegalStateException if this database is closed
+ * @throws TidesDBException if the native stats retrieval fails
*/
public DbStats getDbStats() throws TidesDBException {
checkNotClosed();
@@ -456,36 +572,70 @@ public DbStats getDbStats() throws TidesDBException {
}
/**
- * Cancels background compaction database-wide. In-flight merges bail safely at their
- * next checkpoint (their uncommitted output is discarded, inputs are left intact, so no
- * data is lost) and any queued compaction is skipped. Flushes are unaffected, so
- * durability is preserved. Blocks (bounded) until compaction is idle.
+ * Collects block-cache statistics.
*
- * The cancellation is sticky for the session and is reset on the next open. It is
- * intended to be called immediately before {@link #close()} for a fast shutdown. On POSIX systems (Linux, macOS, the BSDs, illumos) this raises the {@code RLIMIT_NOFILE}
- * soft limit toward the hard limit; on Windows it raises the CRT stdio cap (max 8192). A
- * failed or partial raise is non-fatal. Two codes describe conditions a caller is expected to handle rather than
+ * report: {@link #ERR_LOCKED} is transient contention and the remedy is always
+ * to retry, and {@link #ERR_CONFLICT} is the engine's first-committer-wins
+ * verdict on a transaction. {@link #isRetryable()} identifies the former.
*/
public class TidesDBException extends Exception {
-
+
+ private static final long serialVersionUID = 1L;
+
/**
* The error code.
*/
private final int errorCode;
-
+
/**
* Error codes returned by the native TidesDB library.
*/
@@ -67,12 +74,14 @@ public class TidesDBException extends Exception {
public static final int ERR_EXISTS = -6;
/**
- * Transaction conflict.
+ * Transaction conflict. The engine's own first-committer-wins verdict:
+ * another transaction committed a conflicting write first.
*/
public static final int ERR_CONFLICT = -7;
/**
- * Key or value exceeds size limits.
+ * A value does not fit the space that has to hold it, such as a caller's
+ * buffer too small for what was asked of it.
*/
public static final int ERR_TOO_LARGE = -8;
@@ -82,7 +91,7 @@ public class TidesDBException extends Exception {
public static final int ERR_MEMORY_LIMIT = -9;
/**
- * Invalid database handle.
+ * Invalid database handle, or the database is closing.
*/
public static final int ERR_INVALID_DB = -10;
@@ -92,10 +101,45 @@ public class TidesDBException extends Exception {
public static final int ERR_UNKNOWN = -11;
/**
- * Database is locked by another process.
+ * Transient contention: something else held what the call needed, nothing
+ * was written, and the remedy is always to try again. It is not confined to
+ * operations that take a column family exclusively, since a read has to open
+ * SSTables and walk sources a compaction may be moving. Treat it as retry,
+ * never as absence and never as an error to surface.
*/
public static final int ERR_LOCKED = -12;
-
+
+ /**
+ * The database is read-only.
+ */
+ public static final int ERR_READONLY = -13;
+
+ /**
+ * The transaction's timeout has passed; the next operation on it expired it.
+ */
+ public static final int ERR_TXN_EXPIRED = -14;
+
+ /**
+ * The device or filesystem is out of space. Distinct from {@link #ERR_IO}
+ * because the data is intact and the operation succeeds once space is freed.
+ */
+ public static final int ERR_NO_SPACE = -15;
+
+ /**
+ * The transaction was aborted from outside through
+ * {@link Transaction#requestAbort()}. Distinct from {@link #ERR_CONFLICT}:
+ * a conflict is the engine's own verdict, where this says an outside
+ * authority decided the transaction loses and the engine never got a say.
+ */
+ public static final int ERR_TXN_ABORTED = -16;
+
+ /**
+ * The point in time asked for is no longer reconstructable: a collection has
+ * already run below that sequence, so the versions it would resolve to are
+ * gone.
+ */
+ public static final int ERR_TOO_OLD = -17;
+
/**
* Creates an exception with a message and an unknown error code.
*
@@ -105,7 +149,7 @@ public TidesDBException(String message) {
super(message);
this.errorCode = ERR_UNKNOWN;
}
-
+
/**
* Creates an exception with a message and a specific error code.
*
@@ -116,7 +160,7 @@ public TidesDBException(String message, int errorCode) {
super(message);
this.errorCode = errorCode;
}
-
+
/**
* Creates an exception with a message and a cause, using an unknown
* error code.
@@ -128,7 +172,7 @@ public TidesDBException(String message, Throwable cause) {
super(message, cause);
this.errorCode = ERR_UNKNOWN;
}
-
+
/**
* Creates an exception with a message, error code, and cause.
*
@@ -140,7 +184,7 @@ public TidesDBException(String message, int errorCode, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
-
+
/**
* Returns the TidesDB error code.
*
@@ -149,7 +193,18 @@ public TidesDBException(String message, int errorCode, Throwable cause) {
public int getErrorCode() {
return errorCode;
}
-
+
+ /**
+ * Returns whether the operation failed to transient contention and should
+ * simply be retried. Nothing was written and nothing is wrong with the
+ * database.
+ *
+ * @return {@code true} when the error code is {@link #ERR_LOCKED}
+ */
+ public boolean isRetryable() {
+ return errorCode == ERR_LOCKED;
+ }
+
/**
* Returns a human-readable description of the error code. The mapping is
* local to this class and does not invoke any native method.
@@ -175,13 +230,23 @@ public String getErrorMessage() {
case ERR_CONFLICT:
return "transaction conflict";
case ERR_TOO_LARGE:
- return "key or value too large";
+ return "value too large for the space that must hold it";
case ERR_MEMORY_LIMIT:
return "memory limit exceeded";
case ERR_INVALID_DB:
return "invalid database handle";
case ERR_LOCKED:
- return "database is locked";
+ return "resource is locked, retry";
+ case ERR_READONLY:
+ return "database is read-only";
+ case ERR_TXN_EXPIRED:
+ return "transaction expired";
+ case ERR_NO_SPACE:
+ return "no space left on device";
+ case ERR_TXN_ABORTED:
+ return "transaction aborted by request";
+ case ERR_TOO_OLD:
+ return "sequence is no longer reconstructable";
default:
return "unknown error";
}
diff --git a/src/main/java/com/tidesdb/TidesDBIterator.java b/src/main/java/com/tidesdb/TidesDBIterator.java
index b750dfb..09ff6ce 100644
--- a/src/main/java/com/tidesdb/TidesDBIterator.java
+++ b/src/main/java/com/tidesdb/TidesDBIterator.java
@@ -30,6 +30,12 @@
* and {@code close()} throw {@link IllegalStateException}. The {@link #isValid()}
* method returns {@code false} on a freed iterator.
*
+ * A scan reaches the same SSTables a point read does, so every method here
+ * can fail with {@link TidesDBException#ERR_LOCKED} for the same reason and with
+ * the same remedy: the position did not move, nothing is wrong with the
+ * iterator, and the step should be retried. That is not the end of the range,
+ * which is what {@link #isValid()} reports.
+ *
* This class is not guaranteed to be thread-safe.
*/
public class TidesDBIterator implements Closeable {
diff --git a/src/main/java/com/tidesdb/Transaction.java b/src/main/java/com/tidesdb/Transaction.java
index 58bfff8..60494e4 100644
--- a/src/main/java/com/tidesdb/Transaction.java
+++ b/src/main/java/com/tidesdb/Transaction.java
@@ -21,62 +21,77 @@
import java.io.Closeable;
/**
- * Represents a transaction in TidesDB. Transactions provide atomic
- * operations on the database and implement {@link java.io.Closeable} for
- * use with try-with-resources.
+ * A transaction in TidesDB. Every read and write goes through one, and
+ * {@code Transaction} implements {@link java.io.Closeable} for use with
+ * try-with-resources.
*
- * Close transactions before closing the owning {@link TidesDB} instance.
- * After this transaction is freed, all operations except {@code close()} throw
- * {@link IllegalStateException}.
+ * Close transactions before closing the owning {@link TidesDB} instance, and
+ * before releasing any {@link Snapshot} they were opened against. After this
+ * transaction is freed, all operations except {@link #requestAbort()} and
+ * {@code close()} throw {@link IllegalStateException}.
*
- * This class is not guaranteed to be thread-safe.
+ * This class is not guaranteed to be thread-safe. The one exception is
+ * {@link #requestAbort()}, which may be called from a thread other than the one
+ * running the transaction.
*/
public class Transaction implements Closeable {
-
+
static {
NativeLibrary.load();
}
-
+
+ /**
+ * The longest bound {@link #deleteRange(ColumnFamily, byte[], byte[])} and
+ * {@link #deletePrefix(ColumnFamily, byte[])} accept. An interval delete
+ * holds its bounds in a fixed slot for the length of its commit, which is
+ * what stops a concurrent write landing inside the range; a range too wide to
+ * name in one call is expressed as several.
+ */
+ public static final int MAX_RANGE_BOUND_SIZE = 256;
+
private long nativeHandle;
- private boolean freed = false;
-
+ private volatile boolean freed = false;
+
Transaction(long nativeHandle) {
this.nativeHandle = nativeHandle;
}
-
+
+ /* ===== writes ===== */
+
/**
- * Adds a key-value pair to the transaction.
+ * Buffers a put into this transaction.
*
- * @param cf the column family; must not be {@code null}
+ * @param cf the target column family; must not be {@code null}
* @param key the key; must not be {@code null} or empty
- * @param value the value; must not be {@code null}
- * @param ttl expiration as seconds since the Unix epoch, or {@code -1} for
- * no expiration
+ * @param value the value; must not be {@code null}. It may be empty, which
+ * stores the key present carrying nothing — a distinct state from an
+ * absence, since a read returns it with a zero length rather than
+ * reporting the key missing
+ * @param ttlSeconds how long the entry lives, in seconds from
+ * now; zero or negative never expires. The engine converts it
+ * to an absolute deadline once, here at the boundary, so a long
+ * recovery cannot extend an entry's life
* @throws IllegalArgumentException if {@code cf} is {@code null}, {@code key}
* is {@code null} or empty, or {@code value} is {@code null}
* @throws IllegalStateException if this transaction is freed
* @throws TidesDBException if the native put fails
*/
- public void put(ColumnFamily cf, byte[] key, byte[] value, long ttl) throws TidesDBException {
+ public void put(ColumnFamily cf, byte[] key, byte[] value, long ttlSeconds)
+ throws TidesDBException {
checkNotFreed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
- }
- if (key == null || key.length == 0) {
- throw new IllegalArgumentException("Key cannot be null or empty");
- }
+ requireCf(cf);
+ requireKey(key);
if (value == null) {
throw new IllegalArgumentException("Value cannot be null");
}
- nativePut(nativeHandle, cf.getNativeHandle(), key, value, ttl);
+ nativePut(nativeHandle, cf.getNativeHandle(), key, value, ttlSeconds);
}
-
+
/**
- * Adds a key-value pair to the transaction with no expiration.
+ * Buffers a put with no expiration. Equivalent to
+ * {@code put(cf, key, value, 0)}.
*
- * Equivalent to {@code put(cf, key, value, -1)}.
- *
- * @param cf the column family; must not be {@code null}
+ * @param cf the target column family; must not be {@code null}
* @param key the key; must not be {@code null} or empty
* @param value the value; must not be {@code null}
* @throws IllegalArgumentException if {@code cf} is {@code null}, {@code key}
@@ -85,154 +100,211 @@ public void put(ColumnFamily cf, byte[] key, byte[] value, long ttl) throws Tide
* @throws TidesDBException if the native put fails
*/
public void put(ColumnFamily cf, byte[] key, byte[] value) throws TidesDBException {
- put(cf, key, value, -1);
+ put(cf, key, value, 0);
}
-
+
/**
- * Retrieves a value from the transaction.
+ * Buffers a delete (tombstone) into this transaction.
*
- * @param cf the column family; must not be {@code null}
+ * @param cf the target column family; must not be {@code null}
* @param key the key; must not be {@code null} or empty
- * @return the value, or {@code null} if not found
* @throws IllegalArgumentException if {@code cf} is {@code null}, or
* {@code key} is {@code null} or empty
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native get fails
+ * @throws TidesDBException if the native delete fails
*/
- public byte[] get(ColumnFamily cf, byte[] key) throws TidesDBException {
+ public void delete(ColumnFamily cf, byte[] key) throws TidesDBException {
checkNotFreed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
- }
- if (key == null || key.length == 0) {
- throw new IllegalArgumentException("Key cannot be null or empty");
- }
- return nativeGet(nativeHandle, cf.getNativeHandle(), key);
+ requireCf(cf);
+ requireKey(key);
+ nativeDelete(nativeHandle, cf.getNativeHandle(), key);
}
-
+
/**
- * Removes a key-value pair from the transaction.
+ * Buffers a single-delete: a tombstone the caller promises supersedes at most
+ * one put, so the two can be reaped together at compaction.
*
- * @param cf the column family; must not be {@code null}
+ * Caller contract: between any two single-deletes on the same key, and
+ * from the start of the key's history to its first single-delete, the key has
+ * been put at most once. The engine cannot verify this; violating it can
+ * leave older puts visible after the single-delete. When in doubt, prefer
+ * {@link #delete(ColumnFamily, byte[])}.
+ *
+ * @param cf the target column family; must not be {@code null}
* @param key the key; must not be {@code null} or empty
* @throws IllegalArgumentException if {@code cf} is {@code null}, or
* {@code key} is {@code null} or empty
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native delete fails
+ * @throws TidesDBException if the native single-delete fails
*/
- public void delete(ColumnFamily cf, byte[] key) throws TidesDBException {
+ public void singleDelete(ColumnFamily cf, byte[] key) throws TidesDBException {
checkNotFreed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
- }
- if (key == null || key.length == 0) {
- throw new IllegalArgumentException("Key cannot be null");
- }
- nativeDelete(nativeHandle, cf.getNativeHandle(), key);
+ requireCf(cf);
+ requireKey(key);
+ nativeSingleDelete(nativeHandle, cf.getNativeHandle(), key);
}
/**
- * Writes a single-delete tombstone for a key. Has the same read semantics as
- * {@link #delete}, but lets compaction drop the put and tombstone together as
- * soon as both appear in the same merge input.
- *
- * Caller contract: between any two single-deletes on the same key (and from
- * the start of the key's history to its first single-delete) the key has been
- * put at most once. The engine cannot verify this; violating it can leave
- * older puts visible after the single-delete. Use only for workloads that
- * insert each key once and delete it once. When in doubt, prefer {@link #delete}.
- *
- * @param cf the column family
- * @param key the key
- * @throws TidesDBException if the single-delete fails
+ * Buffers a delete of every key in the column family from {@code lo}
+ * (inclusive) to {@code hi} (exclusive).
+ *
+ * It costs one entry however many keys it covers, and it deletes keys
+ * written before it as well as keys written after it — so it is not the same
+ * as deleting the keys that happen to be there when you call it. A write to a
+ * key inside the range survives it when it is newer: buffered after it in the
+ * same transaction, or committed at a later sequence.
+ *
+ * The delete is O(1) to write but not to reclaim: the keys it covers stay
+ * on disk until a compaction rewrites the range.
+ *
+ * At {@link IsolationLevel#SNAPSHOT} and above the commit is refused with
+ * {@link TidesDBException#ERR_CONFLICT} when any key in the range was written
+ * after this transaction drew its snapshot.
+ *
+ * @param cf the target column family; must not be {@code null}
+ * @param lo the inclusive lower bound; must not be {@code null} or empty, and
+ * at most {@link #MAX_RANGE_BOUND_SIZE} bytes. Keys are never empty, so
+ * a single zero byte is a lower bound below every key there can be
+ * @param hi the exclusive upper bound, or {@code null}/empty to run to the end
+ * of the family; at most {@link #MAX_RANGE_BOUND_SIZE} bytes
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, {@code lo}
+ * is {@code null} or empty, or either bound is too long
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the native delete fails
*/
- public void singleDelete(ColumnFamily cf, byte[] key) throws TidesDBException {
+ public void deleteRange(ColumnFamily cf, byte[] lo, byte[] hi) throws TidesDBException {
checkNotFreed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
+ requireCf(cf);
+ if (lo == null || lo.length == 0) {
+ throw new IllegalArgumentException("Lower bound cannot be null or empty");
}
- if (key == null || key.length == 0) {
- throw new IllegalArgumentException("Key cannot be null");
+ requireBoundSize(lo, "Lower bound");
+ if (hi != null) {
+ requireBoundSize(hi, "Upper bound");
}
- nativeSingleDelete(nativeHandle, cf.getNativeHandle(), key);
+ nativeDeleteRange(nativeHandle, cf.getNativeHandle(), lo, hi);
}
/**
- * Commits the transaction, making all pending operations durable.
+ * Buffers a delete of every key in the column family that begins with
+ * {@code prefix}. This is the one-bound form of
+ * {@link #deleteRange(ColumnFamily, byte[], byte[])} and carries the same
+ * semantics and costs.
*
+ * A two-phase transaction holds its prefix from the prepare until phase
+ * two resolves it, so a write under it is refused for as long as it stays in
+ * doubt.
+ *
+ * @param cf the target column family; must not be {@code null}
+ * @param prefix the prefix to delete under; must not be {@code null} or empty,
+ * and at most {@link #MAX_RANGE_BOUND_SIZE} bytes. A whole family is
+ * dropped with {@link TidesDB#dropColumnFamily(String)} rather than
+ * deleted a prefix at a time
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, or
+ * {@code prefix} is {@code null}, empty, or too long
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native commit fails
+ * @throws TidesDBException if the native delete fails
*/
- public void commit() throws TidesDBException {
+ public void deletePrefix(ColumnFamily cf, byte[] prefix) throws TidesDBException {
checkNotFreed();
- nativeCommit(nativeHandle);
+ requireCf(cf);
+ if (prefix == null || prefix.length == 0) {
+ throw new IllegalArgumentException("Prefix cannot be null or empty");
+ }
+ requireBoundSize(prefix, "Prefix");
+ nativeDeletePrefix(nativeHandle, cf.getNativeHandle(), prefix);
}
-
+
+ /* ===== reads ===== */
+
/**
- * Rolls back all operations in the transaction.
+ * Reads a key at the transaction snapshot, recording the read into the
+ * conflict footprint.
*
+ * @param cf the target column family; must not be {@code null}
+ * @param key the key; must not be {@code null} or empty
+ * @return the value, or {@code null} if not found
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, or
+ * {@code key} is {@code null} or empty
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native rollback fails
+ * @throws TidesDBException if the native read fails, including
+ * {@link TidesDBException#ERR_LOCKED} when contention left the read
+ * unservable and it should be retried
*/
- public void rollback() throws TidesDBException {
+ public byte[] get(ColumnFamily cf, byte[] key) throws TidesDBException {
checkNotFreed();
- nativeRollback(nativeHandle);
+ requireCf(cf);
+ requireKey(key);
+ return nativeGet(nativeHandle, cf.getNativeHandle(), key);
}
-
+
/**
- * Creates a named savepoint within the transaction.
+ * Reads a key at the transaction snapshot without recording it into
+ * the conflict footprint, for existence probes — such as primary-key
+ * uniqueness — that should not pollute the write-write base.
*
- * @param name the savepoint name; must not be {@code null} or empty
- * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * @param cf the target column family; must not be {@code null}
+ * @param key the key; must not be {@code null} or empty
+ * @return the value, or {@code null} if not found
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, or
+ * {@code key} is {@code null} or empty
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native savepoint creation fails
+ * @throws TidesDBException if the native read fails
*/
- public void savepoint(String name) throws TidesDBException {
+ public byte[] getNoTrack(ColumnFamily cf, byte[] key) throws TidesDBException {
checkNotFreed();
- if (name == null || name.isEmpty()) {
- throw new IllegalArgumentException("Savepoint name cannot be null or empty");
- }
- nativeSavepoint(nativeHandle, name);
+ requireCf(cf);
+ requireKey(key);
+ return nativeGetNoTrack(nativeHandle, cf.getNativeHandle(), key);
}
-
+
/**
- * Rolls back the transaction to a named savepoint.
+ * Non-tracking existence check at the transaction snapshot.
*
- * @param name the savepoint name; must not be {@code null} or empty
- * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * @param cf the target column family; must not be {@code null}
+ * @param key the key; must not be {@code null} or empty
+ * @return {@code true} if the key is present
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, or
+ * {@code key} is {@code null} or empty
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native rollback fails
+ * @throws TidesDBException if the native check fails, including
+ * {@link TidesDBException#ERR_LOCKED} when contention left the read
+ * unservable and it should be retried
*/
- public void rollbackToSavepoint(String name) throws TidesDBException {
+ public boolean contains(ColumnFamily cf, byte[] key) throws TidesDBException {
checkNotFreed();
- if (name == null || name.isEmpty()) {
- throw new IllegalArgumentException("Savepoint name cannot be null or empty");
- }
- nativeRollbackToSavepoint(nativeHandle, name);
+ requireCf(cf);
+ requireKey(key);
+ return nativeContains(nativeHandle, cf.getNativeHandle(), key);
}
-
+
/**
- * Releases a named savepoint without rolling back.
+ * Returns the sequence ceiling this transaction's reads filter at, so a
+ * caller can reason about which committed versions the transaction can and
+ * cannot see.
*
- * @param name the savepoint name; must not be {@code null} or empty
- * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * The engine's sequence is an unsigned 64-bit value, so a ceiling of
+ * {@code UINT64_MAX} arrives here as {@code -1}. Compare sequences with
+ * {@link Long#compareUnsigned(long, long)} rather than {@code <}.
+ *
+ * @return {@code -1} (an unsigned {@code UINT64_MAX}) for read-uncommitted,
+ * the current sequence for read-committed, and the sequence frozen at
+ * begin for repeatable-read and stronger
* @throws IllegalStateException if this transaction is freed
- * @throws TidesDBException if the native release fails
*/
- public void releaseSavepoint(String name) throws TidesDBException {
+ public long getReadSnapshot() {
checkNotFreed();
- if (name == null || name.isEmpty()) {
- throw new IllegalArgumentException("Savepoint name cannot be null or empty");
- }
- nativeReleaseSavepoint(nativeHandle, name);
+ return nativeReadSnapshot(nativeHandle);
}
-
+
+ /* ===== iterators ===== */
+
/**
- * Creates a new iterator for the given column family within this transaction.
+ * Creates an iterator over a column family at this transaction's snapshot.
*
* Close the returned iterator before freeing this transaction.
*
- * @param cf the column family; must not be {@code null}
+ * @param cf the column family to iterate; must not be {@code null}
* @return a new iterator
* @throws IllegalArgumentException if {@code cf} is {@code null}
* @throws IllegalStateException if this transaction is freed
@@ -240,19 +312,137 @@ public void releaseSavepoint(String name) throws TidesDBException {
*/
public TidesDBIterator newIterator(ColumnFamily cf) throws TidesDBException {
checkNotFreed();
- if (cf == null) {
- throw new IllegalArgumentException("Column family cannot be null");
+ requireCf(cf);
+ return new TidesDBIterator(nativeNewIterator(nativeHandle, cf.getNativeHandle()));
+ }
+
+ /**
+ * Creates an iterator over the part of a column family a scan will actually
+ * read, at this transaction's snapshot.
+ *
+ * An iterator holds one open cursor per SSTable that could answer it.
+ * Telling it the range up front lets it leave out the SSTables whose own key
+ * range cannot meet it, so a scan of a narrow band costs what that band costs
+ * rather than what the whole column family costs.
+ *
+ * The range is a promise about what will be read, not a fence the iterator
+ * enforces. Results are defined only inside it, because the SSTables that
+ * could answer outside it were never opened: seeking or stepping past either
+ * end may report absent a key that exists. Use
+ * {@link #newIterator(ColumnFamily)} for a scan whose extent is not known in
+ * advance.
+ *
+ * @param cf the column family to iterate; must not be {@code null}
+ * @param lower the range start, inclusive; must not be {@code null} or empty
+ * @param upper the range end, inclusive for the purpose of choosing SSTables,
+ * so an exclusive end may be passed unchanged; must not be {@code null}
+ * or empty
+ * @return a new iterator
+ * @throws IllegalArgumentException if {@code cf} is {@code null}, or either
+ * bound is {@code null} or empty
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the native iterator cannot be created
+ */
+ public TidesDBIterator newRangeIterator(ColumnFamily cf, byte[] lower, byte[] upper)
+ throws TidesDBException {
+ checkNotFreed();
+ requireCf(cf);
+ if (lower == null || lower.length == 0) {
+ throw new IllegalArgumentException("Lower bound cannot be null or empty");
+ }
+ if (upper == null || upper.length == 0) {
+ throw new IllegalArgumentException("Upper bound cannot be null or empty");
}
- long iterHandle = nativeNewIterator(nativeHandle, cf.getNativeHandle());
- return new TidesDBIterator(iterHandle);
+ return new TidesDBIterator(
+ nativeNewRangeIterator(nativeHandle, cf.getNativeHandle(), lower, upper));
}
-
+
+ /* ===== lifecycle ===== */
+
/**
- * Resets a committed or aborted transaction for reuse with a new isolation level.
- * This avoids the overhead of freeing and reallocating transaction resources in hot loops.
+ * Commits the transaction, writing its batch to the write-ahead log and
+ * memtable.
*
- * @param isolation the new isolation level for the reset transaction
- * @throws TidesDBException if the reset fails (e.g., transaction is still active)
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException with {@link TidesDBException#ERR_CONFLICT} when
+ * another transaction committed a conflicting write first, or
+ * {@link TidesDBException#ERR_TXN_ABORTED} when an outside authority
+ * aborted it through {@link #requestAbort()}
+ */
+ public void commit() throws TidesDBException {
+ checkNotFreed();
+ nativeCommit(nativeHandle);
+ }
+
+ /**
+ * Discards the transaction's buffered writes without committing.
+ *
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the native rollback fails
+ */
+ public void rollback() throws TidesDBException {
+ checkNotFreed();
+ nativeRollback(nativeHandle);
+ }
+
+ /**
+ * Bounds how long this transaction may stay active, overriding the database's
+ * {@link Config#getTxnTimeoutSeconds()} for this one. The deadline is
+ * measured from the moment of this call, so calling it again on a live
+ * transaction extends it.
+ *
+ * The transaction is not aborted in the background when the deadline
+ * passes: the next operation on it notices, aborts it, and fails with
+ * {@link TidesDBException#ERR_TXN_EXPIRED}.
+ *
+ * @param seconds seconds from now at which it expires, or ≤ 0 to clear any
+ * timeout
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the transaction is already resolved
+ */
+ public void setTimeout(long seconds) throws TidesDBException {
+ checkNotFreed();
+ nativeSetTimeout(nativeHandle, seconds);
+ }
+
+ /**
+ * Aborts a transaction that another thread is running, so its next operation
+ * fails with {@link TidesDBException#ERR_TXN_ABORTED} rather than whatever it
+ * would otherwise have done. This is the one method here that may be called
+ * on a transaction owned by a different thread.
+ *
+ * It exists for a caller that is itself the authority on whether a
+ * transaction may proceed. The call stores a flag and does nothing else: the
+ * transaction is not rolled back here, and the thread running it observes the
+ * flag when it next enters an operation and frees the transaction as it
+ * normally would.
+ *
+ * A request that lands just after the running thread has checked lets the
+ * operation in progress finish and stops the one after it.
+ *
+ * A transaction that has already prepared is not affected: it has voted,
+ * and only the coordinator's decision may resolve it.
+ *
+ * Calling this on a freed transaction is a no-op.
+ */
+ public void requestAbort() {
+ long handle = nativeHandle;
+ if (freed || handle == 0) {
+ return;
+ }
+ nativeRequestAbort(handle);
+ }
+
+ /**
+ * Resets this transaction for reuse at a new isolation level, discarding its
+ * buffered state. This avoids the cost of freeing and reallocating
+ * transaction resources in hot loops.
+ *
+ * @param isolation the isolation level for the reset transaction; must not be
+ * {@code null}
+ * @throws IllegalArgumentException if {@code isolation} is {@code null}
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the reset fails
*/
public void reset(IsolationLevel isolation) throws TidesDBException {
checkNotFreed();
@@ -261,21 +451,138 @@ public void reset(IsolationLevel isolation) throws TidesDBException {
}
nativeReset(nativeHandle, isolation.getValue());
}
-
+
+ /**
+ * Reports this transaction's lifecycle state, so a coordinator can tell a
+ * prepared transaction from a resolved one.
+ *
+ * @return the current state
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the native call fails
+ */
+ public TransactionState state() throws TidesDBException {
+ checkNotFreed();
+ return TransactionState.fromValue(nativeState(nativeHandle));
+ }
+
+ /* ===== two-phase commit ===== */
+
+ /**
+ * Two-phase-commit phase one: runs the same conflict checks as
+ * {@link #commit()} and durably logs the write batch under the given
+ * transaction id, but leaves the writes invisible and unapplied so a
+ * coordinator can gather votes from every participant before deciding.
+ *
+ * On success the transaction moves to {@link TransactionState#PREPARED}
+ * and holds its snapshot and reservations until resolved with
+ * {@link #commitPrepared()} or {@link #rollbackPrepared()}. A read-only
+ * transaction prepares with nothing durable and needs no phase two. The xid
+ * is copied.
+ *
+ * @param xid the transaction id to record durably; must not be {@code null}
+ * or empty
+ * @throws IllegalArgumentException if {@code xid} is {@code null} or empty
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the prepare fails
+ */
+ public void prepare(byte[] xid) throws TidesDBException {
+ checkNotFreed();
+ if (xid == null || xid.length == 0) {
+ throw new IllegalArgumentException("Transaction id cannot be null or empty");
+ }
+ nativePrepare(nativeHandle, xid);
+ }
+
+ /**
+ * Two-phase-commit phase two: durably logs the decision to commit the
+ * prepared transaction, then applies its batch and makes it visible. Valid
+ * only on a prepared transaction. A transient I/O failure leaves it prepared
+ * so the coordinator can retry.
+ *
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException with {@link TidesDBException#ERR_INVALID_ARGS} if
+ * the transaction is not prepared
+ */
+ public void commitPrepared() throws TidesDBException {
+ checkNotFreed();
+ nativeCommitPrepared(nativeHandle);
+ }
+
/**
- * Frees the transaction and releases all native resources.
+ * Two-phase-commit phase two: durably logs the decision to roll back the
+ * prepared transaction and releases its reservations. Nothing was applied, so
+ * nothing is undone. Valid only on a prepared transaction.
*
- * This method is idempotent; subsequent calls are no-ops. After freeing,
- * all other operations on this instance throw {@link IllegalStateException}.
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException with {@link TidesDBException#ERR_INVALID_ARGS} if
+ * the transaction is not prepared
+ */
+ public void rollbackPrepared() throws TidesDBException {
+ checkNotFreed();
+ nativeRollbackPrepared(nativeHandle);
+ }
+
+ /* ===== savepoints ===== */
+
+ /**
+ * Marks a named savepoint in this transaction to roll back to later.
+ *
+ * @param name the savepoint name; must not be {@code null} or empty
+ * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException if the native savepoint creation fails
+ */
+ public void savepoint(String name) throws TidesDBException {
+ checkNotFreed();
+ requireName(name);
+ nativeSavepoint(nativeHandle, name);
+ }
+
+ /**
+ * Discards writes buffered since a named savepoint.
+ *
+ * @param name the savepoint name; must not be {@code null} or empty
+ * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException with {@link TidesDBException#ERR_NOT_FOUND} when
+ * no savepoint carries that name
+ */
+ public void rollbackToSavepoint(String name) throws TidesDBException {
+ checkNotFreed();
+ requireName(name);
+ nativeRollbackToSavepoint(nativeHandle, name);
+ }
+
+ /**
+ * Releases a named savepoint without rolling back.
+ *
+ * @param name the savepoint name; must not be {@code null} or empty
+ * @throws IllegalArgumentException if {@code name} is {@code null} or empty
+ * @throws IllegalStateException if this transaction is freed
+ * @throws TidesDBException with {@link TidesDBException#ERR_NOT_FOUND} when
+ * no savepoint carries that name
+ */
+ public void releaseSavepoint(String name) throws TidesDBException {
+ checkNotFreed();
+ requireName(name);
+ nativeReleaseSavepoint(nativeHandle, name);
+ }
+
+ /**
+ * Frees the transaction, rolling it back if still open, and releases all
+ * native resources.
+ *
+ * This method is idempotent; subsequent calls are no-ops.
*/
public void free() {
if (!freed && nativeHandle != 0) {
- nativeFree(nativeHandle);
- nativeHandle = 0;
+ long handle = nativeHandle;
freed = true;
+ nativeHandle = 0;
+ nativeFree(handle);
}
}
-
+
/**
* Closes this transaction. Equivalent to {@link #free()}.
*/
@@ -283,27 +590,90 @@ public void free() {
public void close() {
free();
}
-
+
private void checkNotFreed() {
if (freed) {
throw new IllegalStateException("Transaction has been freed");
}
}
-
+
+ private static void requireCf(ColumnFamily cf) {
+ if (cf == null) {
+ throw new IllegalArgumentException("Column family cannot be null");
+ }
+ cf.checkOwnerOpen();
+ }
+
+ private static void requireKey(byte[] key) {
+ if (key == null || key.length == 0) {
+ throw new IllegalArgumentException("Key cannot be null or empty");
+ }
+ }
+
+ private static void requireName(String name) {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("Savepoint name cannot be null or empty");
+ }
+ }
+
+ private static void requireBoundSize(byte[] bound, String what) {
+ if (bound.length > MAX_RANGE_BOUND_SIZE) {
+ throw new IllegalArgumentException(
+ what + " must be at most " + MAX_RANGE_BOUND_SIZE + " bytes, was: " + bound.length);
+ }
+ }
+
long getNativeHandle() {
return nativeHandle;
}
-
- private static native void nativePut(long handle, long cfHandle, byte[] key, byte[] value, long ttl) throws TidesDBException;
+
+ private static native void nativePut(long handle, long cfHandle, byte[] key, byte[] value,
+ long ttlSeconds) throws TidesDBException;
+
private static native byte[] nativeGet(long handle, long cfHandle, byte[] key) throws TidesDBException;
+
+ private static native byte[] nativeGetNoTrack(long handle, long cfHandle, byte[] key) throws TidesDBException;
+
+ private static native boolean nativeContains(long handle, long cfHandle, byte[] key) throws TidesDBException;
+
+ private static native long nativeReadSnapshot(long handle);
+
private static native void nativeDelete(long handle, long cfHandle, byte[] key) throws TidesDBException;
+
private static native void nativeSingleDelete(long handle, long cfHandle, byte[] key) throws TidesDBException;
+
+ private static native void nativeDeleteRange(long handle, long cfHandle, byte[] lo, byte[] hi) throws TidesDBException;
+
+ private static native void nativeDeletePrefix(long handle, long cfHandle, byte[] prefix) throws TidesDBException;
+
+ private static native long nativeNewIterator(long handle, long cfHandle) throws TidesDBException;
+
+ private static native long nativeNewRangeIterator(long handle, long cfHandle, byte[] lower,
+ byte[] upper) throws TidesDBException;
+
private static native void nativeCommit(long handle) throws TidesDBException;
+
private static native void nativeRollback(long handle) throws TidesDBException;
+
+ private static native void nativeSetTimeout(long handle, long seconds) throws TidesDBException;
+
+ private static native void nativeRequestAbort(long handle);
+
+ private static native void nativeReset(long handle, int isolationLevel) throws TidesDBException;
+
+ private static native int nativeState(long handle) throws TidesDBException;
+
+ private static native void nativePrepare(long handle, byte[] xid) throws TidesDBException;
+
+ private static native void nativeCommitPrepared(long handle) throws TidesDBException;
+
+ private static native void nativeRollbackPrepared(long handle) throws TidesDBException;
+
private static native void nativeSavepoint(long handle, String name) throws TidesDBException;
+
private static native void nativeRollbackToSavepoint(long handle, String name) throws TidesDBException;
+
private static native void nativeReleaseSavepoint(long handle, String name) throws TidesDBException;
- private static native long nativeNewIterator(long handle, long cfHandle) throws TidesDBException;
- private static native void nativeReset(long handle, int isolationLevel) throws TidesDBException;
+
private static native void nativeFree(long handle);
}
diff --git a/src/main/java/com/tidesdb/TransactionState.java b/src/main/java/com/tidesdb/TransactionState.java
new file mode 100644
index 0000000..1c04541
--- /dev/null
+++ b/src/main/java/com/tidesdb/TransactionState.java
@@ -0,0 +1,80 @@
+/**
+ *
+ * Copyright (C) TidesDB
+ *
+ * Original Author: Alex Gaetano Padula
+ *
+ * Licensed under the Mozilla Public License, v. 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
+ *
+ * https://www.mozilla.org/en-US/MPL/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 com.tidesdb;
+
+/**
+ * Transaction lifecycle state, reported by {@link Transaction#state()} for
+ * two-phase-commit coordination. Each constant maps to an integer used by the
+ * JNI bridge.
+ */
+public enum TransactionState {
+
+ /**
+ * Buffering writes, not yet resolved.
+ */
+ ACTIVE(0),
+
+ /**
+ * Durably prepared under an xid, awaiting commit or rollback.
+ */
+ PREPARED(1),
+
+ /**
+ * Committed and applied.
+ */
+ COMMITTED(2),
+
+ /**
+ * Rolled back or expired.
+ */
+ ABORTED(3);
+
+ private final int value;
+
+ TransactionState(int value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the JNI numeric mapping for this transaction state.
+ *
+ * @return the integer value passed to the native library
+ */
+ public int getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the {@link TransactionState} constant matching the given JNI
+ * integer value.
+ *
+ * @param value the JNI integer value
+ * @return the matching constant
+ * @throws IllegalArgumentException if {@code value} does not map to any
+ * known constant
+ */
+ public static TransactionState fromValue(int value) {
+ for (TransactionState state : values()) {
+ if (state.value == value) {
+ return state;
+ }
+ }
+ throw new IllegalArgumentException("Unknown transaction state value: " + value);
+ }
+}
diff --git a/src/test/java/com/tidesdb/PojoAndEnumTest.java b/src/test/java/com/tidesdb/PojoAndEnumTest.java
index 289a17f..469f5c7 100644
--- a/src/test/java/com/tidesdb/PojoAndEnumTest.java
+++ b/src/test/java/com/tidesdb/PojoAndEnumTest.java
@@ -8,7 +8,7 @@
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
- * https://www.mozilla.org.org/en-US/MPL/2.0/
+ * https://www.mozilla.org/en-US/MPL/2.0/
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@@ -18,825 +18,629 @@
*/
package com.tidesdb;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import java.nio.file.Path;
+import java.nio.charset.StandardCharsets;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;
/**
- * Tests for pure-Java POJOs, enums, builders, and exception classes.
- * Covers branches and constructors not exercised by the integration tests
- * in {@link TidesDBTest}.
+ * Tests for the value types and enums, which need no open database.
*/
-class PojoAndEnumTest {
+public class PojoAndEnumTest {
+
+ @Nested
+ class Enums {
+
+ @Test
+ void logLevelMapsToTheNativeOrdering() {
+ assertEquals(0, LogLevel.NONE.getValue());
+ assertEquals(1, LogLevel.TRACE.getValue());
+ assertEquals(2, LogLevel.INFO.getValue());
+ assertEquals(3, LogLevel.WARN.getValue());
+ assertEquals(4, LogLevel.ERROR.getValue());
+
+ for (LogLevel level : LogLevel.values()) {
+ assertEquals(level, LogLevel.fromValue(level.getValue()));
+ }
+ assertThrows(IllegalArgumentException.class, () -> LogLevel.fromValue(99));
+ }
- // -----------------------------------------------------------------------
- // TidesDBException
- // -----------------------------------------------------------------------
+ @Test
+ void isolationLevelRunsWeakestToStrongest() {
+ assertEquals(0, IsolationLevel.READ_UNCOMMITTED.getValue());
+ assertEquals(1, IsolationLevel.READ_COMMITTED.getValue());
+ assertEquals(2, IsolationLevel.REPEATABLE_READ.getValue());
+ assertEquals(3, IsolationLevel.SNAPSHOT.getValue());
+ assertEquals(4, IsolationLevel.SERIALIZABLE.getValue());
+
+ for (IsolationLevel level : IsolationLevel.values()) {
+ assertEquals(level, IsolationLevel.fromValue(level.getValue()));
+ }
+ assertThrows(IllegalArgumentException.class, () -> IsolationLevel.fromValue(5));
+ }
- @Test
- void tidesDBException_messageConstructor_defaultsToUnknownErrorCode() {
- TidesDBException ex = new TidesDBException("boom");
- assertEquals("boom", ex.getMessage());
- assertEquals(TidesDBException.ERR_UNKNOWN, ex.getErrorCode());
- }
+ @Test
+ void syncModeMapsToTheNativeValues() {
+ assertEquals(0, SyncMode.SYNC_NONE.getValue());
+ assertEquals(1, SyncMode.SYNC_FULL.getValue());
+ assertEquals(2, SyncMode.SYNC_INTERVAL.getValue());
- @Test
- void tidesDBException_messageAndCodeConstructor() {
- TidesDBException ex = new TidesDBException("not found", TidesDBException.ERR_NOT_FOUND);
- assertEquals("not found", ex.getMessage());
- assertEquals(TidesDBException.ERR_NOT_FOUND, ex.getErrorCode());
- }
+ for (SyncMode mode : SyncMode.values()) {
+ assertEquals(mode, SyncMode.fromValue(mode.getValue()));
+ }
+ assertThrows(IllegalArgumentException.class, () -> SyncMode.fromValue(3));
+ }
- @Test
- void tidesDBException_messageAndCauseConstructor() {
- RuntimeException cause = new RuntimeException("root");
- TidesDBException ex = new TidesDBException("wrapper", cause);
- assertEquals("wrapper", ex.getMessage());
- assertSame(cause, ex.getCause());
- assertEquals(TidesDBException.ERR_UNKNOWN, ex.getErrorCode());
- }
+ @Test
+ void compressionAlgorithmValuesAreTheEncodingIds() {
+ assertEquals(0, CompressionAlgorithm.NONE.getValue());
+ assertEquals(1, CompressionAlgorithm.SNAPPY.getValue());
+ assertEquals(2, CompressionAlgorithm.LZ4.getValue());
+ assertEquals(3, CompressionAlgorithm.ZSTD.getValue());
+ assertEquals(4, CompressionAlgorithm.LZ4_FAST.getValue());
+
+ for (CompressionAlgorithm algo : CompressionAlgorithm.values()) {
+ assertEquals(algo, CompressionAlgorithm.fromValue(algo.getValue()));
+ }
+ assertThrows(IllegalArgumentException.class, () -> CompressionAlgorithm.fromValue(5));
+ }
- @Test
- void tidesDBException_messageCodeAndCauseConstructor() {
- RuntimeException cause = new RuntimeException("root");
- TidesDBException ex = new TidesDBException("io err", TidesDBException.ERR_IO, cause);
- assertEquals("io err", ex.getMessage());
- assertEquals(TidesDBException.ERR_IO, ex.getErrorCode());
- assertSame(cause, ex.getCause());
- }
+ @Test
+ void transactionStateCoversTheLifecycle() {
+ assertEquals(0, TransactionState.ACTIVE.getValue());
+ assertEquals(1, TransactionState.PREPARED.getValue());
+ assertEquals(2, TransactionState.COMMITTED.getValue());
+ assertEquals(3, TransactionState.ABORTED.getValue());
+
+ for (TransactionState state : TransactionState.values()) {
+ assertEquals(state, TransactionState.fromValue(state.getValue()));
+ }
+ assertThrows(IllegalArgumentException.class, () -> TransactionState.fromValue(4));
+ }
- @Test
- void tidesDBException_getErrorMessage_coversAllKnownCodes() {
- assertErrorMessage(TidesDBException.ERR_SUCCESS, "success");
- assertErrorMessage(TidesDBException.ERR_MEMORY, "memory allocation failed");
- assertErrorMessage(TidesDBException.ERR_INVALID_ARGS, "invalid arguments");
- assertErrorMessage(TidesDBException.ERR_NOT_FOUND, "not found");
- assertErrorMessage(TidesDBException.ERR_IO, "I/O error");
- assertErrorMessage(TidesDBException.ERR_CORRUPTION, "data corruption");
- assertErrorMessage(TidesDBException.ERR_EXISTS, "already exists");
- assertErrorMessage(TidesDBException.ERR_CONFLICT, "transaction conflict");
- assertErrorMessage(TidesDBException.ERR_TOO_LARGE, "key or value too large");
- assertErrorMessage(TidesDBException.ERR_MEMORY_LIMIT, "memory limit exceeded");
- assertErrorMessage(TidesDBException.ERR_INVALID_DB, "invalid database handle");
- assertErrorMessage(TidesDBException.ERR_LOCKED, "database is locked");
- }
+ @Test
+ void stallReasonValuesAreContiguousIndices() {
+ StallReason[] reasons = StallReason.values();
+ for (int i = 0; i < reasons.length; i++) {
+ assertEquals(i, reasons[i].getValue(), "the value is the index into StallStats");
+ assertEquals(reasons[i], StallReason.fromValue(i));
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> StallReason.fromValue(reasons.length));
+ }
- @Test
- void tidesDBException_getErrorMessage_unknownCodeReturnsUnknownError() {
- TidesDBException ex = new TidesDBException("msg", 9999);
- assertEquals("unknown error", ex.getErrorMessage());
- }
+ @Test
+ void ioClassValuesAreContiguousIndices() {
+ IoClass[] classes = IoClass.values();
+ for (int i = 0; i < classes.length; i++) {
+ assertEquals(i, classes[i].getValue(), "the value is the index into IoStats");
+ assertEquals(classes[i], IoClass.fromValue(i));
+ }
+ assertThrows(IllegalArgumentException.class, () -> IoClass.fromValue(classes.length));
+ }
- @Test
- void tidesDBException_errorCodeConstants_areDistinct() {
- int[] codes = {
- TidesDBException.ERR_SUCCESS,
- TidesDBException.ERR_MEMORY,
- TidesDBException.ERR_INVALID_ARGS,
- TidesDBException.ERR_NOT_FOUND,
- TidesDBException.ERR_IO,
- TidesDBException.ERR_CORRUPTION,
- TidesDBException.ERR_EXISTS,
- TidesDBException.ERR_CONFLICT,
- TidesDBException.ERR_TOO_LARGE,
- TidesDBException.ERR_MEMORY_LIMIT,
- TidesDBException.ERR_INVALID_DB,
- TidesDBException.ERR_UNKNOWN,
- TidesDBException.ERR_LOCKED,
- };
- assertThat(codes).doesNotHaveDuplicates();
+ @Test
+ void stallReasonAndIoClassCarryNativeNames() {
+ for (StallReason reason : StallReason.values()) {
+ assertNotNull(reason.getNativeName());
+ assertFalse(reason.getNativeName().isEmpty());
+ }
+ for (IoClass cls : IoClass.values()) {
+ assertNotNull(cls.getNativeName());
+ assertFalse(cls.getNativeName().isEmpty());
+ }
+ }
}
- private void assertErrorMessage(int code, String expectedMessage) {
- TidesDBException ex = new TidesDBException("irrelevant", code);
- assertEquals(expectedMessage, ex.getErrorMessage(),
- "Error code " + code + " should map to '" + expectedMessage + "'");
- }
+ @Nested
+ class DatabaseConfig {
+
+ @Test
+ void carriesTheNativeDefaults() {
+ Config config = Config.defaultConfig("/tmp/tidesdb-config-test");
+
+ assertEquals("/tmp/tidesdb-config-test", config.getDbPath());
+ assertTrue(config.getNumFlushThreads() > 0);
+ assertTrue(config.getNumCompactionThreads() > 0);
+ assertTrue(config.getBlockCacheSize() > 0);
+ assertTrue(config.getMaxOpenSSTables() > 0);
+ assertTrue(config.getMemtableWriteBufferSize() > 0);
+ assertTrue(config.getValueSeparationThreshold() > 0);
+ assertTrue(config.getVlogSegmentSize() > 0);
+ assertNotNull(config.getLogLevel());
+ assertNotNull(config.getMemtableSyncMode());
+ assertNotNull(config.toString());
+ }
- // -----------------------------------------------------------------------
- // KeyValue
- // -----------------------------------------------------------------------
-
- @Test
- void keyValue_constructorAndGetters() {
- byte[] k = {1, 2, 3};
- byte[] v = {4, 5};
- KeyValue kv = new KeyValue(k, v);
- assertSame(k, kv.getKey());
- assertSame(v, kv.getValue());
- }
+ @Test
+ void roundTripsThroughItsBuilder() {
+ Config config = Config.builder("/tmp/db")
+ .numFlushThreads(3)
+ .numCompactionThreads(5)
+ .logLevel(LogLevel.WARN)
+ .blockCacheSize(1024)
+ .maxOpenSSTables(64)
+ .logToFile(true)
+ .logTruncationAt(2048)
+ .memtableWriteBufferSize(4096)
+ .memtableSkipListMaxLevel(16)
+ .memtableSkipListProbability(0.5f)
+ .memtableSyncMode(SyncMode.SYNC_FULL)
+ .memtableSyncIntervalUs(1000)
+ .valueSeparationThreshold(512)
+ .vlogSegmentSize(8192)
+ .memtableL0QueueStallThreshold(8)
+ .memtableIdleFlushSeconds(30)
+ .txnTimeoutSeconds(60)
+ .build();
+
+ assertEquals("/tmp/db", config.getDbPath());
+ assertEquals(3, config.getNumFlushThreads());
+ assertEquals(5, config.getNumCompactionThreads());
+ assertEquals(LogLevel.WARN, config.getLogLevel());
+ assertEquals(1024, config.getBlockCacheSize());
+ assertEquals(64, config.getMaxOpenSSTables());
+ assertTrue(config.isLogToFile());
+ assertEquals(2048, config.getLogTruncationAt());
+ assertEquals(4096, config.getMemtableWriteBufferSize());
+ assertEquals(16, config.getMemtableSkipListMaxLevel());
+ assertEquals(0.5f, config.getMemtableSkipListProbability());
+ assertEquals(SyncMode.SYNC_FULL, config.getMemtableSyncMode());
+ assertEquals(1000, config.getMemtableSyncIntervalUs());
+ assertEquals(512, config.getValueSeparationThreshold());
+ assertEquals(8192, config.getVlogSegmentSize());
+ assertEquals(8, config.getMemtableL0QueueStallThreshold());
+ assertEquals(30, config.getMemtableIdleFlushSeconds());
+ assertEquals(60, config.getTxnTimeoutSeconds());
+
+ Config copy = config.toBuilder().build();
+ assertEquals(config.toString(), copy.toString());
+ }
- @Test
- void keyValue_nullKeyAndValue() {
- KeyValue kv = new KeyValue(null, null);
- assertNull(kv.getKey());
- assertNull(kv.getValue());
- }
+ @Test
+ void rejectsNegativeAndNullFields() {
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder(null).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").numFlushThreads(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").numCompactionThreads(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").blockCacheSize(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").maxOpenSSTables(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").logTruncationAt(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableWriteBufferSize(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").valueSeparationThreshold(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").vlogSegmentSize(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableL0QueueStallThreshold(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableIdleFlushSeconds(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").txnTimeoutSeconds(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").logLevel(null).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableSyncMode(null).build());
+ }
- // -----------------------------------------------------------------------
- // CommitOp
- // -----------------------------------------------------------------------
-
- @Test
- void commitOp_putOperation() {
- byte[] key = {10};
- byte[] val = {20};
- CommitOp op = new CommitOp(key, val, 3600L, false);
- assertSame(key, op.getKey());
- assertSame(val, op.getValue());
- assertEquals(3600L, op.getTtl());
- assertFalse(op.isDelete());
+ @Test
+ void boundsSkipListProbability() {
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableSkipListProbability(-0.1f).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableSkipListProbability(1.1f).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> Config.builder("/tmp/db").memtableSkipListProbability(Float.NaN).build());
+ assertDoesNotThrow(
+ () -> Config.builder("/tmp/db").memtableSkipListProbability(0.25f).build());
+ }
}
- @Test
- void commitOp_deleteOperation() {
- byte[] key = {10};
- CommitOp op = new CommitOp(key, null, -1, true);
- assertSame(key, op.getKey());
- assertNull(op.getValue());
- assertEquals(-1, op.getTtl());
- assertTrue(op.isDelete());
- }
+ @Nested
+ class FamilyConfig {
- // -----------------------------------------------------------------------
- // CacheStats
- // -----------------------------------------------------------------------
-
- @Test
- void cacheStats_constructorAndGetters() {
- CacheStats cs = new CacheStats(true, 100, 4096, 80, 20, 0.8, 4);
- assertTrue(cs.isEnabled());
- assertEquals(100, cs.getTotalEntries());
- assertEquals(4096, cs.getTotalBytes());
- assertEquals(80, cs.getHits());
- assertEquals(20, cs.getMisses());
- assertEquals(0.8, cs.getHitRate(), 1e-9);
- assertEquals(4, cs.getNumPartitions());
- }
+ @Test
+ void carriesTheNativeDefaults() {
+ ColumnFamilyConfig config = ColumnFamilyConfig.defaultConfig();
- @Test
- void cacheStats_toString() {
- CacheStats cs = new CacheStats(false, 0, 0, 0, 0, 0.0, 1);
- String str = cs.toString();
- assertThat(str).contains("enabled=false");
- assertThat(str).contains("totalEntries=0");
- assertThat(str).contains("totalBytes=0");
- assertThat(str).contains("hits=0");
- assertThat(str).contains("misses=0");
- assertThat(str).contains("hitRate=0.0");
- assertThat(str).contains("numPartitions=1");
- }
+ assertTrue(config.getLevelSizeRatio() > 0);
+ assertTrue(config.getBtreeKlogBlockSize() > 0);
+ assertNotNull(config.getEncodingPipeline());
+ assertNotNull(config.getDefaultIsolationLevel());
+ assertNotNull(config.getName());
+ assertNotNull(config.toString());
+ }
- // -----------------------------------------------------------------------
- // DbStats
- // -----------------------------------------------------------------------
-
- @Test
- void dbStats_constructorAndGetters() {
- DbStats stats = new DbStats(
- 5, 1000000, 500000, 2000000, 1, 3,
- 8000, 2, 10, 50000, 8, 42,
- 100, 0, 0,
- false, 0, 0, false, 0, 0,
- false, null, 0, 0, 0, 0, 0, 0, 0,
- false, 0, 0,
- 0, 5000, 3000, 4000, 2000, 6000, 10, 20);
-
- assertEquals(5, stats.getNumColumnFamilies());
- assertEquals(1000000, stats.getTotalMemory());
- assertEquals(500000, stats.getAvailableMemory());
- assertEquals(2000000, stats.getResolvedMemoryLimit());
- assertEquals(1, stats.getMemoryPressureLevel());
- assertEquals(3, stats.getFlushPendingCount());
- assertEquals(8000, stats.getTotalMemtableBytes());
- assertEquals(2, stats.getTotalImmutableCount());
- assertEquals(10, stats.getTotalSstableCount());
- assertEquals(50000, stats.getTotalDataSizeBytes());
- assertEquals(8, stats.getNumOpenSstables());
- assertEquals(42, stats.getGlobalSeq());
- assertEquals(100, stats.getTxnMemoryBytes());
- assertEquals(0, stats.getCompactionQueueSize());
- assertEquals(0, stats.getFlushQueueSize());
- assertFalse(stats.isUnifiedMemtableEnabled());
- assertEquals(0, stats.getUnifiedMemtableBytes());
- assertEquals(0, stats.getUnifiedImmutableCount());
- assertFalse(stats.isUnifiedIsFlushing());
- assertEquals(0, stats.getUnifiedNextCfIndex());
- assertEquals(0, stats.getUnifiedWalGeneration());
- assertFalse(stats.isObjectStoreEnabled());
- assertNull(stats.getObjectStoreConnector());
- assertEquals(0, stats.getLocalCacheBytesUsed());
- assertEquals(0, stats.getLocalCacheBytesMax());
- assertEquals(0, stats.getLocalCacheNumFiles());
- assertEquals(0, stats.getLastUploadedGeneration());
- assertEquals(0, stats.getUploadQueueDepth());
- assertEquals(0, stats.getTotalUploads());
- assertEquals(0, stats.getTotalUploadFailures());
- assertFalse(stats.isReplicaMode());
- assertEquals(0, stats.getPrimaryEpoch());
- assertEquals(0, stats.getSeenEpoch());
- assertEquals(0, stats.getUwalBytesWritten());
- assertEquals(5000, stats.getWalBytesWritten());
- assertEquals(3000, stats.getFlushBytesWritten());
- assertEquals(4000, stats.getCompactionBytesWritten());
- assertEquals(2000, stats.getCompactionBytesRead());
- assertEquals(6000, stats.getUserBytesWritten());
- assertEquals(10, stats.getFlushCount());
- assertEquals(20, stats.getCompactionCount());
- }
+ @Test
+ void roundTripsThroughItsBuilder() {
+ ColumnFamilyConfig config = ColumnFamilyConfig.builder()
+ .levelSizeRatio(12)
+ .minLevels(2)
+ .dividingLevelOffset(1)
+ .keepValuesInline(true)
+ .btreeKlogBlockSize(8192)
+ .encodingPipeline(CompressionAlgorithm.LZ4, CompressionAlgorithm.ZSTD)
+ .enableBloomFilter(true)
+ .bloomFpr(0.02)
+ .defaultIsolationLevel(IsolationLevel.SERIALIZABLE)
+ .l1FileCountTrigger(6)
+ .tombstoneDensityTrigger(0.4)
+ .tombstoneDensityMinEntries(1000)
+ .build();
+
+ assertEquals(12, config.getLevelSizeRatio());
+ assertEquals(2, config.getMinLevels());
+ assertEquals(1, config.getDividingLevelOffset());
+ assertTrue(config.isKeepValuesInline());
+ assertEquals(8192, config.getBtreeKlogBlockSize());
+ assertArrayEquals(new int[]{2, 3}, config.getEncodingPipeline());
+ assertTrue(config.isEnableBloomFilter());
+ assertEquals(0.02, config.getBloomFpr(), 1e-9);
+ assertEquals(IsolationLevel.SERIALIZABLE, config.getDefaultIsolationLevel());
+ assertEquals(6, config.getL1FileCountTrigger());
+ assertEquals(0.4, config.getTombstoneDensityTrigger(), 1e-9);
+ assertEquals(1000, config.getTombstoneDensityMinEntries());
+
+ ColumnFamilyConfig copy = config.toBuilder().build();
+ assertEquals(config.toString(), copy.toString());
+ }
- @Test
- void dbStats_toString() {
- DbStats stats = new DbStats(
- 2, 100, 50, 200, 0, 0,
- 30, 0, 5, 100, 3, 1,
- 0, 0, 0,
- false, 0, 0, false, 0, 0,
- false, null, 0, 0, 0, 0, 0, 0, 0,
- false, 0, 0,
- 0, 100, 200, 300, 400, 500, 1, 2);
-
- String str = stats.toString();
- assertThat(str).contains("numColumnFamilies=2");
- assertThat(str).contains("totalMemory=100");
- assertThat(str).contains("replicaMode=false");
- }
+ @Test
+ void treatsNoCompressionAsAnEmptyPipeline() {
+ assertArrayEquals(new int[0], ColumnFamilyConfig.builder()
+ .compression(CompressionAlgorithm.NONE).build().getEncodingPipeline());
+ assertArrayEquals(new int[]{CompressionAlgorithm.SNAPPY.getValue()},
+ ColumnFamilyConfig.builder()
+ .compression(CompressionAlgorithm.SNAPPY).build().getEncodingPipeline());
+ }
- // -----------------------------------------------------------------------
- // CompressionAlgorithm enum
- // -----------------------------------------------------------------------
-
- @Test
- void compressionAlgorithm_values() {
- assertEquals(0, CompressionAlgorithm.NO_COMPRESSION.getValue());
- assertEquals(1, CompressionAlgorithm.SNAPPY_COMPRESSION.getValue());
- assertEquals(2, CompressionAlgorithm.LZ4_COMPRESSION.getValue());
- assertEquals(3, CompressionAlgorithm.ZSTD_COMPRESSION.getValue());
- assertEquals(4, CompressionAlgorithm.LZ4_FAST_COMPRESSION.getValue());
- }
+ @Test
+ void acceptsRawEncodingIds() {
+ ColumnFamilyConfig config =
+ ColumnFamilyConfig.builder().encodingPipelineIds(2, 3).build();
+ assertArrayEquals(new int[]{2, 3}, config.getEncodingPipeline());
- @Test
- void compressionAlgorithm_fromValue_roundTripsAllValues() {
- for (CompressionAlgorithm algo : CompressionAlgorithm.values()) {
- assertEquals(algo, CompressionAlgorithm.fromValue(algo.getValue()),
- "fromValue should round-trip for " + algo.name());
+ assertArrayEquals(new int[0],
+ ColumnFamilyConfig.builder().encodingPipelineIds().build().getEncodingPipeline());
}
- }
- @Test
- void compressionAlgorithm_fromValue_invalidValueThrows() {
- assertThatThrownBy(() -> CompressionAlgorithm.fromValue(99))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("99");
- }
+ @Test
+ void copiesThePipelineOnTheWayInAndOut() {
+ int[] source = {2, 3};
+ ColumnFamilyConfig config =
+ ColumnFamilyConfig.builder().encodingPipelineIds(source).build();
- // -----------------------------------------------------------------------
- // IsolationLevel enum
- // -----------------------------------------------------------------------
-
- @Test
- void isolationLevel_values() {
- assertEquals(0, IsolationLevel.READ_UNCOMMITTED.getValue());
- assertEquals(1, IsolationLevel.READ_COMMITTED.getValue());
- assertEquals(2, IsolationLevel.REPEATABLE_READ.getValue());
- assertEquals(3, IsolationLevel.SNAPSHOT.getValue());
- assertEquals(4, IsolationLevel.SERIALIZABLE.getValue());
- }
+ source[0] = 99;
+ assertArrayEquals(new int[]{2, 3}, config.getEncodingPipeline(),
+ "the builder took a copy");
- @Test
- void isolationLevel_fromValue_roundTripsAllValues() {
- for (IsolationLevel level : IsolationLevel.values()) {
- assertEquals(level, IsolationLevel.fromValue(level.getValue()),
- "fromValue should round-trip for " + level.name());
+ int[] returned = config.getEncodingPipeline();
+ returned[0] = 99;
+ assertArrayEquals(new int[]{2, 3}, config.getEncodingPipeline(),
+ "the getter returned a copy");
}
- }
- @Test
- void isolationLevel_fromValue_invalidValueThrows() {
- assertThatThrownBy(() -> IsolationLevel.fromValue(-99))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("-99");
- }
+ @Test
+ void boundsThePipelineLength() {
+ int[] tooMany = new int[ColumnFamilyConfig.MAX_ENCODING_PIPELINE + 1];
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().encodingPipelineIds(tooMany).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().encodingPipelineIds(256).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().encodingPipelineIds(-1).build());
+ }
- // -----------------------------------------------------------------------
- // LogLevel enum
- // -----------------------------------------------------------------------
-
- @Test
- void logLevel_values() {
- assertEquals(0, LogLevel.DEBUG.getValue());
- assertEquals(1, LogLevel.INFO.getValue());
- assertEquals(2, LogLevel.WARN.getValue());
- assertEquals(3, LogLevel.ERROR.getValue());
- assertEquals(4, LogLevel.FATAL.getValue());
- assertEquals(99, LogLevel.NONE.getValue());
- }
+ @Test
+ void rejectsInvalidFields() {
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().bloomFpr(-0.1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().bloomFpr(1.0).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().bloomFpr(Double.NaN).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().tombstoneDensityTrigger(1.5).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().minLevels(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().l1FileCountTrigger(-1).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().defaultIsolationLevel(null).build());
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().compression(null).build());
+ }
- @Test
- void logLevel_fromValue_roundTripsAllValues() {
- for (LogLevel level : LogLevel.values()) {
- assertEquals(level, LogLevel.fromValue(level.getValue()),
- "fromValue should round-trip for " + level.name());
+ @Test
+ void boundsTheName() {
+ StringBuilder tooLong = new StringBuilder();
+ for (int i = 0; i < ColumnFamilyConfig.MAX_NAME_LENGTH; i++) {
+ tooLong.append('x');
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> ColumnFamilyConfig.builder().name(tooLong.toString()).build());
+ assertEquals("", ColumnFamilyConfig.builder().name(null).build().getName());
}
}
- @Test
- void logLevel_fromValue_invalidValueThrows() {
- assertThatThrownBy(() -> LogLevel.fromValue(42))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("42");
- }
+ @Nested
+ class ValueTypes {
- // -----------------------------------------------------------------------
- // SyncMode enum
- // -----------------------------------------------------------------------
+ @Test
+ void commitOpCarriesItsFields() {
+ byte[] key = "k".getBytes(StandardCharsets.UTF_8);
+ byte[] value = "v".getBytes(StandardCharsets.UTF_8);
- @Test
- void syncMode_values() {
- assertEquals(0, SyncMode.SYNC_NONE.getValue());
- assertEquals(1, SyncMode.SYNC_FULL.getValue());
- assertEquals(2, SyncMode.SYNC_INTERVAL.getValue());
- }
+ CommitOp put = new CommitOp(key, value, 12345L, false);
+ assertArrayEquals(key, put.getKey());
+ assertArrayEquals(value, put.getValue());
+ assertEquals(12345L, put.getTtl());
+ assertFalse(put.isDelete());
- @Test
- void syncMode_fromValue_roundTripsAllValues() {
- for (SyncMode mode : SyncMode.values()) {
- assertEquals(mode, SyncMode.fromValue(mode.getValue()),
- "fromValue should round-trip for " + mode.name());
+ CommitOp delete = new CommitOp(key, null, -1L, true);
+ assertNull(delete.getValue());
+ assertTrue(delete.isDelete());
}
- }
- @Test
- void syncMode_fromValue_invalidValueThrows() {
- assertThatThrownBy(() -> SyncMode.fromValue(-1))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("-1");
- }
+ @Test
+ void keyValueCarriesBothHalves() {
+ byte[] key = {1, 2};
+ byte[] value = {3, 4};
+ KeyValue kv = new KeyValue(key, value);
+ assertArrayEquals(key, kv.getKey());
+ assertArrayEquals(value, kv.getValue());
+ }
+
+ @Test
+ void cacheStatsCarriesItsFields() {
+ CacheStats stats = new CacheStats(true, 10, 2048, 7, 3, 0.7, 8);
+ assertTrue(stats.isEnabled());
+ assertEquals(10, stats.getTotalEntries());
+ assertEquals(2048, stats.getTotalBytes());
+ assertEquals(7, stats.getHits());
+ assertEquals(3, stats.getMisses());
+ assertEquals(0.7, stats.getHitRate(), 1e-9);
+ assertEquals(8, stats.getNumPartitions());
+ assertNotNull(stats.toString());
+ }
- // -----------------------------------------------------------------------
- // Config.Builder validation
- // -----------------------------------------------------------------------
+ @Test
+ void stallStatsIsIndexedByReason() {
+ StallStat[] reasons = new StallStat[StallReason.values().length];
+ for (int i = 0; i < reasons.length; i++) {
+ reasons[i] = new StallStat(i, i * 10L, i * 100L);
+ }
+ StallStats stats = new StallStats(reasons);
+
+ assertEquals(0, stats.get(StallReason.WAL_APPEND).getCount());
+ assertEquals(StallReason.MANIFEST_COMMIT.getValue(),
+ stats.get(StallReason.MANIFEST_COMMIT).getCount());
+ assertEquals(reasons.length, stats.getReasons().length);
+ assertNotNull(stats.toString());
+
+ long expectedTotal = 0;
+ for (StallStat r : reasons) {
+ expectedTotal += r.getTotalUs();
+ }
+ assertEquals(expectedTotal, stats.getTotalUs());
+ }
- @Test
- void configBuilder_nullDbPathThrows() {
- assertThatThrownBy(() -> Config.builder(null).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Database path");
- }
+ @Test
+ void stallStatsRejectsAMissizedArray() {
+ assertThrows(IllegalArgumentException.class, () -> new StallStats(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new StallStats(new StallStat[]{new StallStat(0, 0, 0)}));
+ }
- @Test
- void configBuilder_numFlushThreadsZeroOrNegativeThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).numFlushThreads(0).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("flush threads");
- assertThatThrownBy(() -> Config.builder(dir.toString()).numFlushThreads(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("flush threads");
- }
+ @Test
+ void ioStatsIsIndexedByClass() {
+ IoStat[] classes = new IoStat[IoClass.values().length];
+ for (int i = 0; i < classes.length; i++) {
+ classes[i] = new IoStat(i, i * 1000L, i * 10L, i);
+ }
+ IoStats stats = new IoStats(classes);
+
+ assertEquals(0, stats.get(IoClass.SSTABLE).getOps());
+ assertEquals(IoClass.VLOG.getValue(), stats.get(IoClass.VLOG).getOps());
+ assertEquals(classes.length, stats.getClasses().length);
+ assertNotNull(stats.toString());
+ }
- @Test
- void configBuilder_numCompactionThreadsZeroOrNegativeThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).numCompactionThreads(0).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("compaction threads");
- assertThatThrownBy(() -> Config.builder(dir.toString()).numCompactionThreads(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("compaction threads");
- }
+ @Test
+ void ioStatComputesThroughput() {
+ assertEquals(0.0, new IoStat(0, 0, 0, 0).getBytesPerSecond(), 1e-9);
+ assertEquals(1_000_000.0, new IoStat(1, 1_000_000, 1_000_000, 1).getBytesPerSecond(),
+ 1e-6);
+ }
- @Test
- void configBuilder_nullLogLevelThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).logLevel(null).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Log level");
- }
+ @Test
+ void ioStatsRejectsAMissizedArray() {
+ assertThrows(IllegalArgumentException.class, () -> new IoStats(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new IoStats(new IoStat[]{new IoStat(0, 0, 0, 0)}));
+ }
- @Test
- void configBuilder_negativeBlockCacheSizeThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).blockCacheSize(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Block cache size");
- }
+ @Test
+ void encodingStatsComputesItsRatio() {
+ EncodingStats stats = new EncodingStats(new int[]{2}, 1000, 250, 4);
+ assertArrayEquals(new int[]{2}, stats.getIds());
+ assertEquals(1000, stats.getLogicalBytes());
+ assertEquals(250, stats.getStoredBytes());
+ assertEquals(4, stats.getItemCount());
+ assertEquals(4.0, stats.getRatio(), 1e-9);
+ assertNotNull(stats.toString());
+
+ assertEquals(0.0, new EncodingStats(null, 100, 0, 0).getRatio(), 1e-9,
+ "nothing stored means no ratio to report");
+ assertArrayEquals(new int[0], new EncodingStats(null, 0, 0, 0).getIds());
+ }
- @Test
- void configBuilder_zeroOrNegativeMaxOpenSSTablesThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).maxOpenSSTables(0).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Max open SSTables");
- assertThatThrownBy(() -> Config.builder(dir.toString()).maxOpenSSTables(-5).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Max open SSTables");
- }
+ @Test
+ void rangeStatsCarriesItsFields() {
+ RangeStats exact = new RangeStats(3, 500, true);
+ assertEquals(3, exact.getSstablesOverlapping());
+ assertEquals(500, exact.getEstimatedKeys());
+ assertTrue(exact.isKeysExact());
+ assertNotNull(exact.toString());
- @Test
- void configBuilder_negativeMaxConcurrentFlushesThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).maxConcurrentFlushes(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("maxConcurrentFlushes");
- }
+ assertFalse(new RangeStats(9, 100_000, false).isKeysExact());
+ }
- @Test
- void configBuilder_negativeUnifiedMemtableSkipListMaxLevelThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSkipListMaxLevel(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSkipListMaxLevel");
- }
+ @Test
+ void preparedTransactionCopiesItsXid() {
+ byte[] xid = {1, 2, 3};
+ PreparedTransaction prepared = new PreparedTransaction(null, xid);
- @Test
- void configBuilder_negativeUnifiedMemtableSyncModeThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSyncMode(-1).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSyncMode");
- }
+ xid[0] = 99;
+ assertArrayEquals(new byte[]{1, 2, 3}, prepared.getXid(),
+ "the constructor holds the array it was given, and the getter copies it");
- @Test
- void configBuilder_unifiedMemtableSkipListProbabilityOutOfRangeThrows(@TempDir Path dir) {
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSkipListProbability(-0.1f).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSkipListProbability");
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSkipListProbability(1.1f).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSkipListProbability");
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSkipListProbability(Float.NaN).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSkipListProbability");
- assertThatThrownBy(() -> Config.builder(dir.toString()).unifiedMemtableSkipListProbability(Float.POSITIVE_INFINITY).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("unifiedMemtableSkipListProbability");
- }
+ byte[] returned = prepared.getXid();
+ returned[0] = 99;
+ assertArrayEquals(new byte[]{1, 2, 3}, prepared.getXid());
- @Test
- void configBuilder_validBoundaryValuesAccepted(@TempDir Path dir) {
- assertDoesNotThrow(() -> Config.builder(dir.toString())
- .numFlushThreads(1)
- .numCompactionThreads(1)
- .maxOpenSSTables(1)
- .blockCacheSize(0)
- .logTruncationAt(0)
- .maxMemoryUsage(0)
- .maxConcurrentFlushes(0)
- .unifiedMemtableSkipListProbability(0.0f)
- .build());
-
- assertDoesNotThrow(() -> Config.builder(dir.toString())
- .unifiedMemtableSkipListProbability(1.0f)
- .build());
- }
+ assertNull(prepared.getTransaction());
+ assertArrayEquals(new byte[0], new PreparedTransaction(null, null).getXid());
+ assertNotNull(prepared.toString());
+ }
- @Test
- void configBuilder_settersPreserveValues(@TempDir Path dir) {
- Config config = Config.builder(dir.toString())
- .numFlushThreads(8)
- .numCompactionThreads(4)
- .logLevel(LogLevel.ERROR)
- .blockCacheSize(1024)
- .maxOpenSSTables(128)
- .logToFile(true)
- .logTruncationAt(1234)
- .maxMemoryUsage(9999)
- .unifiedMemtable(true)
- .unifiedMemtableWriteBufferSize(512)
- .unifiedMemtableSkipListMaxLevel(6)
- .unifiedMemtableSkipListProbability(0.5f)
- .unifiedMemtableSyncMode(1)
- .unifiedMemtableSyncIntervalUs(500)
- .objectStoreFsPath("/some/path")
- .maxConcurrentFlushes(3)
- .finishCompactionsOnClose(true)
- .build();
-
- assertEquals(8, config.getNumFlushThreads());
- assertEquals(4, config.getNumCompactionThreads());
- assertEquals(LogLevel.ERROR, config.getLogLevel());
- assertEquals(1024, config.getBlockCacheSize());
- assertEquals(128, config.getMaxOpenSSTables());
- assertTrue(config.isLogToFile());
- assertEquals(1234, config.getLogTruncationAt());
- assertEquals(9999, config.getMaxMemoryUsage());
- assertTrue(config.isUnifiedMemtable());
- assertEquals(512, config.getUnifiedMemtableWriteBufferSize());
- assertEquals(6, config.getUnifiedMemtableSkipListMaxLevel());
- assertEquals(0.5f, config.getUnifiedMemtableSkipListProbability(), 1e-6);
- assertEquals(1, config.getUnifiedMemtableSyncMode());
- assertEquals(500, config.getUnifiedMemtableSyncIntervalUs());
- assertEquals("/some/path", config.getObjectStoreFsPath());
- assertEquals(3, config.getMaxConcurrentFlushes());
- assertTrue(config.isFinishCompactionsOnClose());
- }
+ @Test
+ void cfStatsCopiesItsLevelArrays() {
+ long[] sizes = new long[CfStats.MAX_LEVELS];
+ sizes[0] = 100;
+ CfStats stats = new CfStats(1, null, sizes, new int[CfStats.MAX_LEVELS],
+ new long[CfStats.MAX_LEVELS], new long[CfStats.MAX_LEVELS], 10, 100, 1.0, 2.0, 1.0,
+ 5, 2, 2.0, 0, 0.0, 0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
- // -----------------------------------------------------------------------
- // ColumnFamilyConfig.Builder validation
- // -----------------------------------------------------------------------
-
- @Test
- void columnFamilyConfigBuilder_settersPreserveValues() {
- ColumnFamilyConfig config = ColumnFamilyConfig.builder()
- .writeBufferSize(64 * 1024)
- .levelSizeRatio(5)
- .minLevels(3)
- .dividingLevelOffset(1)
- .klogValueThreshold(256)
- .compressionAlgorithm(CompressionAlgorithm.ZSTD_COMPRESSION)
- .enableBloomFilter(false)
- .bloomFPR(0.05)
- .enableBlockIndexes(false)
- .indexSampleRatio(4)
- .blockIndexPrefixLen(32)
- .syncMode(SyncMode.SYNC_NONE)
- .syncIntervalUs(5000)
- .comparatorName("my_cmp")
- .skipListMaxLevel(8)
- .skipListProbability(0.5f)
- .defaultIsolationLevel(IsolationLevel.SNAPSHOT)
- .minDiskSpace(1024)
- .l1FileCountTrigger(8)
- .l0QueueStallThreshold(10)
- .tombstoneDensityTrigger(0.3)
- .tombstoneDensityMinEntries(512)
- .useBtree(true)
- .objectLazyCompaction(true)
- .objectPrefetchCompaction(false)
- .build();
-
- assertEquals(64 * 1024, config.getWriteBufferSize());
- assertEquals(5, config.getLevelSizeRatio());
- assertEquals(3, config.getMinLevels());
- assertEquals(1, config.getDividingLevelOffset());
- assertEquals(256, config.getKlogValueThreshold());
- assertEquals(CompressionAlgorithm.ZSTD_COMPRESSION, config.getCompressionAlgorithm());
- assertFalse(config.isEnableBloomFilter());
- assertEquals(0.05, config.getBloomFPR(), 1e-9);
- assertFalse(config.isEnableBlockIndexes());
- assertEquals(4, config.getIndexSampleRatio());
- assertEquals(32, config.getBlockIndexPrefixLen());
- assertEquals(SyncMode.SYNC_NONE, config.getSyncMode());
- assertEquals(5000, config.getSyncIntervalUs());
- assertEquals("my_cmp", config.getComparatorName());
- assertEquals(8, config.getSkipListMaxLevel());
- assertEquals(0.5f, config.getSkipListProbability(), 1e-6);
- assertEquals(IsolationLevel.SNAPSHOT, config.getDefaultIsolationLevel());
- assertEquals(1024, config.getMinDiskSpace());
- assertEquals(8, config.getL1FileCountTrigger());
- assertEquals(10, config.getL0QueueStallThreshold());
- assertEquals(0.3, config.getTombstoneDensityTrigger(), 1e-9);
- assertEquals(512, config.getTombstoneDensityMinEntries());
- assertTrue(config.isUseBtree());
- assertTrue(config.isObjectLazyCompaction());
- assertFalse(config.isObjectPrefetchCompaction());
- }
+ sizes[0] = 999;
+ assertEquals(100, stats.getLevelSizes()[0], "the constructor took a copy");
- // -----------------------------------------------------------------------
- // ObjectStoreConfig.Builder
- // -----------------------------------------------------------------------
-
- @Test
- void objectStoreConfigBuilder_settersPreserveValues() {
- ObjectStoreConfig config = ObjectStoreConfig.builder()
- .localCachePath("/cache")
- .localCacheMaxBytes(1024)
- .cacheOnRead(false)
- .cacheOnWrite(false)
- .maxConcurrentUploads(2)
- .maxConcurrentDownloads(16)
- .multipartThreshold(1024 * 1024)
- .multipartPartSize(256 * 1024)
- .syncManifestToObject(false)
- .replicateWal(false)
- .walUploadSync(true)
- .walSyncThresholdBytes(2048)
- .walSyncOnCommit(true)
- .replicaMode(true)
- .replicaSyncIntervalUs(1000)
- .replicaReplayWal(false)
- .build();
-
- assertEquals("/cache", config.getLocalCachePath());
- assertEquals(1024, config.getLocalCacheMaxBytes());
- assertFalse(config.isCacheOnRead());
- assertFalse(config.isCacheOnWrite());
- assertEquals(2, config.getMaxConcurrentUploads());
- assertEquals(16, config.getMaxConcurrentDownloads());
- assertEquals(1024 * 1024, config.getMultipartThreshold());
- assertEquals(256 * 1024, config.getMultipartPartSize());
- assertFalse(config.isSyncManifestToObject());
- assertFalse(config.isReplicateWal());
- assertTrue(config.isWalUploadSync());
- assertEquals(2048, config.getWalSyncThresholdBytes());
- assertTrue(config.isWalSyncOnCommit());
- assertTrue(config.isReplicaMode());
- assertEquals(1000, config.getReplicaSyncIntervalUs());
- assertFalse(config.isReplicaReplayWal());
- }
+ long[] returned = stats.getLevelSizes();
+ returned[0] = 999;
+ assertEquals(100, stats.getLevelSizes()[0], "the getter returned a copy");
- @Test
- void objectStoreConfigBuilder_defaultValues() {
- ObjectStoreConfig config = ObjectStoreConfig.builder().build();
- assertNull(config.getLocalCachePath());
- assertEquals(0, config.getLocalCacheMaxBytes());
- assertTrue(config.isCacheOnRead());
- assertTrue(config.isCacheOnWrite());
- assertEquals(4, config.getMaxConcurrentUploads());
- assertEquals(8, config.getMaxConcurrentDownloads());
- assertEquals(64 * 1024 * 1024, config.getMultipartThreshold());
- assertEquals(8 * 1024 * 1024, config.getMultipartPartSize());
- assertTrue(config.isSyncManifestToObject());
- assertTrue(config.isReplicateWal());
- assertFalse(config.isWalUploadSync());
- assertEquals(1048576, config.getWalSyncThresholdBytes());
- assertFalse(config.isWalSyncOnCommit());
- assertFalse(config.isReplicaMode());
- assertEquals(5000000, config.getReplicaSyncIntervalUs());
- assertTrue(config.isReplicaReplayWal());
+ assertEquals(1, stats.getNumLevels());
+ assertEquals(10, stats.getTotalKeys());
+ assertNotNull(stats.toString());
+ }
}
- // -----------------------------------------------------------------------
- // S3Config.Builder
- // -----------------------------------------------------------------------
-
- @Test
- void s3ConfigBuilder_emptyRequiredFieldsThrow() {
- assertThatThrownBy(() -> S3Config.builder().endpoint("").bucket("b").accessKey("ak").secretKey("sk").build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("endpoint");
- assertThatThrownBy(() -> S3Config.builder().endpoint("e").bucket("").accessKey("ak").secretKey("sk").build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("bucket");
- assertThatThrownBy(() -> S3Config.builder().endpoint("e").bucket("b").accessKey("").secretKey("sk").build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("access key");
- assertThatThrownBy(() -> S3Config.builder().endpoint("e").bucket("b").accessKey("ak").secretKey("").build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("secret key");
- }
+ @Nested
+ class Exceptions {
- @Test
- void s3ConfigBuilder_defaultOptionalFields() {
- S3Config config = S3Config.builder()
- .endpoint("s3.example.com")
- .bucket("my-bucket")
- .accessKey("AK")
- .secretKey("SK")
- .build();
-
- assertNull(config.getPrefix());
- assertNull(config.getRegion());
- assertTrue(config.isUseSsl());
- assertFalse(config.isUsePathStyle());
- assertNull(config.getTlsCaPath());
- assertFalse(config.isTlsInsecureSkipVerify());
- assertEquals(0, config.getMultipartThreshold());
- assertEquals(0, config.getMultipartPartSize());
- }
+ @Test
+ void carriesACodeAndADescription() {
+ TidesDBException e = new TidesDBException("boom", TidesDBException.ERR_IO);
+ assertEquals(TidesDBException.ERR_IO, e.getErrorCode());
+ assertEquals("boom", e.getMessage());
+ assertEquals("I/O error", e.getErrorMessage());
+ }
- @Test
- void s3ConfigBuilder_allFieldsSet() {
- S3Config config = S3Config.builder()
- .endpoint("minio.local:9000")
- .bucket("test-bucket")
- .prefix("prefix/")
- .accessKey("access")
- .secretKey("secret")
- .region("eu-west-1")
- .useSsl(false)
- .usePathStyle(true)
- .tlsCaPath("/etc/ssl/ca.pem")
- .tlsInsecureSkipVerify(true)
- .multipartThreshold(5 * 1024 * 1024)
- .multipartPartSize(1024 * 1024)
- .build();
-
- assertEquals("minio.local:9000", config.getEndpoint());
- assertEquals("test-bucket", config.getBucket());
- assertEquals("prefix/", config.getPrefix());
- assertEquals("access", config.getAccessKey());
- assertEquals("secret", config.getSecretKey());
- assertEquals("eu-west-1", config.getRegion());
- assertFalse(config.isUseSsl());
- assertTrue(config.isUsePathStyle());
- assertEquals("/etc/ssl/ca.pem", config.getTlsCaPath());
- assertTrue(config.isTlsInsecureSkipVerify());
- assertEquals(5 * 1024 * 1024, config.getMultipartThreshold());
- assertEquals(1024 * 1024, config.getMultipartPartSize());
- }
+ @Test
+ void defaultsToUnknown() {
+ assertEquals(TidesDBException.ERR_UNKNOWN,
+ new TidesDBException("boom").getErrorCode());
+ assertEquals(TidesDBException.ERR_UNKNOWN,
+ new TidesDBException("boom", new RuntimeException()).getErrorCode());
+ }
- // -----------------------------------------------------------------------
- // Stats
- // -----------------------------------------------------------------------
-
- @Test
- void stats_constructorAndGetters() {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- Stats stats = new Stats(
- 3, 1024,
- new long[]{100, 200, 300}, new int[]{1, 2, 3},
- cfConfig,
- 500, 600,
- 10.5, 20.5, new long[]{100, 200, 200},
- 1.5, 0.95,
- false, 0, 0, 0.0,
- 10, 0.02, new long[]{5, 3, 2},
- 0.15, 2,
- 1000, 2000, 3000, 4000, 5000, 5, 10);
-
- assertEquals(3, stats.getNumLevels());
- assertEquals(1024, stats.getMemtableSize());
- assertArrayEquals(new long[]{100, 200, 300}, stats.getLevelSizes());
- assertArrayEquals(new int[]{1, 2, 3}, stats.getLevelNumSSTables());
- assertNotNull(stats.getConfig());
- assertEquals(500, stats.getTotalKeys());
- assertEquals(600, stats.getTotalDataSize());
- assertEquals(10.5, stats.getAvgKeySize(), 1e-9);
- assertEquals(20.5, stats.getAvgValueSize(), 1e-9);
- assertArrayEquals(new long[]{100, 200, 200}, stats.getLevelKeyCounts());
- assertEquals(1.5, stats.getReadAmp(), 1e-9);
- assertEquals(0.95, stats.getHitRate(), 1e-9);
- assertFalse(stats.isUseBtree());
- assertEquals(0, stats.getBtreeTotalNodes());
- assertEquals(0, stats.getBtreeMaxHeight());
- assertEquals(0.0, stats.getBtreeAvgHeight(), 1e-9);
- assertEquals(10, stats.getTotalTombstones());
- assertEquals(0.02, stats.getTombstoneRatio(), 1e-9);
- assertArrayEquals(new long[]{5, 3, 2}, stats.getLevelTombstoneCounts());
- assertEquals(0.15, stats.getMaxSstDensity(), 1e-9);
- assertEquals(2, stats.getMaxSstDensityLevel());
- assertEquals(1000, stats.getWalBytesWritten());
- assertEquals(2000, stats.getFlushBytesWritten());
- assertEquals(3000, stats.getCompactionBytesWritten());
- assertEquals(4000, stats.getCompactionBytesRead());
- assertEquals(5000, stats.getUserBytesWritten());
- assertEquals(5, stats.getFlushCount());
- assertEquals(10, stats.getCompactionCount());
- }
+ @Test
+ void keepsItsCause() {
+ RuntimeException cause = new RuntimeException("root");
+ TidesDBException e = new TidesDBException("boom", TidesDBException.ERR_IO, cause);
+ assertSame(cause, e.getCause());
+ assertEquals(TidesDBException.ERR_IO, e.getErrorCode());
+ }
- @Test
- void stats_toString_containsAllFields() {
- Stats stats = new Stats(
- 2, 512,
- new long[]{100, 200}, new int[]{1, 2},
- ColumnFamilyConfig.defaultConfig(),
- 300, 400,
- 8.0, 16.0, new long[]{150, 150},
- 1.0, 0.9,
- false, 0, 0, 0.0,
- 5, 0.01, new long[]{3, 2},
- 0.1, 1,
- 500, 1000, 1500, 2000, 2500, 3, 7);
-
- String str = stats.toString();
- assertThat(str).contains("numLevels=2");
- assertThat(str).contains("memtableSize=512");
- assertThat(str).contains("totalKeys=300");
- assertThat(str).contains("totalDataSize=400");
- assertThat(str).contains("readAmp=");
- assertThat(str).contains("hitRate=");
- assertThat(str).contains("useBtree=false");
- assertThat(str).contains("totalTombstones=5");
- assertThat(str).contains("tombstoneRatio=");
- assertThat(str).contains("levelSizes=[");
- assertThat(str).contains("levelNumSSTables=[");
- assertThat(str).contains("levelKeyCounts=[");
- assertThat(str).contains("levelTombstoneCounts=[");
- }
+ @Test
+ void describesEveryCode() {
+ int[] codes = {
+ TidesDBException.ERR_SUCCESS, TidesDBException.ERR_MEMORY,
+ TidesDBException.ERR_INVALID_ARGS, TidesDBException.ERR_NOT_FOUND,
+ TidesDBException.ERR_IO, TidesDBException.ERR_CORRUPTION,
+ TidesDBException.ERR_EXISTS, TidesDBException.ERR_CONFLICT,
+ TidesDBException.ERR_TOO_LARGE, TidesDBException.ERR_MEMORY_LIMIT,
+ TidesDBException.ERR_INVALID_DB, TidesDBException.ERR_UNKNOWN,
+ TidesDBException.ERR_LOCKED, TidesDBException.ERR_READONLY,
+ TidesDBException.ERR_TXN_EXPIRED, TidesDBException.ERR_NO_SPACE,
+ TidesDBException.ERR_TXN_ABORTED, TidesDBException.ERR_TOO_OLD};
+
+ for (int code : codes) {
+ String message = new TidesDBException("x", code).getErrorMessage();
+ assertNotNull(message);
+ assertFalse(message.isEmpty());
+ }
+ assertEquals("unknown error", new TidesDBException("x", -999).getErrorMessage());
+ }
- @Test
- void stats_toString_nullArraysOmitted() {
- Stats stats = new Stats(
- 0, 0, null, null, ColumnFamilyConfig.defaultConfig(),
- 0, 0, 0, 0, null, 0, 0,
- false, 0, 0, 0.0,
- 0, 0, null, 0, 0,
- 0, 0, 0, 0, 0, 0, 0);
-
- String str = stats.toString();
- assertThat(str).doesNotContain("levelSizes=");
- assertThat(str).doesNotContain("levelNumSSTables=");
- assertThat(str).doesNotContain("levelKeyCounts=");
- assertThat(str).doesNotContain("levelTombstoneCounts=");
+ @Test
+ void identifiesTheRetryableCode() {
+ assertTrue(new TidesDBException("x", TidesDBException.ERR_LOCKED).isRetryable());
+ assertFalse(new TidesDBException("x", TidesDBException.ERR_CONFLICT).isRetryable());
+ assertFalse(new TidesDBException("x", TidesDBException.ERR_IO).isRetryable());
+ }
+
+ @Test
+ void errorCodesMatchTheNativeNumbering() {
+ assertEquals(0, TidesDBException.ERR_SUCCESS);
+ assertEquals(-1, TidesDBException.ERR_MEMORY);
+ assertEquals(-2, TidesDBException.ERR_INVALID_ARGS);
+ assertEquals(-3, TidesDBException.ERR_NOT_FOUND);
+ assertEquals(-4, TidesDBException.ERR_IO);
+ assertEquals(-5, TidesDBException.ERR_CORRUPTION);
+ assertEquals(-6, TidesDBException.ERR_EXISTS);
+ assertEquals(-7, TidesDBException.ERR_CONFLICT);
+ assertEquals(-8, TidesDBException.ERR_TOO_LARGE);
+ assertEquals(-9, TidesDBException.ERR_MEMORY_LIMIT);
+ assertEquals(-10, TidesDBException.ERR_INVALID_DB);
+ assertEquals(-11, TidesDBException.ERR_UNKNOWN);
+ assertEquals(-12, TidesDBException.ERR_LOCKED);
+ assertEquals(-13, TidesDBException.ERR_READONLY);
+ assertEquals(-14, TidesDBException.ERR_TXN_EXPIRED);
+ assertEquals(-15, TidesDBException.ERR_NO_SPACE);
+ assertEquals(-16, TidesDBException.ERR_TXN_ABORTED);
+ assertEquals(-17, TidesDBException.ERR_TOO_OLD);
+ }
}
- @Test
- void stats_toString_btreeFieldsIncludedWhenEnabled() {
- Stats stats = new Stats(
- 1, 0,
- new long[0], new int[0],
- ColumnFamilyConfig.defaultConfig(),
- 0, 0,
- 0, 0, new long[0],
- 0, 0,
- true, 50, 5, 3.5,
- 0, 0, new long[0],
- 0, 0,
- 0, 0, 0, 0, 0, 0, 0);
-
- String str = stats.toString();
- assertThat(str).contains("useBtree=true");
- assertThat(str).contains("btreeTotalNodes=50");
- assertThat(str).contains("btreeMaxHeight=5");
- assertThat(str).contains("btreeAvgHeight=3.5");
+ @Nested
+ class NativeStatics {
+
+ @Test
+ void reportsWhichCompressionBackendsAreLinkedIn() {
+ assertTrue(TidesDB.isCompressionAvailable(CompressionAlgorithm.NONE),
+ "no-compression is always available");
+ for (CompressionAlgorithm algo : CompressionAlgorithm.values()) {
+ assertDoesNotThrow(() -> TidesDB.isCompressionAvailable(algo));
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> TidesDB.isCompressionAvailable(null));
+ }
+
+ @Test
+ void describesResultCodes() {
+ assertNotNull(TidesDB.strerror(TidesDBException.ERR_SUCCESS));
+ assertNotNull(TidesDB.strerror(TidesDBException.ERR_NOT_FOUND));
+ assertNotNull(TidesDB.strerror(-999), "an unrecognised code still describes itself");
+ }
+
+ @Test
+ void reportsTheOpenFileCeiling() {
+ assertTrue(TidesDB.raiseOpenFileLimit(0) > 0);
+ }
+
+ @Test
+ void loadsTheNativeLibrary() {
+ NativeLibrary.load();
+ assertTrue(NativeLibrary.isLoaded());
+ }
}
}
diff --git a/src/test/java/com/tidesdb/TidesDBTest.java b/src/test/java/com/tidesdb/TidesDBTest.java
index f8bb5df..ac91371 100644
--- a/src/test/java/com/tidesdb/TidesDBTest.java
+++ b/src/test/java/com/tidesdb/TidesDBTest.java
@@ -18,3333 +18,1656 @@
*/
package com.tidesdb;
-import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.time.Instant;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
-import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
/**
- * Tests for TidesDB Java bindings.
+ * Behavioural tests for the TidesDB Java binding against a live engine.
*/
-@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class TidesDBTest {
-
+
@TempDir
Path tempDir;
-
- @Test
- @Order(1)
- void testOpenClose() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- assertNotNull(db);
+
+ private static byte[] b(String s) {
+ return s.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static String s(byte[] v) {
+ return v == null ? null : new String(v, StandardCharsets.UTF_8);
+ }
+
+ /** Opens a database under a fresh subdirectory, with logging off. */
+ private TidesDB open(String name) throws TidesDBException {
+ return TidesDB.open(Config.builder(tempDir.resolve(name).toString())
+ .logLevel(LogLevel.NONE)
+ .build());
+ }
+
+ /** Opens a database with one column family already created, and returns both. */
+ private TidesDB openWithCf(String name, String cfName) throws TidesDBException {
+ TidesDB db = open(name);
+ db.createColumnFamily(cfName, ColumnFamilyConfig.builder().build());
+ return db;
+ }
+
+ /** Writes one committed key/value pair. */
+ private static void write(TidesDB db, ColumnFamily cf, String key, String value)
+ throws TidesDBException {
+ try (Transaction txn = db.beginTransaction()) {
+ txn.put(cf, b(key), b(value));
+ txn.commit();
}
}
-
- @Test
- @Order(2)
- void testCreateDropColumnFamily() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb2").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
-
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
- assertNotNull(cf);
- assertEquals("test_cf", cf.getName());
-
- String[] families = db.listColumnFamilies();
- assertTrue(families.length > 0);
-
- db.dropColumnFamily("test_cf");
+
+ /** Reads one key back in its own transaction. */
+ private static String read(TidesDB db, ColumnFamily cf, String key) throws TidesDBException {
+ try (Transaction txn = db.beginTransaction()) {
+ String value = s(txn.get(cf, b(key)));
+ txn.rollback();
+ return value;
}
}
-
- @Test
- @Order(3)
- void testTransactionPutGetDelete() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb3").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- byte[] key = "key".getBytes(StandardCharsets.UTF_8);
- byte[] value = "value".getBytes(StandardCharsets.UTF_8);
-
- try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, key, value);
- txn.commit();
+
+ @Nested
+ class Lifecycle {
+
+ @Test
+ void opensAndCloses() throws TidesDBException {
+ try (TidesDB db = open("open-close")) {
+ assertNotNull(db);
}
-
- try (Transaction txn = db.beginTransaction()) {
- byte[] result = txn.get(cf, key);
- assertNotNull(result);
- assertArrayEquals(value, result);
+ }
+
+ @Test
+ void closeIsIdempotent() throws TidesDBException {
+ TidesDB db = open("double-close");
+ db.close();
+ assertDoesNotThrow(db::close);
+ }
+
+ @Test
+ void operationsOnAClosedDatabaseThrow() throws TidesDBException {
+ TidesDB db = open("closed-guard");
+ db.close();
+ assertThrows(IllegalStateException.class, db::listColumnFamilies);
+ assertThrows(IllegalStateException.class, db::beginTransaction);
+ assertThrows(IllegalStateException.class, db::getDbStats);
+ }
+
+ @Test
+ void rejectsAMissingPath() {
+ assertThrows(IllegalArgumentException.class, () -> TidesDB.open(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> TidesDB.open(Config.builder("").build()));
+ }
+
+ @Test
+ void refusesASecondHandleOnTheSameDirectory() throws TidesDBException {
+ try (TidesDB first = open("locked")) {
+ TidesDBException e = assertThrows(TidesDBException.class, () -> open("locked"));
+ assertEquals(TidesDBException.ERR_LOCKED, e.getErrorCode());
+ assertTrue(e.isRetryable());
}
-
- try (Transaction txn = db.beginTransaction()) {
- txn.delete(cf, key);
- txn.commit();
+ }
+
+ @Test
+ void dataSurvivesAReopen() throws TidesDBException {
+ Config config = Config.builder(tempDir.resolve("reopen").toString())
+ .logLevel(LogLevel.NONE)
+ .build();
+
+ try (TidesDB db = TidesDB.open(config)) {
+ db.createColumnFamily("cf", ColumnFamilyConfig.builder().build());
+ write(db, db.getColumnFamily("cf"), "durable", "value");
}
-
- try (Transaction txn = db.beginTransaction()) {
- assertThrows(TidesDBException.class, () -> txn.get(cf, key));
+ try (TidesDB db = TidesDB.open(config)) {
+ assertEquals("value", read(db, db.getColumnFamily("cf"), "durable"));
}
}
}
-
- @Test
- @Order(4)
- void testTransactionWithTTL() throws TidesDBException, InterruptedException {
- Config config = Config.builder(tempDir.resolve("testdb4").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- byte[] key = "temp_key".getBytes(StandardCharsets.UTF_8);
- byte[] value = "temp_value".getBytes(StandardCharsets.UTF_8);
-
- // Set TTL to 2 seconds from now
- long ttl = Instant.now().getEpochSecond() + 2;
-
- try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, key, value, ttl);
- txn.commit();
+
+ @Nested
+ class ColumnFamilies {
+
+ @Test
+ void createsListsAndDrops() throws TidesDBException {
+ try (TidesDB db = open("cf-crud")) {
+ db.createColumnFamily("alpha", ColumnFamilyConfig.builder().build());
+ db.createColumnFamily("beta", ColumnFamilyConfig.builder().build());
+
+ assertEquals(2, db.listColumnFamilies().length);
+ assertTrue(Arrays.asList(db.listColumnFamilies()).contains("alpha"));
+
+ db.dropColumnFamily("alpha");
+ assertFalse(Arrays.asList(db.listColumnFamilies()).contains("alpha"));
}
-
- // Verify key exists before expiration
- try (Transaction txn = db.beginTransaction()) {
- byte[] result = txn.get(cf, key);
- assertNotNull(result);
- assertArrayEquals(value, result);
- }
-
- Thread.sleep(3000);
-
- // Verify key is expired
- try (Transaction txn = db.beginTransaction()) {
- assertThrows(TidesDBException.class, () -> txn.get(cf, key));
+ }
+
+ @Test
+ void listsNothingOnAFreshDatabase() throws TidesDBException {
+ try (TidesDB db = open("cf-empty")) {
+ assertEquals(0, db.listColumnFamilies().length);
}
}
- }
-
- @Test
- @Order(5)
- void testMultiOperationTransaction() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb5").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Multiple operations in one transaction
- try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, "key1".getBytes(), "value1".getBytes());
- txn.put(cf, "key2".getBytes(), "value2".getBytes());
- txn.put(cf, "key3".getBytes(), "value3".getBytes());
- txn.commit();
+
+ @Test
+ void rejectsADuplicateName() throws TidesDBException {
+ try (TidesDB db = openWithCf("cf-dup", "cf")) {
+ TidesDBException e = assertThrows(TidesDBException.class,
+ () -> db.createColumnFamily("cf", ColumnFamilyConfig.builder().build()));
+ assertEquals(TidesDBException.ERR_EXISTS, e.getErrorCode());
}
-
- // Verify all keys exist
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 1; i <= 3; i++) {
- byte[] key = ("key" + i).getBytes();
- byte[] expectedValue = ("value" + i).getBytes();
- byte[] result = txn.get(cf, key);
- assertArrayEquals(expectedValue, result);
- }
+ }
+
+ @Test
+ void reportsAMissingFamilyAsNotFound() throws TidesDBException {
+ try (TidesDB db = open("cf-missing")) {
+ TidesDBException e =
+ assertThrows(TidesDBException.class, () -> db.getColumnFamily("absent"));
+ assertEquals(TidesDBException.ERR_NOT_FOUND, e.getErrorCode());
}
}
- }
-
- @Test
- @Order(6)
- void testTransactionRollback() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb6").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- byte[] key = "rollback_key".getBytes();
- byte[] value = "rollback_value".getBytes();
-
- try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, key, value);
- txn.rollback();
+
+ @Test
+ void renamesInPlace() throws TidesDBException {
+ try (TidesDB db = openWithCf("cf-rename", "before")) {
+ write(db, db.getColumnFamily("before"), "k", "v");
+ db.renameColumnFamily("before", "after");
+
+ assertTrue(Arrays.asList(db.listColumnFamilies()).contains("after"));
+ assertFalse(Arrays.asList(db.listColumnFamilies()).contains("before"));
+ assertEquals("v", read(db, db.getColumnFamily("after"), "k"));
}
-
- // Verify key does not exist
- try (Transaction txn = db.beginTransaction()) {
- assertThrows(TidesDBException.class, () -> txn.get(cf, key));
+ }
+
+ @Test
+ void clonesAtAPointInTime() throws TidesDBException {
+ try (TidesDB db = openWithCf("cf-clone", "src")) {
+ ColumnFamily src = db.getColumnFamily("src");
+ write(db, src, "before-clone", "yes");
+
+ db.cloneColumnFamily("src", "dst");
+ write(db, src, "after-clone", "yes");
+
+ ColumnFamily dst = db.getColumnFamily("dst");
+ assertEquals("yes", read(db, dst, "before-clone"));
+ assertNull(read(db, dst, "after-clone"), "a clone is a point-in-time copy");
}
}
- }
-
- @Test
- @Order(7)
- void testSavepoints() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb7").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, "key1".getBytes(), "value1".getBytes());
-
- txn.savepoint("sp1");
- txn.put(cf, "key2".getBytes(), "value2".getBytes());
-
- // Rollback to savepoint -- key2 is discarded, key1 remains
- txn.rollbackToSavepoint("sp1");
-
- // Add different operation after rollback
- txn.put(cf, "key3".getBytes(), "value3".getBytes());
-
- txn.commit();
+
+ @Test
+ void rejectsEmptyNames() throws TidesDBException {
+ try (TidesDB db = open("cf-names")) {
+ ColumnFamilyConfig cfg = ColumnFamilyConfig.builder().build();
+ assertThrows(IllegalArgumentException.class, () -> db.createColumnFamily(null, cfg));
+ assertThrows(IllegalArgumentException.class, () -> db.createColumnFamily("", cfg));
+ assertThrows(IllegalArgumentException.class,
+ () -> db.createColumnFamily("cf", null));
+ assertThrows(IllegalArgumentException.class, () -> db.dropColumnFamily(""));
+ assertThrows(IllegalArgumentException.class, () -> db.getColumnFamily(""));
}
-
- try (Transaction txn = db.beginTransaction()) {
- // key1 should exist
- assertNotNull(txn.get(cf, "key1".getBytes()));
-
- // key2 should not exist (rolled back)
- assertThrows(TidesDBException.class, () -> txn.get(cf, "key2".getBytes()));
-
- // key3 should exist
- assertNotNull(txn.get(cf, "key3".getBytes()));
+ }
+
+ @Test
+ void keepsFamiliesIsolated() throws TidesDBException {
+ try (TidesDB db = openWithCf("cf-isolated", "one")) {
+ db.createColumnFamily("two", ColumnFamilyConfig.builder().build());
+ ColumnFamily one = db.getColumnFamily("one");
+ ColumnFamily two = db.getColumnFamily("two");
+
+ write(db, one, "shared-key", "from-one");
+ write(db, two, "shared-key", "from-two");
+
+ assertEquals("from-one", read(db, one, "shared-key"));
+ assertEquals("from-two", read(db, two, "shared-key"));
}
}
}
-
- @Test
- @Order(8)
- void testIterator() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb8").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- String key = String.format("key%02d", i);
- String value = "value" + i;
- txn.put(cf, key.getBytes(), value.getBytes());
- }
- txn.commit();
- }
-
- try (Transaction txn = db.beginTransaction()) {
- try (TidesDBIterator iter = txn.newIterator(cf)) {
- iter.seekToFirst();
-
- int count = 0;
- while (iter.isValid()) {
- byte[] key = iter.key();
- byte[] value = iter.value();
- assertNotNull(key);
- assertNotNull(value);
- count++;
- iter.next();
- }
- assertEquals(10, count);
- }
- }
-
- try (Transaction txn = db.beginTransaction()) {
- try (TidesDBIterator iter = txn.newIterator(cf)) {
- iter.seekToLast();
-
- int count = 0;
- while (iter.isValid()) {
- byte[] key = iter.key();
- byte[] value = iter.value();
- assertNotNull(key);
- assertNotNull(value);
- count++;
- iter.prev();
- }
- assertEquals(10, count);
- }
+
+ @Nested
+ class ReadsAndWrites {
+
+ @Test
+ void putsAndGets() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-basic", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "key", "value");
+ assertEquals("value", read(db, cf, "key"));
}
}
- }
-
- @Test
- @Order(9)
- void testIsolationLevels() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb9").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- for (IsolationLevel level : IsolationLevel.values()) {
- try (Transaction txn = db.beginTransaction(level)) {
- assertNotNull(txn);
- }
+
+ @Test
+ void reportsAnAbsentKeyAsNull() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-absent", "cf")) {
+ assertNull(read(db, db.getColumnFamily("cf"), "never-written"));
}
}
- }
-
- @Test
- @Order(10)
- void testColumnFamilyStats() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb10").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 100; i++) {
- txn.put(cf, ("key" + i).getBytes(), ("value" + i).getBytes());
+
+ @Test
+ void distinguishesAnEmptyValueFromAnAbsence() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-empty", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ try (Transaction txn = db.beginTransaction()) {
+ txn.put(cf, b("present-but-empty"), new byte[0]);
+ txn.commit();
+ }
+ try (Transaction txn = db.beginTransaction()) {
+ byte[] value = txn.get(cf, b("present-but-empty"));
+ assertNotNull(value, "an empty value is a present key, not an absence");
+ assertEquals(0, value.length);
+ assertTrue(txn.contains(cf, b("present-but-empty")));
+ txn.rollback();
}
- txn.commit();
}
-
- Stats stats = cf.getStats();
- assertNotNull(stats);
- assertTrue(stats.getNumLevels() >= 0);
- assertTrue(stats.getTotalKeys() >= 0);
- assertTrue(stats.getTotalDataSize() >= 0);
- assertTrue(stats.getAvgKeySize() >= 0);
- assertTrue(stats.getAvgValueSize() >= 0);
- assertTrue(stats.getReadAmp() >= 0);
- assertTrue(stats.getHitRate() >= 0.0 && stats.getHitRate() <= 1.0);
- assertFalse(stats.isUseBtree());
- }
- }
-
- @Test
- @Order(11)
- void testCacheStats() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb11").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- CacheStats stats = db.getCacheStats();
- assertNotNull(stats);
}
- }
-
- @Test
- @Order(12)
- void testCustomColumnFamilyConfig() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb12").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.builder()
- .writeBufferSize(128 * 1024 * 1024)
- .levelSizeRatio(10)
- .minLevels(5)
- .compressionAlgorithm(CompressionAlgorithm.LZ4_COMPRESSION)
- .enableBloomFilter(true)
- .bloomFPR(0.01)
- .enableBlockIndexes(true)
- .syncMode(SyncMode.SYNC_INTERVAL)
- .syncIntervalUs(128000)
- .defaultIsolationLevel(IsolationLevel.READ_COMMITTED)
- .build();
-
- db.createColumnFamily("custom_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("custom_cf");
- assertNotNull(cf);
- assertEquals("custom_cf", cf.getName());
+
+ @Test
+ void overwritesInPlace() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-overwrite", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "key", "first");
+ write(db, cf, "key", "second");
+ assertEquals("second", read(db, cf, "key"));
+ }
}
- }
- @Test
- @Order(13)
- void testBtreeColumnFamily() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb13").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.builder()
- .writeBufferSize(128 * 1024 * 1024)
- .levelSizeRatio(10)
- .minLevels(5)
- .compressionAlgorithm(CompressionAlgorithm.LZ4_COMPRESSION)
- .enableBloomFilter(true)
- .bloomFPR(0.01)
- .enableBlockIndexes(true)
- .syncMode(SyncMode.SYNC_FULL)
- .useBtree(true)
- .build();
-
- db.createColumnFamily("btree_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("btree_cf");
- assertNotNull(cf);
- assertEquals("btree_cf", cf.getName());
-
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 100; i++) {
- txn.put(cf, ("key" + i).getBytes(), ("value" + i).getBytes());
+ @Test
+ void roundTripsBinaryValues() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-binary", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ byte[] key = {0x00, 0x01, (byte) 0xFF, 0x7F};
+ byte[] value = new byte[512];
+ for (int i = 0; i < value.length; i++) {
+ value[i] = (byte) i;
+ }
+
+ try (Transaction txn = db.beginTransaction()) {
+ txn.put(cf, key, value);
+ txn.commit();
+ }
+ try (Transaction txn = db.beginTransaction()) {
+ assertArrayEquals(value, txn.get(cf, key));
+ txn.rollback();
}
- txn.commit();
}
-
- try (Transaction txn = db.beginTransaction()) {
- byte[] result = txn.get(cf, "key50".getBytes());
- assertNotNull(result);
- assertArrayEquals("value50".getBytes(), result);
- }
-
- Stats stats = cf.getStats();
- assertNotNull(stats);
- assertTrue(stats.isUseBtree());
- assertTrue(stats.getBtreeTotalNodes() >= 0);
- assertTrue(stats.getBtreeMaxHeight() >= 0);
- assertTrue(stats.getBtreeAvgHeight() >= 0.0);
}
- }
-
- @Test
- @Order(14)
- void testBtreeIterator() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb14").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.builder()
- .writeBufferSize(128 * 1024 * 1024)
- .compressionAlgorithm(CompressionAlgorithm.LZ4_COMPRESSION)
- .enableBloomFilter(true)
- .useBtree(true)
- .build();
-
- db.createColumnFamily("btree_iter_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("btree_iter_cf");
-
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- String key = String.format("key%02d", i);
- String value = "value" + i;
- txn.put(cf, key.getBytes(), value.getBytes());
+
+ @Test
+ void roundTripsALargeValueThroughTheValueLog() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-large", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ byte[] value = new byte[256 * 1024];
+ Arrays.fill(value, (byte) 'x');
+
+ try (Transaction txn = db.beginTransaction()) {
+ txn.put(cf, b("big"), value);
+ txn.commit();
}
- txn.commit();
- }
-
- try (Transaction txn = db.beginTransaction()) {
- try (TidesDBIterator iter = txn.newIterator(cf)) {
- iter.seekToFirst();
-
- int count = 0;
- while (iter.isValid()) {
- byte[] key = iter.key();
- byte[] value = iter.value();
- assertNotNull(key);
- assertNotNull(value);
- count++;
- iter.next();
- }
- assertEquals(10, count);
+ db.flushMemtable();
+ try (Transaction txn = db.beginTransaction()) {
+ assertArrayEquals(value, txn.get(cf, b("big")));
+ txn.rollback();
}
}
}
- }
-
- @Test
- @Order(15)
- void testCloneColumnFamily() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb15").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("source_cf", cfConfig);
-
- ColumnFamily sourceCf = db.getColumnFamily("source_cf");
-
- // Insert data into source
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- txn.put(sourceCf, ("key" + i).getBytes(), ("value" + i).getBytes());
+
+ @Test
+ void readsNothingFromARolledBackTransaction() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-rollback", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ try (Transaction txn = db.beginTransaction()) {
+ txn.put(cf, b("discarded"), b("v"));
+ txn.rollback();
}
- txn.commit();
+ assertNull(read(db, cf, "discarded"));
}
-
- // Clone the column family
- db.cloneColumnFamily("source_cf", "cloned_cf");
-
- // Verify clone exists
- ColumnFamily clonedCf = db.getColumnFamily("cloned_cf");
- assertNotNull(clonedCf);
- assertEquals("cloned_cf", clonedCf.getName());
-
- // Verify both column families are listed
- String[] families = db.listColumnFamilies();
- assertTrue(families.length >= 2);
-
- // Verify data exists in clone
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- byte[] result = txn.get(clonedCf, ("key" + i).getBytes());
- assertNotNull(result);
- assertArrayEquals(("value" + i).getBytes(), result);
+ }
+
+ @Test
+ void doesNotTrackANoTrackRead() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-notrack", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "probe", "value");
+
+ try (Transaction txn = db.beginTransaction()) {
+ assertEquals("value", s(txn.getNoTrack(cf, b("probe"))));
+ assertNull(txn.getNoTrack(cf, b("absent")));
+ txn.rollback();
}
}
-
- // Verify independence: insert into clone, should not appear in source
- try (Transaction txn = db.beginTransaction()) {
- txn.put(clonedCf, "clone_only_key".getBytes(), "clone_only_value".getBytes());
- txn.commit();
- }
-
- try (Transaction txn = db.beginTransaction()) {
- assertThrows(TidesDBException.class, () -> txn.get(sourceCf, "clone_only_key".getBytes()));
- }
}
- }
-
- @Test
- @Order(16)
- void testCheckpoint() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb16").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Insert some data
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- txn.put(cf, ("key" + i).getBytes(), ("value" + i).getBytes());
+
+ @Test
+ void reportsExistenceWithoutReadingTheValue() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-contains", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "here", "v");
+
+ try (Transaction txn = db.beginTransaction()) {
+ assertTrue(txn.contains(cf, b("here")));
+ assertFalse(txn.contains(cf, b("not-here")));
+ txn.rollback();
}
- txn.commit();
}
-
- // Create checkpoint
- String checkpointDir = tempDir.resolve("testdb16_checkpoint").toString();
- db.checkpoint(checkpointDir);
-
- // Open the checkpoint as a separate database and verify data
- Config checkpointConfig = Config.builder(checkpointDir)
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB checkpointDb = TidesDB.open(checkpointConfig)) {
- ColumnFamily checkpointCf = checkpointDb.getColumnFamily("test_cf");
- assertNotNull(checkpointCf);
-
- try (Transaction txn = checkpointDb.beginTransaction()) {
- for (int i = 0; i < 10; i++) {
- byte[] result = txn.get(checkpointCf, ("key" + i).getBytes());
- assertNotNull(result);
- assertArrayEquals(("value" + i).getBytes(), result);
- }
+ }
+
+ @Test
+ void rejectsBadArguments() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-args", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ try (Transaction txn = db.beginTransaction()) {
+ assertThrows(IllegalArgumentException.class, () -> txn.put(null, b("k"), b("v")));
+ assertThrows(IllegalArgumentException.class, () -> txn.put(cf, null, b("v")));
+ assertThrows(IllegalArgumentException.class,
+ () -> txn.put(cf, new byte[0], b("v")));
+ assertThrows(IllegalArgumentException.class, () -> txn.put(cf, b("k"), null));
+ assertThrows(IllegalArgumentException.class, () -> txn.get(cf, null));
+ assertThrows(IllegalArgumentException.class, () -> txn.delete(cf, new byte[0]));
+ txn.rollback();
}
}
}
- }
-
- @Test
- @Order(17)
- void testCheckpointNullDir() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb16b").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- assertThrows(IllegalArgumentException.class, () -> db.checkpoint(null));
- assertThrows(IllegalArgumentException.class, () -> db.checkpoint(""));
+
+ @Test
+ void refusesOperationsOnAFreedTransaction() throws TidesDBException {
+ try (TidesDB db = openWithCf("rw-freed", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ Transaction txn = db.beginTransaction();
+ txn.free();
+ txn.free();
+
+ assertThrows(IllegalStateException.class, () -> txn.put(cf, b("k"), b("v")));
+ assertThrows(IllegalStateException.class, () -> txn.get(cf, b("k")));
+ assertThrows(IllegalStateException.class, txn::commit);
+ assertDoesNotThrow(txn::requestAbort);
+ }
}
}
-
- @Test
- @Order(18)
- void testTransactionPutGetDeleteBadKey() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb3").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
+ @Nested
+ class Deletes {
- ColumnFamily cf = db.getColumnFamily("test_cf");
+ @Test
+ void deletesAKey() throws TidesDBException {
+ try (TidesDB db = openWithCf("del-basic", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "doomed", "v");
- byte[] key = new byte[0]; // Bad key (empty)
- byte[] value = "value".getBytes(StandardCharsets.UTF_8);
-
- assertThrows(IllegalArgumentException.class, () -> {
try (Transaction txn = db.beginTransaction()) {
- txn.put(cf, key, value);
+ txn.delete(cf, b("doomed"));
+ txn.commit();
}
- });
+ assertNull(read(db, cf, "doomed"));
+ }
+ }
- assertThrows(IllegalArgumentException.class, () -> {
- try (Transaction txn = db.beginTransaction()) {
- byte[] result = txn.get(cf, key);
- assertNotNull(result);
- assertArrayEquals(value, result);
- }
- });
+ @Test
+ void singleDeletesAKeyWrittenOnce() throws TidesDBException {
+ try (TidesDB db = openWithCf("del-single", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ write(db, cf, "once", "v");
- assertThrows(IllegalArgumentException.class, () -> {
try (Transaction txn = db.beginTransaction()) {
- txn.delete(cf, key);
+ txn.singleDelete(cf, b("once"));
txn.commit();
}
- });
- }
- }
-
- @Test
- @Order(19)
- void testTransactionReset() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb17").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Begin transaction and do first batch of work
- Transaction txn = db.beginTransaction();
- txn.put(cf, "key1".getBytes(), "value1".getBytes());
- txn.commit();
-
- // Reset instead of free + begin
- txn.reset(IsolationLevel.READ_COMMITTED);
-
- // Second batch of work using the same transaction
- txn.put(cf, "key2".getBytes(), "value2".getBytes());
- txn.commit();
-
- // Free once when done
- txn.free();
-
- // Verify both keys exist
- try (Transaction readTxn = db.beginTransaction()) {
- byte[] result1 = readTxn.get(cf, "key1".getBytes());
- assertNotNull(result1);
- assertArrayEquals("value1".getBytes(), result1);
-
- byte[] result2 = readTxn.get(cf, "key2".getBytes());
- assertNotNull(result2);
- assertArrayEquals("value2".getBytes(), result2);
+ assertNull(read(db, cf, "once"));
}
}
- }
-
- @Test
- @Order(20)
- void testTransactionResetWithDifferentIsolation() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb18").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Begin with READ_COMMITTED
- Transaction txn = db.beginTransaction(IsolationLevel.READ_COMMITTED);
- txn.put(cf, "key1".getBytes(), "value1".getBytes());
- txn.commit();
-
- // Reset with different isolation level (REPEATABLE_READ)
- txn.reset(IsolationLevel.REPEATABLE_READ);
- txn.put(cf, "key2".getBytes(), "value2".getBytes());
- txn.commit();
-
- // Reset again with SERIALIZABLE
- txn.reset(IsolationLevel.SERIALIZABLE);
- txn.put(cf, "key3".getBytes(), "value3".getBytes());
- txn.commit();
-
- txn.free();
-
- // Verify all keys exist
- try (Transaction readTxn = db.beginTransaction()) {
- for (int i = 1; i <= 3; i++) {
- byte[] result = readTxn.get(cf, ("key" + i).getBytes());
- assertNotNull(result);
- assertArrayEquals(("value" + i).getBytes(), result);
+
+ @Test
+ void deletesAHalfOpenRange() throws TidesDBException {
+ try (TidesDB db = openWithCf("del-range", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ try (Transaction txn = db.beginTransaction()) {
+ for (int i = 0; i < 20; i++) {
+ txn.put(cf, b(String.format("k%02d", i)), b("v" + i));
+ }
+ txn.commit();
}
- }
- }
- }
-
- @Test
- @Order(22)
- void testRangeCost() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb20").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Insert data
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 100; i++) {
- String key = String.format("key%04d", i);
- txn.put(cf, key.getBytes(), ("value" + i).getBytes());
+ try (Transaction txn = db.beginTransaction()) {
+ txn.deleteRange(cf, b("k05"), b("k10"));
+ txn.commit();
}
- txn.commit();
+
+ assertNotNull(read(db, cf, "k04"), "below the lower bound");
+ assertNull(read(db, cf, "k05"), "the lower bound is inclusive");
+ assertNull(read(db, cf, "k09"));
+ assertNotNull(read(db, cf, "k10"), "the upper bound is exclusive");
}
-
- // Estimate cost for a range
- double cost = cf.rangeCost("key0000".getBytes(), "key0099".getBytes());
- assertTrue(cost >= 0.0, "Range cost should be non-negative");
}
- }
-
- @Test
- @Order(23)
- void testRangeCostComparison() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb21").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- // Insert data
- try (Transaction txn = db.beginTransaction()) {
- for (int i = 0; i < 1000; i++) {
- String key = String.format("key%04d", i);
- txn.put(cf, key.getBytes(), ("value" + i).getBytes());
+
+ @Test
+ void deletesToTheEndOfTheFamily() throws TidesDBException {
+ try (TidesDB db = openWithCf("del-range-open", "cf")) {
+ ColumnFamily cf = db.getColumnFamily("cf");
+ try (Transaction txn = db.beginTransaction()) {
+ for (int i = 0; i < 10; i++) {
+ txn.put(cf, b("k" + i), b("v"));
+ }
+ txn.commit();
}
- txn.commit();
- }
-
- // Both costs should be non-negative
- double costSmall = cf.rangeCost("key0000".getBytes(), "key0010".getBytes());
- double costLarge = cf.rangeCost("key0000".getBytes(), "key0999".getBytes());
- assertTrue(costSmall >= 0.0, "Small range cost should be non-negative");
- assertTrue(costLarge >= 0.0, "Large range cost should be non-negative");
- }
- }
-
- @Test
- @Order(24)
- void testRangeCostNullKeys() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb22").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- assertThrows(IllegalArgumentException.class,
- () -> cf.rangeCost(null, "key".getBytes()));
- assertThrows(IllegalArgumentException.class,
- () -> cf.rangeCost("key".getBytes(), null));
- assertThrows(IllegalArgumentException.class,
- () -> cf.rangeCost(new byte[0], "key".getBytes()));
- assertThrows(IllegalArgumentException.class,
- () -> cf.rangeCost("key".getBytes(), new byte[0]));
- }
- }
-
- @Test
- @Order(25)
- void testCommitHookBasic() throws TidesDBException {
- Config config = Config.builder(tempDir.resolve("testdb23").toString())
- .numFlushThreads(2)
- .numCompactionThreads(2)
- .logLevel(LogLevel.INFO)
- .blockCacheSize(64 * 1024 * 1024)
- .maxOpenSSTables(256)
- .build();
-
- try (TidesDB db = TidesDB.open(config)) {
- ColumnFamilyConfig cfConfig = ColumnFamilyConfig.defaultConfig();
- db.createColumnFamily("test_cf", cfConfig);
-
- ColumnFamily cf = db.getColumnFamily("test_cf");
-
- List