From eed32565e4ba57117aa167a7552eaaa9cf233ffa Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Tue, 6 Oct 2026 06:09:57 +0530 Subject: [PATCH 1/4] feat: size the node cache in megabytes The cache limit was a number of decoded nodes, which says little about memory: a node can be a few bytes or a full page. Each shard now has a byte budget and every node counts as the size of its page, header plus payload when read and the image size when written. CLOCK eviction is kept, on a linked ring so entries can differ in size. The cache_nodes setting becomes cache_mb, 256 by default. --- .../java/io/hstore/bench/HStoreStore.java | 11 +- .../java/io/hstore/engine/EngineOptions.java | 33 +++--- .../java/io/hstore/engine/StorageEngine.java | 2 +- .../java/io/hstore/engine/tree/NodeCache.java | 105 ++++++++++++------ .../hstore/engine/tree/PagedNodeSource.java | 12 +- .../hstore/engine/txn/TransactionManager.java | 2 +- .../java/io/hstore/server/ServerConfig.java | 2 +- .../main/java/io/hstore/server/Setting.java | 2 +- .../java/io/hstore/server/studio/Studio.java | 2 +- .../resources/studio/js/views/dashboard.js | 2 +- 10 files changed, 110 insertions(+), 63 deletions(-) diff --git a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java index bdfdd72..9dd9c3f 100644 --- a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java +++ b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java @@ -32,21 +32,19 @@ final class HStoreStore implements Store { private long writtenBeforeReopen; private long readBeforeReopen; - static final int CACHED_NODES = 1 << 20; + static final long DEFAULT_CACHE_BYTES = (1L << 20) * 16 * 1024; private final int history; - private final int cachedNodes; private final long cacheBytes; HStoreStore(Path directory, boolean sync, int history, long cacheBytes, double liveRatio, int pagesPerSegment, boolean create) { this.directory = directory; this.history = history; - this.cacheBytes = cacheBytes; + this.cacheBytes = cacheBytes > 0 ? cacheBytes : DEFAULT_CACHE_BYTES; EngineOptions engine = EngineOptions.defaults(); - this.cachedNodes = cacheBytes > 0 ? Math.toIntExact(cacheBytes / engine.pageSize()) : CACHED_NODES; this.options = DatabaseOptions.defaults().withEngine(engine - .withDurability(sync ? Durability.SYNC : Durability.ASYNC).withCachedNodes(cachedNodes).withHistoryLimit(history) + .withDurability(sync ? Durability.SYNC : Durability.ASYNC).withCacheBytes(this.cacheBytes).withHistoryLimit(history) .withCompactionLiveRatio(liveRatio).withPagesPerSegment(pagesPerSegment > 0 ? pagesPerSegment : engine.pagesPerSegment())); this.database = HypergraphDatabase.open(directory, options); if (create) { @@ -70,8 +68,7 @@ public String version() { @Override public String cache() { - String budget = cacheBytes > 0 ? " (%,d MiB divided by the page size)".formatted(cacheBytes >> 20) : ""; - return "node cache of %,d decoded nodes%s and retains %d generations of history".formatted(cachedNodes, budget, history); + return "node cache of %,d MiB and retains %d generations of history".formatted(cacheBytes >> 20, history); } @Override diff --git a/engine/src/main/java/io/hstore/engine/EngineOptions.java b/engine/src/main/java/io/hstore/engine/EngineOptions.java index c3f9c18..e60a9ec 100644 --- a/engine/src/main/java/io/hstore/engine/EngineOptions.java +++ b/engine/src/main/java/io/hstore/engine/EngineOptions.java @@ -12,7 +12,7 @@ public record EngineOptions( int pageSize, int pagesPerSegment, long walSegmentBytes, - int cachedNodes, + long cacheBytes, Durability durability, WalMode walMode, int historyLimit, @@ -30,6 +30,9 @@ public record EngineOptions( if (compactionLiveRatio <= 0 || compactionLiveRatio >= 1) { throw new IllegalArgumentException("compaction live ratio must be between 0 and 1: " + compactionLiveRatio); } + if (cacheBytes < 1) { + throw new IllegalArgumentException("node cache must be at least one byte: " + cacheBytes); + } if (feedRetention < 1) { throw new IllegalArgumentException("feed retention must keep at least one generation: " + feedRetention); } @@ -38,67 +41,67 @@ public record EngineOptions( } public static EngineOptions defaults() { - return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 1 << 16, Durability.SYNC, WalMode.PAGE_REFERENCES, 64, + return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 256L << 20, Durability.SYNC, WalMode.PAGE_REFERENCES, 64, 256L << 20, 0.5, 100_000, List.of(), List.of(), CrashPoint.Injector.NONE); } public EngineOptions withPageSize(int bytes) { - return new EngineOptions(bytes, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(bytes, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withWalSegmentBytes(long bytes) { - return new EngineOptions(pageSize, pagesPerSegment, bytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, bytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withPagesPerSegment(int pages) { - return new EngineOptions(pageSize, pages, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pages, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withDurability(Durability mode) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, mode, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, mode, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withWalMode(WalMode mode) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, mode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, mode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withHistoryLimit(int generations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, generations, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, generations, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withCheckpointWalBytes(long bytes) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, bytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } - public EngineOptions withCachedNodes(int nodes) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodes, durability, walMode, historyLimit, + public EngineOptions withCacheBytes(long bytes) { + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, bytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withCompactionLiveRatio(double ratio) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, ratio, feedRetention, slots, derivations, faults); } public EngineOptions withFeedRetention(long generations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, generations, slots, derivations, faults); } public EngineOptions withExtensions(List> extensionSlots, List extensionDerivations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, extensionSlots, extensionDerivations, faults); } public EngineOptions withFaults(CrashPoint.Injector injector) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cachedNodes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, injector); } } diff --git a/engine/src/main/java/io/hstore/engine/StorageEngine.java b/engine/src/main/java/io/hstore/engine/StorageEngine.java index 2414b76..917e4dc 100644 --- a/engine/src/main/java/io/hstore/engine/StorageEngine.java +++ b/engine/src/main/java/io/hstore/engine/StorageEngine.java @@ -76,7 +76,7 @@ private StorageEngine(Path directory, EngineOptions options, FileChannel lockCha FeedCodec codec = new FeedCodec(slots); this.feed = ChangeFeed.open(directory.resolve("feed"), codec, options.walSegmentBytes()); this.recovery = Recovery.recover(checkpoint, wal, pages, feed, codec); - this.cache = new NodeCache(options.cachedNodes()); + this.cache = new NodeCache(options.cacheBytes()); PagedNodeSource source = new PagedNodeSource(pages, cache); TransactionManager.Storage storage = new TransactionManager.Storage(pages, source, wal, feed, codec, slots, options.derivations(), options.durability(), options.walMode(), options.faults(), options.historyLimit()); diff --git a/engine/src/main/java/io/hstore/engine/tree/NodeCache.java b/engine/src/main/java/io/hstore/engine/tree/NodeCache.java index 7349cde..01e79e3 100644 --- a/engine/src/main/java/io/hstore/engine/tree/NodeCache.java +++ b/engine/src/main/java/io/hstore/engine/tree/NodeCache.java @@ -1,6 +1,5 @@ package io.hstore.engine.tree; -import java.util.Arrays; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.LongAdder; import java.util.concurrent.locks.ReentrantLock; @@ -8,13 +7,14 @@ public final class NodeCache { private static final int SHARDS = 16; + private static final long MIN_SHARD_BYTES = 64 * 1024; private final Shard[] shards = new Shard[SHARDS]; private final LongAdder hits = new LongAdder(); private final LongAdder misses = new LongAdder(); - public NodeCache(int capacity) { - int perShard = Math.max(16, capacity / SHARDS); + public NodeCache(long capacityBytes) { + long perShard = Math.max(MIN_SHARD_BYTES, capacityBytes / SHARDS); for (int i = 0; i < SHARDS; i++) { shards[i] = new Shard(perShard); } @@ -26,8 +26,8 @@ public Node get(long pageId) { return node; } - public void put(long pageId, Node node) { - shard(pageId).put(pageId, node); + public void put(long pageId, Node node, int bytes) { + shard(pageId).put(pageId, node, bytes); } public void clear() { @@ -44,6 +44,14 @@ public long misses() { return misses.sum(); } + public long residentBytes() { + long total = 0; + for (Shard shard : shards) { + total += shard.bytes; + } + return total; + } + private Shard shard(long pageId) { return shards[(int) (Hashing.mix(pageId) >>> 60) & (SHARDS - 1)]; } @@ -51,23 +59,27 @@ private Shard shard(long pageId) { private static final class Slot { final long pageId; final Node node; + final int bytes; volatile boolean referenced; + Slot previous; + Slot next; - Slot(long pageId, Node node) { + Slot(long pageId, Node node, int bytes) { this.pageId = pageId; this.node = node; + this.bytes = bytes; } } private static final class Shard { - private final ConcurrentHashMap entries; - private final Slot[] ring; + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); private final ReentrantLock admission = new ReentrantLock(); - private int hand; + private final long budget; + private volatile long bytes; + private Slot hand; - Shard(int capacity) { - this.entries = new ConcurrentHashMap<>(capacity * 4 / 3 + 1); - this.ring = new Slot[capacity]; + Shard(long budget) { + this.budget = budget; } Node get(long pageId) { @@ -81,45 +93,76 @@ Node get(long pageId) { return slot.node; } - void put(long pageId, Node node) { + void put(long pageId, Node node, int weight) { admission.lock(); try { Slot existing = entries.get(pageId); if (existing != null && existing.node == node) { return; } - Slot slot = new Slot(pageId, node); if (existing != null) { - for (int i = 0; i < ring.length; i++) { - if (ring[i] == existing) { - ring[i] = slot; - break; - } - } - entries.put(pageId, slot); - return; + unlink(existing); + entries.remove(pageId, existing); } - while (ring[hand] != null && ring[hand].referenced) { - ring[hand].referenced = false; - hand = (hand + 1) % ring.length; + if (weight > budget) { + return; } - if (ring[hand] != null) { - entries.remove(ring[hand].pageId, ring[hand]); + while (bytes + weight > budget) { + evict(); } - ring[hand] = slot; + Slot slot = new Slot(pageId, node, weight); + link(slot); entries.put(pageId, slot); - hand = (hand + 1) % ring.length; } finally { admission.unlock(); } } + private void evict() { + while (hand.referenced) { + hand.referenced = false; + hand = hand.next; + } + Slot victim = hand; + unlink(victim); + entries.remove(victim.pageId, victim); + } + + private void link(Slot slot) { + if (hand == null) { + slot.previous = slot; + slot.next = slot; + hand = slot; + } else { + slot.next = hand; + slot.previous = hand.previous; + hand.previous.next = slot; + hand.previous = slot; + } + bytes += slot.bytes; + } + + private void unlink(Slot slot) { + if (slot.next == slot) { + hand = null; + } else { + slot.previous.next = slot.next; + slot.next.previous = slot.previous; + if (hand == slot) { + hand = slot.next; + } + } + slot.previous = null; + slot.next = null; + bytes -= slot.bytes; + } + void clear() { admission.lock(); try { entries.clear(); - Arrays.fill(ring, null); - hand = 0; + hand = null; + bytes = 0; } finally { admission.unlock(); } diff --git a/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java b/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java index 7f45720..039ddb1 100644 --- a/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java +++ b/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java @@ -2,8 +2,11 @@ import io.hstore.engine.HStoreException; import io.hstore.engine.page.IoTrace; +import io.hstore.engine.page.PageHeader; import io.hstore.engine.page.PageStore; +import java.lang.foreign.MemorySegment; + public final class PagedNodeSource implements NodeSource { private final PageStore store; @@ -27,13 +30,14 @@ public Node load(long pageId, TreeSchema schema) { return cached; } IoTrace.recordRead(); - Node node = NodeCodec.decode(store.read(pageId), pageId, schema); - cache.put(pageId, node); + MemorySegment page = store.read(pageId); + Node node = NodeCodec.decode(page, pageId, schema); + cache.put(pageId, node, PageHeader.SIZE + PageHeader.payloadLength(page)); return node; } - public void admit(long pageId, Node frozen) { - cache.put(pageId, frozen); + public void admit(long pageId, Node frozen, int bytes) { + cache.put(pageId, frozen, bytes); } @Override diff --git a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java index 443f9bc..f1d2d23 100644 --- a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java +++ b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java @@ -370,7 +370,7 @@ public void accept(long pageId, MemorySegment image, Node frozen) { ? new WalRecord.Page(txnId, pageId, image.toArray(ValueLayout.JAVA_BYTE)) : new WalRecord.PageRef(txnId, pageId, Math.toIntExact(image.byteSize()), Checksums.crc32c(image))); writes.add(new PageWrite(pageId, image)); - storage.source().admit(pageId, frozen); + storage.source().admit(pageId, frozen, Math.toIntExact(image.byteSize())); } }, epoch, storage.pages().pageSize()); } diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 088e208..26f7eb8 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -104,7 +104,7 @@ EngineOptions engineOptions() { .withPageSize(integer(Setting.PAGE_SIZE)) .withDurability(Durability.valueOf(string(Setting.DURABILITY).toUpperCase())) .withWalMode(string(Setting.WAL_MODE).equalsIgnoreCase("images") ? WalMode.PAGE_IMAGES : WalMode.PAGE_REFERENCES) - .withCachedNodes(integer(Setting.CACHE_NODES)) + .withCacheBytes(number(Setting.CACHE_MB) << 20) .withCheckpointWalBytes(number(Setting.CHECKPOINT_WAL_MB) << 20) .withHistoryLimit(integer(Setting.HISTORY_LIMIT)) .withCompactionLiveRatio(Double.parseDouble(string(Setting.COMPACTION_LIVE_RATIO))) diff --git a/server/src/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index fb178b2..05dc2be 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -17,7 +17,7 @@ enum Setting { PAGE_SIZE("page_size", "16384", "page size in bytes, fixed when the data directory is initialised"), DURABILITY("durability", "sync", "sync waits for fsync before acknowledging commits; async does not"), WAL_MODE("wal_mode", "references", "references logs page checksums and syncs data first; images logs full page images"), - CACHE_NODES("cache_nodes", "65536", "decoded tree nodes kept in the page cache"), + CACHE_MB("cache_mb", "256", "megabytes of decoded tree nodes kept in memory, each counted at the size of its page"), CHECKPOINT_WAL_MB("checkpoint_wal_mb", "256", "write-ahead log volume that triggers a background checkpoint"), HISTORY_LIMIT("history_limit", "64", "committed generations retained for time travel"), COMPACTION_LIVE_RATIO("compaction_live_ratio", "0.5", "sealed segments whose live node images fall below this ratio are compacted"), diff --git a/server/src/main/java/io/hstore/server/studio/Studio.java b/server/src/main/java/io/hstore/server/studio/Studio.java index 021e472..eca4f7c 100644 --- a/server/src/main/java/io/hstore/server/studio/Studio.java +++ b/server/src/main/java/io/hstore/server/studio/Studio.java @@ -476,7 +476,7 @@ private Response stats(Request request) { .put("segmentBytes", (long) configured.pagesPerSegment() * configured.pageSize()) .put("durability", configured.durability().name()) .put("walMode", configured.walMode().name()) - .put("cachedNodes", configured.cachedNodes()) + .put("cacheBytes", configured.cacheBytes()) .put("historyLimit", configured.historyLimit()) .put("checkpointWalBytes", configured.checkpointWalBytes()) .build()) diff --git a/server/src/main/resources/studio/js/views/dashboard.js b/server/src/main/resources/studio/js/views/dashboard.js index bb1828e..3408af8 100644 --- a/server/src/main/resources/studio/js/views/dashboard.js +++ b/server/src/main/resources/studio/js/views/dashboard.js @@ -120,7 +120,7 @@ export class DashboardPage { pair('Page size', bytes(configuration.pageSize)), pair('Durability', configuration.durability), pair('WAL mode', configuration.walMode), - pair('Node cache', `${count(configuration.cachedNodes)} nodes`), + pair('Node cache', bytes(configuration.cacheBytes)), pair('History limit', `${count(configuration.historyLimit)} generations`), pair('Checkpoint after', bytes(configuration.checkpointWalBytes)), pair('Change feed', bytes(engine.feedBytes)), From c9fa0e121666f8a9a6d967b73bd71c6ff5a9caac Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Tue, 6 Oct 2026 06:09:57 +0530 Subject: [PATCH 2/4] feat(server): refuse the old cache_nodes setting with a pointer to cache_mb Whether it comes from hstore.conf, an HSTORE_CACHE_NODES variable or a flag, startup now stops with a message saying to use cache_mb, instead of failing on an unknown key or quietly ignoring it. --- .../java/io/hstore/server/ServerConfig.java | 8 +++++++ .../main/java/io/hstore/server/Setting.java | 24 ++++++++++++++++++- .../java/io/hstore/server/ServerTest.java | 12 ++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 26f7eb8..6568fb2 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -49,6 +49,14 @@ static ServerConfig load(Optional directory, Map environme } properties.forEach((key, value) -> config.set(Setting.of(key.toString()), value.toString().strip(), Source.FILE)); }); + Setting.removedVariables().forEach((variable, message) -> { + if (environment.containsKey(variable)) { + throw new IllegalArgumentException(variable + ": " + message); + } + }); + commandLine.keySet().forEach(key -> Setting.removed(key).ifPresent(message -> { + throw new IllegalArgumentException(message); + })); for (Setting setting : Setting.values()) { Optional.ofNullable(environment.get(setting.environmentVariable())) .ifPresent(value -> config.set(setting, value, Source.ENVIRONMENT)); diff --git a/server/src/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index 05dc2be..208a6c9 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -1,5 +1,9 @@ package io.hstore.server; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + enum Setting { LISTEN_ADDRESS("listen_address", "127.0.0.1", "interface the TCP server binds; use 0.0.0.0 inside containers"), PORT("port", "7432", "TCP port of the HQL wire protocol"), @@ -37,6 +41,9 @@ enum Setting { LOG_ROTATION_MB("log_rotation_mb", "64", "size of one log file before rotation"), LOG_FILE_COUNT("log_file_count", "8", "number of rotated log files kept"); + private static final Map REMOVED = Map.of( + "cache_nodes", "cache_nodes is no longer supported: the node cache is now sized in megabytes. Remove cache_nodes and set cache_mb instead"); + private final String key; private final String fallback; private final String description; @@ -63,8 +70,23 @@ String environmentVariable() { return "HSTORE_" + key.toUpperCase(); } + static Optional removed(String key) { + return Optional.ofNullable(REMOVED.get(normalize(key))); + } + + static Map removedVariables() { + return REMOVED.entrySet().stream().collect(Collectors.toMap(entry -> "HSTORE_" + entry.getKey().toUpperCase(), Map.Entry::getValue)); + } + + private static String normalize(String key) { + return key.toLowerCase().replace('-', '_'); + } + static Setting of(String key) { - String normalized = key.toLowerCase().replace('-', '_'); + String normalized = normalize(key); + removed(normalized).ifPresent(message -> { + throw new IllegalArgumentException(message); + }); for (Setting setting : values()) { if (setting.key.equals(normalized)) { return setting; diff --git a/server/src/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index dd9ae8a..f3c67ed 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -110,6 +110,18 @@ void wireProtocolRunsOverTls() throws Exception { } } + @Test + void cacheIsSizedInMegabytesAndTheOldNodeCountIsRejected(@TempDir Path data) throws Exception { + assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_CACHE_MB", "64"), Map.of()).engineOptions().cacheBytes()); + assertEquals(256L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().cacheBytes()); + Files.writeString(data.resolve(ServerConfig.FILE), "cache_nodes = 4096\n"); + List failures = List.of( + assertThrows(IllegalArgumentException.class, () -> ServerConfig.load(Optional.of(data), Map.of(), Map.of())), + assertThrows(IllegalArgumentException.class, () -> ServerConfig.load(Optional.empty(), Map.of("HSTORE_CACHE_NODES", "4096"), Map.of())), + assertThrows(IllegalArgumentException.class, () -> ServerConfig.load(Optional.empty(), Map.of(), Map.of("cache-nodes", "4096")))); + failures.forEach(failure -> assertTrue(failure.getMessage().contains("cache_mb"), failure.getMessage())); + } + @Test void tlsWithoutACertificateIsRejectedAtStartup() { ServerConfig config = ServerConfig.load(Optional.empty(), Map.of(), Map.of("tls", "on")); From dc6aa767972aea2c7135a15acf47e1cb581f90f1 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Tue, 6 Oct 2026 06:09:57 +0530 Subject: [PATCH 3/4] test(engine): check the node cache keeps to its byte budget --- .../io/hstore/engine/tree/NodeCacheTest.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java diff --git a/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java b/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java new file mode 100644 index 0000000..da27319 --- /dev/null +++ b/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java @@ -0,0 +1,74 @@ +package io.hstore.engine.tree; + +import org.junit.jupiter.api.Test; + +import java.util.random.RandomGenerator; +import java.util.random.RandomGeneratorFactory; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NodeCacheTest { + + private static Node node(long key) { + return Leaf.owned(TreeTest.LONGS, null, new long[]{key}, new Object[]{key}, 1); + } + + @Test + void residentBytesStayWithinTheBudget() { + long budget = 1 << 20; + NodeCache cache = new NodeCache(budget); + RandomGenerator random = RandomGeneratorFactory.of("L64X128MixRandom").create(3); + for (long page = 0; page < 20_000; page++) { + cache.put(page, node(page), 100 + random.nextInt(16 * 1024)); + assertTrue(cache.residentBytes() <= budget, "resident " + cache.residentBytes() + " after page " + page); + if (page % 7 == 0) { + cache.get(random.nextLong(page + 1)); + } + } + assertTrue(cache.residentBytes() > budget / 2, "cache should stay mostly full, holds " + cache.residentBytes()); + } + + @Test + void pagesThatKeepBeingReadSurviveColdInserts() { + NodeCache cache = new NodeCache(1 << 20); + long[] hot = LongStream.range(0, 32).toArray(); + for (long page : hot) { + cache.put(page, node(page), 4096); + } + for (long cold = 1_000; cold < 50_000; cold++) { + cache.put(cold, node(cold), 4096); + for (long page : hot) { + cache.get(page); + } + } + for (long page : hot) { + assertTrue(cache.get(page) != null, "hot page " + page + " was evicted"); + } + } + + @Test + void aNodeLargerThanItsShardIsNotCached() { + NodeCache cache = new NodeCache(1 << 20); + cache.put(7, node(7), 128 * 1024); + assertNull(cache.get(7)); + assertEquals(0, cache.residentBytes()); + } + + @Test + void puttingAPageAgainReplacesItAndItsSize() { + NodeCache cache = new NodeCache(1 << 20); + Node first = node(1); + Node second = node(2); + cache.put(9, first, 1_000); + cache.put(9, second, 5_000); + assertSame(second, cache.get(9)); + assertEquals(5_000, cache.residentBytes()); + cache.clear(); + assertEquals(0, cache.residentBytes()); + assertNull(cache.get(9)); + } +} From 5f5a9dc10b8703382ed1c27165f5a2ea45b2435f Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Tue, 6 Oct 2026 06:09:57 +0530 Subject: [PATCH 4/4] docs: describe cache_mb and the byte-budgeted node cache --- docs/architecture/overview.md | 2 +- docs/benchmarks.md | 12 ++++++------ docs/operations/configuration.md | 12 ++++++++---- docs/operations/docker.md | 2 +- docs/operations/studio.md | 2 +- docs/storage/pages.md | 8 +++++--- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index ecadb7a..4c05555 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -154,7 +154,7 @@ sequenceDiagram Notes: -* Nodes are decoded once and then shared by every reader through `NodeCache`. It has 16 shards, each a `ConcurrentHashMap` plus a CLOCK (second-chance) ring, with total capacity `cache_nodes`. Lookups are lock-free; only admission takes a per-shard lock. Decoded nodes are immutable (`owner == null`), so sharing them needs no copying. See [pages.md](../storage/pages.md#caching-and-io-accounting). +* Nodes are decoded once and then shared by every reader through `NodeCache`. It has 16 shards, each a `ConcurrentHashMap` plus a CLOCK (second-chance) ring, with a total byte budget of `cache_mb`, each node counted at the size of its page. Lookups are lock-free; only admission takes a per-shard lock. Decoded nodes are immutable (`owner == null`), so sharing them needs no copying. See [pages.md](../storage/pages.md#caching-and-io-accounting). * `IoTrace` is bound with `ScopedValue.where(...)` around a query. `recordRead` throws `HStoreException.limit` once page reads plus cache hits exceed the query's page budget (`query_page_budget`). * Historical reads use `TransactionManager.snapshotAt(generation, branch)`. It looks the generation up in the retained `history` map; see [catalog-and-generations.md](../storage/catalog-and-generations.md#history-retention-and-time-travel). diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6569739..6ed4bca 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -21,7 +21,7 @@ flowchart LR |---|---|---| | Version | 0.1.0 (this repository) | 1.4 at commit `99485a1`, storage `bdb-je` on Berkeley DB JE 5.0.73 | | Data model used | node type `Entity` (indexed by key, property `name`); `SET_EDGE` type `Relation` | `String` atoms; `HGPlainLink` links | -| Cache | node cache of 1,048,576 decoded nodes, or `CACHE_MB` / page size | JE cache at 30% of the heap (HyperGraphDB default), or `CACHE_MB`, plus the HyperGraphDB atom cache | +| Cache | node cache of 16 GiB (enough for the whole dataset), or `CACHE_MB` | JE cache at 30% of the heap (HyperGraphDB default), or `CACHE_MB`, plus the HyperGraphDB atom cache | | `async` durability | `durability = async` | HyperGraphDB default: JE `WRITE_NO_SYNC` | | `sync` durability | `durability = sync`, `wal_mode = references`, group commit | JE `COMMIT_SYNC` | | History | `history_limit = 64` generations retained for time travel (default) | none | @@ -249,7 +249,7 @@ not what drives HStore's footprint: defers the work to its cleaner. In `sync` mode group commit amortises the fsync and HStore leads (1.35×). * **Parallel incidence reads at scale 4 (0.46×).** Every probe batch opens a snapshot, which pins a generation in a shared skip-list map. At 10 threads this coordination, plus node-cache admission under - churn (the hot set exceeds one million decoded nodes), costs more than JE's latch-free cache hits. At scale 1 + churn (the hot set exceeds the node cache), costs more than JE's latch-free cache hits. At scale 1 the results are mixed: 0.66× async and 1.34× sync. * **Disk at scale 4 (0.54×).** Live data is comparable: about 1 GB of live node images in HStore against 1.27 GB in JE. The difference is garbage that has not been reclaimed yet. Both engines are log-structured @@ -277,15 +277,15 @@ run in brackets, and "Results agree" requires every run of both stores to return and `HEAP` override the thread count and heap size. Close other workloads first: the numbers are only meaningful on an otherwise idle machine. -**Equal cache budgets.** By default the two caches are not the same size. HStore caches about a million decoded -nodes, which at 16 KiB pages is enough to hold the whole dataset. HyperGraphDB gets JE's default of 30% of the +**Equal cache budgets.** By default the two caches are not the same size. HStore's node cache gets 16 GiB, which is +enough to hold the whole dataset. HyperGraphDB gets JE's default of 30% of the heap. `CACHE_MB` gives both the same budget: | HStore | HyperGraphDB | |---|---| -| node cache of `CACHE_MB` / page size nodes | JE cache of exactly `CACHE_MB` | +| node cache of `CACHE_MB`, each node counted at the size of its page | JE cache of exactly `CACHE_MB` | -Decoded nodes can take more or less heap than their page, and HyperGraphDB's own atom and incidence caches can't +Decoded nodes take more heap than their page, and HyperGraphDB's own atom and incidence caches can't be capped in bytes (they shrink under memory pressure). So check the retained-heap line in the report to see what each store actually kept. Results go to a directory ending in `-cache-`. diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index b6a55c1..ec398c4 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -91,7 +91,7 @@ max_connections = 200 | `page_size` | `HSTORE_PAGE_SIZE` | `16384` | Page size in bytes. Must be a power of two from 1 KiB to 1 MiB. It is recorded in `/FORMAT` at creation, and opening with a different value fails with `database uses N byte pages, options request M`. | | `durability` | `HSTORE_DURABILITY` | `sync` | `sync`: a commit is acknowledged after the group-commit barrier has `fsync`ed it. `async`: acknowledged once appended, so a crash can lose a suffix of acknowledged commits but never corrupts the database. | | `wal_mode` | `HSTORE_WAL_MODE` | `references` | `references`: the WAL logs `PageRef(pageId, length, crc32c)` records and data pages are synced before the WAL. `images`: the WAL logs full page images. See [WAL and recovery](../transactions/wal-and-recovery.md). | -| `cache_nodes` | `HSTORE_CACHE_NODES` | `65536` | Decoded tree nodes kept in the node cache. | +| `cache_mb` | `HSTORE_CACHE_MB` | `256` | Megabytes of decoded tree nodes kept in memory, each counted at the size of its page. | | `checkpoint_wal_mb` | `HSTORE_CHECKPOINT_WAL_MB` | `256` | WAL volume since the last checkpoint that triggers a background checkpoint. | | `history_limit` | `HSTORE_HISTORY_LIMIT` | `64` | Committed generations kept addressable for `AT GENERATION`, `AS OF`, `HISTORY` and the Studio time slider. | | `compaction_live_ratio` | `HSTORE_COMPACTION_LIVE_RATIO` | `0.5` | Sealed segments whose live bytes fall below this fraction of their allocated bytes are relocated by compaction: one segment (the emptiest) per background pass after every `checkpoint_wal_mb` of written node images, or all of them with `COMPACT`. Time-travel history is preserved; retired segments are deleted once `history_limit` has rolled past them. Must satisfy 0 < r < 1; other values fail at startup. | @@ -139,9 +139,13 @@ higher write amplification per commit, because each commit rewrites one root-to- 16 KiB suits mixed workloads. Use 4–8 KiB for update-heavy workloads with small hyperedges, and 32–64 KiB for analytics over giant hyperedges. The value cannot be changed after `init`. -**`cache_nodes`.** The node cache holds *decoded* nodes, not raw pages. Interior nodes are touched by every -lookup and are what you want resident. A rule of thumb: total pages × 0.02 for interior coverage, plus the -working set of hot leaves. The Dashboard *Cache hit rate* tile and `STATS;` show whether the cache suffices. +**`cache_mb`.** The node cache holds *decoded* nodes, not raw pages. Each node counts as the size of its page on +disk (at most `page_size`), so the setting is a byte budget. The heap it really uses is somewhat higher, because a +decoded node takes more memory than its page. Interior nodes are touched by every lookup and are what you want +resident. A rule of thumb: 2% of the data size for interior coverage, plus the working set of hot leaves. The +Dashboard *Cache hit rate* tile and `STATS;` show whether the cache suffices. The old `cache_nodes` setting, a +node count, is no longer accepted: the server refuses to start with it, whether it comes from `hstore.conf`, the +environment or a flag, and says to use `cache_mb`. **`checkpoint_wal_mb`.** A checkpoint writes the current generation's catalog image and truncates WAL segments before the checkpoint LSN. Lower values shorten recovery, since recovery replays WAL after the last checkpoint, diff --git a/docs/operations/docker.md b/docs/operations/docker.md index 356c489..d524e83 100644 --- a/docs/operations/docker.md +++ b/docs/operations/docker.md @@ -76,7 +76,7 @@ Without a password and without `HSTORE_AUTHENTICATION=off`, the container refuse ### Server settings Every [setting](configuration.md) is available as `HSTORE_`, for example `HSTORE_DURABILITY=async`, -`HSTORE_LOG_STATEMENT=ddl`, `HSTORE_LOG_MIN_DURATION_MS=250`, `HSTORE_CACHE_NODES=262144`, +`HSTORE_LOG_STATEMENT=ddl`, `HSTORE_LOG_MIN_DURATION_MS=250`, `HSTORE_CACHE_MB=1024`, `HSTORE_STUDIO=off`, `HSTORE_EMBEDDING_PROVIDER=ollama`. Environment variables override `/var/lib/hstore/data/hstore.conf`. Flags appended to the `docker run` command line override both: diff --git a/docs/operations/studio.md b/docs/operations/studio.md index 92b20ad..70034cc 100644 --- a/docs/operations/studio.md +++ b/docs/operations/studio.md @@ -141,7 +141,7 @@ Errors use one JSON shape: | `GET /api/graph` | yes | Hypergraph neighbourhood for visualisation; see below. | | `GET /api/schema` | yes | Types visible to the session: `id`, `name`, `kind`, `version`, `count`, `roles`, `properties[{name,type,indexed,required}]`, `jsonIndexes[{path,type}]`, plus `branches`. | | `GET /api/history` | yes | `{"current": G, "generations": [{"id","wallTime","txnId"}]}` for the retained generations. | -| `GET /api/stats` | ADMIN | Engine counters, segments (`id`, `state`, `pages` = node images, `bytes`, `live`), recovery outcome, configuration (`pageSize`, `segmentBytes`, `durability`, `walMode`, `cachedNodes`, `historyLimit`, `checkpointWalBytes`), semantic index generation, Studio session count, branches. | +| `GET /api/stats` | ADMIN | Engine counters, segments (`id`, `state`, `pages` = node images, `bytes`, `live`), recovery outcome, configuration (`pageSize`, `segmentBytes`, `durability`, `walMode`, `cacheBytes`, `historyLimit`, `checkpointWalBytes`), semantic index generation, Studio session count, branches. | Example `GET /api/info`: diff --git a/docs/storage/pages.md b/docs/storage/pages.md index b93dc6a..87b1ea4 100644 --- a/docs/storage/pages.md +++ b/docs/storage/pages.md @@ -215,7 +215,7 @@ Most nodes fit in 4 KiB, so most reads are a single 4 KiB `pread`. A short read The cache stores **decoded, frozen** `Node` objects, not bytes. A hit therefore costs no decoding and no allocation. The cache has 16 shards; a node goes to shard `mix(pageId) >>> 60`. Each shard has: * a `ConcurrentHashMap` from page id to `Slot(pageId, node, volatile referenced)`; -* a `Slot[] ring` of capacity `max(16, cache_nodes / 16)` with a CLOCK hand; +* a byte budget of `max(64 KiB, cache_mb / 16)` and a circular, doubly linked ring of slots with a CLOCK hand. Each slot weighs its page: header plus payload when it was read, the image size when the commit wrote it; * a `ReentrantLock` taken only for admission. **Reads are lock-free.** `get` is a `ConcurrentHashMap.get`, plus setting `referenced = true` if it was clear. The check avoids a redundant volatile write on hot entries. @@ -223,8 +223,10 @@ The cache stores **decoded, frozen** `Node` objects, not bytes. A hit therefore **Admission (`put`) uses CLOCK second-chance eviction under the shard lock:** * An entry already holding the same node is left alone. -* An entry for the same page with a different node is replaced in its ring slot. -* Otherwise the hand advances, clearing `referenced` bits, until it finds an empty slot or an unreferenced one. That victim is removed from the map, conditionally on the identity of its `Slot`, the new slot takes its place, and the hand moves on. +* An entry for the same page with a different node is unlinked first, and its bytes are released. +* A node larger than the shard's whole budget isn't cached. +* While the new node doesn't fit in the budget, the hand advances, clearing `referenced` bits, until it reaches an unreferenced slot. That victim is unlinked and removed from the map, conditionally on the identity of its `Slot`. +* The new slot is linked just behind the hand, so it gets a full sweep before it can be evicted. This approximates LRU. Readers never contend with each other, and they contend with writers only on the map's internal bins.