From b1d5ef37055978ac758e628113fc0597b6c21999 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Thu, 8 Oct 2026 16:40:31 +0530 Subject: [PATCH 01/10] refactor: call the engine cache option nodeCacheBytes Once pages are read through memory maps, the OS caches the raw bytes and this budget only covers decoded nodes on the heap. The engine option, the builder and the Studio stats key now say so. Nothing here has been released yet, so no alias is kept. --- .../java/io/hstore/bench/HStoreStore.java | 2 +- docs/operations/studio.md | 2 +- .../java/io/hstore/engine/EngineOptions.java | 30 +++++++++---------- .../java/io/hstore/engine/StorageEngine.java | 2 +- .../java/io/hstore/server/ServerConfig.java | 2 +- .../java/io/hstore/server/studio/Studio.java | 2 +- .../resources/studio/js/views/dashboard.js | 2 +- .../java/io/hstore/server/ServerTest.java | 4 +-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java index 9dd9c3f..6f62ea1 100644 --- a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java +++ b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java @@ -44,7 +44,7 @@ final class HStoreStore implements Store { this.cacheBytes = cacheBytes > 0 ? cacheBytes : DEFAULT_CACHE_BYTES; EngineOptions engine = EngineOptions.defaults(); this.options = DatabaseOptions.defaults().withEngine(engine - .withDurability(sync ? Durability.SYNC : Durability.ASYNC).withCacheBytes(this.cacheBytes).withHistoryLimit(history) + .withDurability(sync ? Durability.SYNC : Durability.ASYNC).withNodeCacheBytes(this.cacheBytes).withHistoryLimit(history) .withCompactionLiveRatio(liveRatio).withPagesPerSegment(pagesPerSegment > 0 ? pagesPerSegment : engine.pagesPerSegment())); this.database = HypergraphDatabase.open(directory, options); if (create) { diff --git a/docs/operations/studio.md b/docs/operations/studio.md index 70034cc..a0da61c 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`, `cacheBytes`, `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`, `nodeCacheBytes`, `historyLimit`, `checkpointWalBytes`), semantic index generation, Studio session count, branches. | Example `GET /api/info`: diff --git a/engine/src/main/java/io/hstore/engine/EngineOptions.java b/engine/src/main/java/io/hstore/engine/EngineOptions.java index e60a9ec..bc0ce6b 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, - long cacheBytes, + long nodeCacheBytes, Durability durability, WalMode walMode, int historyLimit, @@ -30,8 +30,8 @@ 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 (nodeCacheBytes < 1) { + throw new IllegalArgumentException("node cache must be at least one byte: " + nodeCacheBytes); } if (feedRetention < 1) { throw new IllegalArgumentException("feed retention must keep at least one generation: " + feedRetention); @@ -46,62 +46,62 @@ public static EngineOptions defaults() { } public EngineOptions withPageSize(int bytes) { - return new EngineOptions(bytes, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(bytes, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withWalSegmentBytes(long bytes) { - return new EngineOptions(pageSize, pagesPerSegment, bytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, bytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withPagesPerSegment(int pages) { - return new EngineOptions(pageSize, pages, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pages, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withDurability(Durability mode) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, mode, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, mode, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withWalMode(WalMode mode) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, mode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, mode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withHistoryLimit(int generations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, generations, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, generations, checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } public EngineOptions withCheckpointWalBytes(long bytes) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, bytes, compactionLiveRatio, feedRetention, slots, derivations, faults); } - public EngineOptions withCacheBytes(long bytes) { + public EngineOptions withNodeCacheBytes(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, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, ratio, feedRetention, slots, derivations, faults); } public EngineOptions withFeedRetention(long generations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, generations, slots, derivations, faults); } public EngineOptions withExtensions(List> extensionSlots, List extensionDerivations) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, checkpointWalBytes, compactionLiveRatio, feedRetention, extensionSlots, extensionDerivations, faults); } public EngineOptions withFaults(CrashPoint.Injector injector) { - return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, cacheBytes, durability, walMode, historyLimit, + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, 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 917e4dc..c90cf4b 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.cacheBytes()); + this.cache = new NodeCache(options.nodeCacheBytes()); 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/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 6568fb2..4e3ae29 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -112,7 +112,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) - .withCacheBytes(number(Setting.CACHE_MB) << 20) + .withNodeCacheBytes(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/studio/Studio.java b/server/src/main/java/io/hstore/server/studio/Studio.java index eca4f7c..fd20559 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("cacheBytes", configured.cacheBytes()) + .put("nodeCacheBytes", configured.nodeCacheBytes()) .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 3408af8..d749d9d 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', bytes(configuration.cacheBytes)), + pair('Node cache', bytes(configuration.nodeCacheBytes)), pair('History limit', `${count(configuration.historyLimit)} generations`), pair('Checkpoint after', bytes(configuration.checkpointWalBytes)), pair('Change feed', bytes(engine.feedBytes)), diff --git a/server/src/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index f3c67ed..52a2055 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -112,8 +112,8 @@ 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()); + assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_CACHE_MB", "64"), Map.of()).engineOptions().nodeCacheBytes()); + assertEquals(256L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); 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())), From deefe2ebe4c16e0c9550387a14e06dc5019199c3 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Thu, 8 Oct 2026 16:42:45 +0530 Subject: [PATCH 02/10] feat(server): rename cache_mb to node_cache_mb and refuse the old name cache_mb read as if it limited all caching, but it only sizes the decoded nodes kept on the heap. Setting cache_mb in hstore.conf, as HSTORE_CACHE_MB or as --cache-mb now fails at startup with a message that names node_cache_mb, the same way cache_nodes does. The cache_nodes message points to the new name as well. --- .../java/io/hstore/server/ServerConfig.java | 2 +- .../main/java/io/hstore/server/Setting.java | 5 +++-- .../java/io/hstore/server/ServerTest.java | 21 ++++++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 4e3ae29..9413d0f 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -112,7 +112,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) - .withNodeCacheBytes(number(Setting.CACHE_MB) << 20) + .withNodeCacheBytes(number(Setting.NODE_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 208a6c9..2c88612 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -21,7 +21,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_MB("cache_mb", "256", "megabytes of decoded tree nodes kept in memory, each counted at the size of its page"), + NODE_CACHE_MB("node_cache_mb", "256", "megabytes of decoded tree nodes kept on the heap, 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"), @@ -42,7 +42,8 @@ enum Setting { 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"); + "cache_nodes", "cache_nodes is no longer supported: the node cache is now sized in megabytes. Remove cache_nodes and set node_cache_mb instead", + "cache_mb", "cache_mb is now called node_cache_mb, since it only sizes the decoded nodes kept on the heap. Rename it to node_cache_mb"); private final String key; private final String fallback; diff --git a/server/src/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index 52a2055..c76db80 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -16,6 +16,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -111,15 +112,19 @@ 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().nodeCacheBytes()); + void nodeCacheIsSizedInMegabytesAndOldNamesAreRejected(@TempDir Path data) throws Exception { + assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_NODE_CACHE_MB", "64"), Map.of()).engineOptions().nodeCacheBytes()); assertEquals(256L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); - 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())); + List failures = new ArrayList<>(); + for (String old : List.of("cache_nodes", "cache_mb")) { + Files.writeString(data.resolve(ServerConfig.FILE), old + " = 4096\n"); + failures.add(assertThrows(IllegalArgumentException.class, () -> ServerConfig.load(Optional.of(data), Map.of(), Map.of()))); + failures.add(assertThrows(IllegalArgumentException.class, + () -> ServerConfig.load(Optional.empty(), Map.of("HSTORE_" + old.toUpperCase(), "4096"), Map.of()))); + failures.add(assertThrows(IllegalArgumentException.class, + () -> ServerConfig.load(Optional.empty(), Map.of(), Map.of(old.replace('_', '-'), "4096")))); + } + failures.forEach(failure -> assertTrue(failure.getMessage().contains("node_cache_mb"), failure.getMessage())); } @Test From 3322f315020fd63f29edb0408c1da22ec6f527f9 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Thu, 8 Oct 2026 18:43:17 +0530 Subject: [PATCH 03/10] perf: keep 64 MiB of decoded nodes on the heap by default The server is a native image with the serial collector, so everything the node cache holds is copied at every full collection. With page reads going through memory maps, a miss costs a copy out of the OS page cache and a decode rather than a system call, so a smaller decoded cache costs much less than it used to. The default drops from 256 to 64 MiB. --- engine/src/main/java/io/hstore/engine/EngineOptions.java | 2 +- server/src/main/java/io/hstore/server/Setting.java | 2 +- server/src/test/java/io/hstore/server/ServerTest.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/engine/src/main/java/io/hstore/engine/EngineOptions.java b/engine/src/main/java/io/hstore/engine/EngineOptions.java index bc0ce6b..0003816 100644 --- a/engine/src/main/java/io/hstore/engine/EngineOptions.java +++ b/engine/src/main/java/io/hstore/engine/EngineOptions.java @@ -41,7 +41,7 @@ public record EngineOptions( } public static EngineOptions defaults() { - return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 256L << 20, Durability.SYNC, WalMode.PAGE_REFERENCES, 64, + return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 64L << 20, Durability.SYNC, WalMode.PAGE_REFERENCES, 64, 256L << 20, 0.5, 100_000, List.of(), List.of(), CrashPoint.Injector.NONE); } diff --git a/server/src/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index 2c88612..447bb74 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -21,7 +21,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"), - NODE_CACHE_MB("node_cache_mb", "256", "megabytes of decoded tree nodes kept on the heap, each counted at the size of its page"), + NODE_CACHE_MB("node_cache_mb", "64", "megabytes of decoded tree nodes kept on the heap, 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/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index c76db80..b845eaf 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -113,8 +113,8 @@ void wireProtocolRunsOverTls() throws Exception { @Test void nodeCacheIsSizedInMegabytesAndOldNamesAreRejected(@TempDir Path data) throws Exception { - assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_NODE_CACHE_MB", "64"), Map.of()).engineOptions().nodeCacheBytes()); - assertEquals(256L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); + assertEquals(512L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_NODE_CACHE_MB", "512"), Map.of()).engineOptions().nodeCacheBytes()); + assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); List failures = new ArrayList<>(); for (String old : List.of("cache_nodes", "cache_mb")) { Files.writeString(data.resolve(ServerConfig.FILE), old + " = 4096\n"); From 50d9ee81b12160fc4266ca6fc3430aeb55c4c5e4 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Thu, 8 Oct 2026 18:43:53 +0530 Subject: [PATCH 04/10] docs: describe node_cache_mb and why it is small The configuration page explains that only decoded nodes live on the heap, that the serial collector walks all of them on a full collection, and that both cache_mb and cache_nodes are refused at startup. --- docs/architecture/overview.md | 2 +- docs/operations/configuration.md | 22 ++++++++++++++-------- docs/operations/docker.md | 2 +- docs/storage/pages.md | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 4c05555..f3122d4 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 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). +* 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 `node_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/operations/configuration.md b/docs/operations/configuration.md index ec398c4..4d9d88c 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_mb` | `HSTORE_CACHE_MB` | `256` | Megabytes of decoded tree nodes kept in memory, each counted at the size of its page. | +| `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `64` | Megabytes of decoded tree nodes kept on the Java heap, 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,13 +139,19 @@ 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_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`. +**`node_cache_mb`.** This is the only cache HStore sizes itself, and it holds *decoded* nodes on the Java heap. +Raw pages are cached by the operating system like any other file data, in memory the OS can take back when it +needs to. 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. + +The server binary uses the serial collector, and every full collection walks everything this cache holds, so a +bigger cache means longer pauses. Size it for what every lookup touches: the interior nodes, about 2% of the data, +plus the leaves you read all the time. The Dashboard *Cache hit rate* tile and `STATS;` show whether it is big +enough. + +Two older names are refused at startup, whether they come from `hstore.conf`, the environment or a flag: +`cache_mb`, the name this setting had before it was released, and `cache_nodes`, a node count. Both messages name +`node_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 d524e83..eda20de 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_MB=1024`, +`HSTORE_LOG_STATEMENT=ddl`, `HSTORE_LOG_MIN_DURATION_MS=250`, `HSTORE_NODE_CACHE_MB=256`, `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/storage/pages.md b/docs/storage/pages.md index 87b1ea4..4a2a3d1 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 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 byte budget of `max(64 KiB, node_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. From 30f84b353a1426d92cc4884412d7abc1e66d2565 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sat, 10 Oct 2026 00:08:33 +0530 Subject: [PATCH 05/10] fix(engine): count cached nodes at their heap size, not their page size node_cache_mb counted each node as the size of its page, but a decoded node takes three to six times that on the heap. At scale 4 a setting of 256 kept 1.1 to 1.3 GiB, so the setting said little about memory. Node.heapBytes estimates the decoded size from the node itself: a fixed 144 bytes for the node and its summary, its arrays, 96 bytes plus the encoded value for each leaf entry, and 128 bytes per child reference. The constants come from comparing the retained heap with the cache's contents after the benchmark's read workloads. The estimate came out 10 to 40% under the measured heap at scales 1 and 4, against 3.6 to 5.8 times under before. --- engine/src/main/java/io/hstore/engine/tree/Branch.java | 8 ++++++++ engine/src/main/java/io/hstore/engine/tree/Leaf.java | 8 ++++++++ engine/src/main/java/io/hstore/engine/tree/Node.java | 5 +++++ .../main/java/io/hstore/engine/tree/PagedNodeSource.java | 7 +++---- .../java/io/hstore/engine/txn/TransactionManager.java | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/engine/src/main/java/io/hstore/engine/tree/Branch.java b/engine/src/main/java/io/hstore/engine/tree/Branch.java index 56a9604..0035093 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Branch.java +++ b/engine/src/main/java/io/hstore/engine/tree/Branch.java @@ -4,6 +4,8 @@ public final class Branch extends Node { + private static final int CHILD_HEAP_BYTES = 128; + private long[] separators; private Ref[] children; private int size; @@ -175,6 +177,12 @@ public int height() { return height; } + @Override + int heapBytes() { + return NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * separators.length + Integer.BYTES * children.length + + CHILD_HEAP_BYTES * size; + } + @Override int size() { return size; diff --git a/engine/src/main/java/io/hstore/engine/tree/Leaf.java b/engine/src/main/java/io/hstore/engine/tree/Leaf.java index f06facf..68d34e0 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Leaf.java +++ b/engine/src/main/java/io/hstore/engine/tree/Leaf.java @@ -8,6 +8,8 @@ public final class Leaf extends Node { + private static final int ENTRY_HEAP_BYTES = 96; + private long[] keys; private Object[] values; private int size; @@ -53,6 +55,12 @@ private void remeasure() { } } + @Override + int heapBytes() { + return NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * keys.length + Integer.BYTES * values.length + + ENTRY_HEAP_BYTES * size + valueBytes; + } + int search(long key) { return Arrays.binarySearch(keys, 0, size, key); } diff --git a/engine/src/main/java/io/hstore/engine/tree/Node.java b/engine/src/main/java/io/hstore/engine/tree/Node.java index a8966f1..3c9f781 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Node.java +++ b/engine/src/main/java/io/hstore/engine/tree/Node.java @@ -2,6 +2,9 @@ public sealed abstract class Node permits Leaf, Branch { + static final int NODE_HEAP_BYTES = 144; + static final int ARRAY_HEAP_BYTES = 16; + final TreeSchema schema; final Object owner; private volatile Summary summary; @@ -40,4 +43,6 @@ final boolean writableBy(WriteScope scope) { public abstract int height(); abstract int size(); + + abstract int heapBytes(); } 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 039ddb1..b77fb1c 100644 --- a/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java +++ b/engine/src/main/java/io/hstore/engine/tree/PagedNodeSource.java @@ -2,7 +2,6 @@ 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; @@ -32,12 +31,12 @@ public Node load(long pageId, TreeSchema schema) { IoTrace.recordRead(); MemorySegment page = store.read(pageId); Node node = NodeCodec.decode(page, pageId, schema); - cache.put(pageId, node, PageHeader.SIZE + PageHeader.payloadLength(page)); + cache.put(pageId, node, node.heapBytes()); return node; } - public void admit(long pageId, Node frozen, int bytes) { - cache.put(pageId, frozen, bytes); + public void admit(long pageId, Node frozen) { + cache.put(pageId, frozen, frozen.heapBytes()); } @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 f1d2d23..443f9bc 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, Math.toIntExact(image.byteSize())); + storage.source().admit(pageId, frozen); } }, epoch, storage.pages().pageSize()); } From 79aec4f5197d9c1717d95d5d11e8ca6bc1e8e787 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sat, 10 Oct 2026 00:08:33 +0530 Subject: [PATCH 06/10] docs: explain how node_cache_mb counts decoded nodes --- docs/architecture/overview.md | 2 +- docs/operations/configuration.md | 8 +++++--- docs/storage/pages.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index f3122d4..2fd7cf3 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 a total byte budget of `node_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). +* 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 `node_cache_mb`, each node counted at an estimate of its heap size (`Node.heapBytes`). 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/operations/configuration.md b/docs/operations/configuration.md index 4d9d88c..60cdc71 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). | -| `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `64` | Megabytes of decoded tree nodes kept on the Java heap, each counted at the size of its page. | +| `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `64` | Megabytes of Java heap for decoded tree nodes, each counted at an estimate of the memory it takes. | | `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. | @@ -141,8 +141,10 @@ for analytics over giant hyperedges. The value cannot be changed after `init`. **`node_cache_mb`.** This is the only cache HStore sizes itself, and it holds *decoded* nodes on the Java heap. Raw pages are cached by the operating system like any other file data, in memory the OS can take back when it -needs to. 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. +needs to. Each node is counted at an estimate of its size on the heap, worked out from its entry count and the +encoded size of its values, so the setting is close to the heap the cache really takes. Measured against the +heap it was 10 to 40% low, never above. A decoded node is three to six times the size of its page, so a cache +counted in page bytes would have used several times its setting. The server binary uses the serial collector, and every full collection walks everything this cache holds, so a bigger cache means longer pauses. Size it for what every lookup touches: the interior nodes, about 2% of the data, diff --git a/docs/storage/pages.md b/docs/storage/pages.md index 4a2a3d1..9a11eec 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 byte budget of `max(64 KiB, node_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 byte budget of `max(64 KiB, node_cache_mb / 16)` and a circular, doubly linked ring of slots with a CLOCK hand. Each slot weighs `Node.heapBytes()`, an estimate of the decoded node's heap size: 144 bytes for the node and its summary, its key and value or child arrays, then 96 bytes plus the encoded value per leaf entry, or 128 bytes per child reference in a branch. Those constants come from measuring retained heap against cache contents at two dataset sizes; * 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. From f41999954ddd0a494a616e9a187deadbf2ca73ee Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sat, 10 Oct 2026 18:13:42 +0530 Subject: [PATCH 07/10] perf: default node_cache_mb to 256 now that it counts heap With nodes counted at their heap size, 64 would hold about a quarter of what the old default of 64 held, because that one was really using about 250 MB of heap. 256 keeps the same memory and the same read speed, and the number now says what it uses. The setting's help text says so too. --- docs/operations/configuration.md | 5 +++-- docs/operations/docker.md | 2 +- engine/src/main/java/io/hstore/engine/EngineOptions.java | 2 +- server/src/main/java/io/hstore/server/Setting.java | 2 +- server/src/test/java/io/hstore/server/ServerTest.java | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 60cdc71..c3c10f1 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). | -| `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `64` | Megabytes of Java heap for decoded tree nodes, each counted at an estimate of the memory it takes. | +| `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `256` | Megabytes of Java heap for decoded tree nodes, each counted at an estimate of the memory it takes. | | `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. | @@ -144,7 +144,8 @@ Raw pages are cached by the operating system like any other file data, in memory needs to. Each node is counted at an estimate of its size on the heap, worked out from its entry count and the encoded size of its values, so the setting is close to the heap the cache really takes. Measured against the heap it was 10 to 40% low, never above. A decoded node is three to six times the size of its page, so a cache -counted in page bytes would have used several times its setting. +counted in page bytes would have used several times its setting. The default of 256 is about the heap the earlier +default of 64 really used when nodes were counted at their page size. The server binary uses the serial collector, and every full collection walks everything this cache holds, so a bigger cache means longer pauses. Size it for what every lookup touches: the interior nodes, about 2% of the data, diff --git a/docs/operations/docker.md b/docs/operations/docker.md index eda20de..083ab88 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_NODE_CACHE_MB=256`, +`HSTORE_LOG_STATEMENT=ddl`, `HSTORE_LOG_MIN_DURATION_MS=250`, `HSTORE_NODE_CACHE_MB=512`, `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/engine/src/main/java/io/hstore/engine/EngineOptions.java b/engine/src/main/java/io/hstore/engine/EngineOptions.java index 0003816..bc0ce6b 100644 --- a/engine/src/main/java/io/hstore/engine/EngineOptions.java +++ b/engine/src/main/java/io/hstore/engine/EngineOptions.java @@ -41,7 +41,7 @@ public record EngineOptions( } public static EngineOptions defaults() { - return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 64L << 20, 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); } diff --git a/server/src/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index 447bb74..919b1ff 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -21,7 +21,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"), - NODE_CACHE_MB("node_cache_mb", "64", "megabytes of decoded tree nodes kept on the heap, each counted at the size of its page"), + NODE_CACHE_MB("node_cache_mb", "256", "megabytes of heap for decoded tree nodes, each counted at an estimate of its size"), 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/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index b845eaf..e993a8f 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -114,7 +114,7 @@ void wireProtocolRunsOverTls() throws Exception { @Test void nodeCacheIsSizedInMegabytesAndOldNamesAreRejected(@TempDir Path data) throws Exception { assertEquals(512L << 20, ServerConfig.load(Optional.empty(), Map.of("HSTORE_NODE_CACHE_MB", "512"), Map.of()).engineOptions().nodeCacheBytes()); - assertEquals(64L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); + assertEquals(256L << 20, ServerConfig.load(Optional.empty(), Map.of(), Map.of()).engineOptions().nodeCacheBytes()); List failures = new ArrayList<>(); for (String old : List.of("cache_nodes", "cache_mb")) { Files.writeString(data.resolve(ServerConfig.FILE), old + " = 4096\n"); From 76c71dcfc81efecd968d7f898d8fe4df36b51335 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 12:55:42 +0530 Subject: [PATCH 08/10] fix(engine): use fewer node cache shards when the budget is small Each of the 16 shards got a sixteenth of the budget, and a node heavier than its shard was never cached. With a small node_cache_mb and large pages that silently skipped every interior node. The cache now picks as many shards as leave room for 64 pages each, from 1 up to 16. --- .../java/io/hstore/engine/StorageEngine.java | 2 +- .../java/io/hstore/engine/tree/NodeCache.java | 20 +++++++++----- .../io/hstore/engine/tree/NodeCacheTest.java | 27 +++++++++++++++---- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/engine/src/main/java/io/hstore/engine/StorageEngine.java b/engine/src/main/java/io/hstore/engine/StorageEngine.java index c90cf4b..1918ec3 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.nodeCacheBytes()); + this.cache = new NodeCache(options.nodeCacheBytes(), options.pageSize()); 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 01e79e3..3508f93 100644 --- a/engine/src/main/java/io/hstore/engine/tree/NodeCache.java +++ b/engine/src/main/java/io/hstore/engine/tree/NodeCache.java @@ -6,20 +6,26 @@ public final class NodeCache { - private static final int SHARDS = 16; - private static final long MIN_SHARD_BYTES = 64 * 1024; + private static final int MAX_SHARDS = 16; + private static final long PAGES_PER_SHARD = 64; - private final Shard[] shards = new Shard[SHARDS]; + private final Shard[] shards; private final LongAdder hits = new LongAdder(); private final LongAdder misses = new LongAdder(); - public NodeCache(long capacityBytes) { - long perShard = Math.max(MIN_SHARD_BYTES, capacityBytes / SHARDS); - for (int i = 0; i < SHARDS; i++) { + public NodeCache(long capacityBytes, int pageSize) { + shards = new Shard[shardsFor(capacityBytes, pageSize)]; + long perShard = Math.max(1, capacityBytes / shards.length); + for (int i = 0; i < shards.length; i++) { shards[i] = new Shard(perShard); } } + static int shardsFor(long capacityBytes, int pageSize) { + long fit = capacityBytes / (pageSize * PAGES_PER_SHARD); + return Math.clamp(Long.highestOneBit(fit), 1, MAX_SHARDS); + } + public Node get(long pageId) { Node node = shard(pageId).get(pageId); (node == null ? misses : hits).increment(); @@ -53,7 +59,7 @@ public long residentBytes() { } private Shard shard(long pageId) { - return shards[(int) (Hashing.mix(pageId) >>> 60) & (SHARDS - 1)]; + return shards[(int) (Hashing.mix(pageId) >>> 60) & (shards.length - 1)]; } private static final class Slot { diff --git a/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java b/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java index da27319..5a7d77a 100644 --- a/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java +++ b/engine/src/test/java/io/hstore/engine/tree/NodeCacheTest.java @@ -13,6 +13,8 @@ class NodeCacheTest { + private static final int PAGE = 16 * 1024; + private static Node node(long key) { return Leaf.owned(TreeTest.LONGS, null, new long[]{key}, new Object[]{key}, 1); } @@ -20,7 +22,7 @@ private static Node node(long key) { @Test void residentBytesStayWithinTheBudget() { long budget = 1 << 20; - NodeCache cache = new NodeCache(budget); + NodeCache cache = new NodeCache(budget, PAGE); 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)); @@ -34,7 +36,7 @@ void residentBytesStayWithinTheBudget() { @Test void pagesThatKeepBeingReadSurviveColdInserts() { - NodeCache cache = new NodeCache(1 << 20); + NodeCache cache = new NodeCache(1 << 20, PAGE); long[] hot = LongStream.range(0, 32).toArray(); for (long page : hot) { cache.put(page, node(page), 4096); @@ -52,15 +54,15 @@ void pagesThatKeepBeingReadSurviveColdInserts() { @Test void aNodeLargerThanItsShardIsNotCached() { - NodeCache cache = new NodeCache(1 << 20); - cache.put(7, node(7), 128 * 1024); + NodeCache cache = new NodeCache(1 << 20, PAGE); + cache.put(7, node(7), (1 << 20) + 1); assertNull(cache.get(7)); assertEquals(0, cache.residentBytes()); } @Test void puttingAPageAgainReplacesItAndItsSize() { - NodeCache cache = new NodeCache(1 << 20); + NodeCache cache = new NodeCache(1 << 20, PAGE); Node first = node(1); Node second = node(2); cache.put(9, first, 1_000); @@ -71,4 +73,19 @@ void puttingAPageAgainReplacesItAndItsSize() { assertEquals(0, cache.residentBytes()); assertNull(cache.get(9)); } + + @Test + void aNodeAsLargeAsAPageIsCachedEvenWhenTheBudgetIsSmall() { + NodeCache cache = new NodeCache(2 << 20, 1 << 20); + Node node = node(1); + cache.put(1, node, 3 << 19); + assertSame(node, cache.get(1)); + } + + @Test + void largeBudgetsAreSplitIntoShards() { + assertEquals(16, NodeCache.shardsFor(256L << 20, PAGE)); + assertEquals(1, NodeCache.shardsFor(1 << 20, PAGE)); + assertEquals(1, NodeCache.shardsFor(1, PAGE)); + } } From 91c80f1e4488af18c7ae6ecd7be83286ca7e4942 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 12:55:42 +0530 Subject: [PATCH 09/10] fix(server): refuse node_cache_mb values outside 1 to 16 TiB A huge value overflowed when it was shifted into bytes, and 0 failed later with an engine message that didn't name the setting. --- docs/operations/configuration.md | 2 +- .../src/main/java/io/hstore/server/ServerConfig.java | 11 ++++++++++- server/src/test/java/io/hstore/server/ServerTest.java | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index c3c10f1..be4b152 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -152,7 +152,7 @@ bigger cache means longer pauses. Size it for what every lookup touches: the int plus the leaves you read all the time. The Dashboard *Cache hit rate* tile and `STATS;` show whether it is big enough. -Two older names are refused at startup, whether they come from `hstore.conf`, the environment or a flag: +The value must be between 1 and 16,777,216 (16 TiB). Two older names are refused at startup, whether they come from `hstore.conf`, the environment or a flag: `cache_mb`, the name this setting had before it was released, and `cache_nodes`, a node count. Both messages name `node_cache_mb`. diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 9413d0f..39d9017 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -25,6 +25,7 @@ final class ServerConfig { static final String FILE = "hstore.conf"; + private static final long MAX_MEGABYTES = 1L << 24; enum Source { DEFAULT, FILE, ENVIRONMENT, COMMAND_LINE } @@ -95,6 +96,14 @@ long number(Setting setting) { return Long.parseLong(values.get(setting)); } + long megabytes(Setting setting) { + long megabytes = number(setting); + if (megabytes < 1 || megabytes > MAX_MEGABYTES) { + throw new IllegalArgumentException("%s must be between 1 and %,d, not %d".formatted(setting.key(), MAX_MEGABYTES, megabytes)); + } + return megabytes << 20; + } + boolean flag(Setting setting) { return switch (values.get(setting).toLowerCase()) { case "on", "true", "yes", "1" -> true; @@ -112,7 +121,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) - .withNodeCacheBytes(number(Setting.NODE_CACHE_MB) << 20) + .withNodeCacheBytes(megabytes(Setting.NODE_CACHE_MB)) .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/test/java/io/hstore/server/ServerTest.java b/server/src/test/java/io/hstore/server/ServerTest.java index e993a8f..66d7cfe 100644 --- a/server/src/test/java/io/hstore/server/ServerTest.java +++ b/server/src/test/java/io/hstore/server/ServerTest.java @@ -124,6 +124,10 @@ void nodeCacheIsSizedInMegabytesAndOldNamesAreRejected(@TempDir Path data) throw failures.add(assertThrows(IllegalArgumentException.class, () -> ServerConfig.load(Optional.empty(), Map.of(), Map.of(old.replace('_', '-'), "4096")))); } + for (String outOfRange : List.of("0", "-1", "99999999999999")) { + failures.add(assertThrows(IllegalArgumentException.class, + () -> ServerConfig.load(Optional.empty(), Map.of("HSTORE_NODE_CACHE_MB", outOfRange), Map.of()).engineOptions())); + } failures.forEach(failure -> assertTrue(failure.getMessage().contains("node_cache_mb"), failure.getMessage())); } From 64b84e938da493b9a6da770335e20ed242a6efb0 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 12:55:42 +0530 Subject: [PATCH 10/10] fix(bench): give the HStore node cache half the heap by default Now that nodes count at their heap size, a 16 GiB cap could never bind before a 4 GiB heap ran out. The docs also describe the cap and the estimate as they are now. --- .../src/main/java/io/hstore/bench/HStoreStore.java | 2 +- docs/benchmarks.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java index 6f62ea1..a984d19 100644 --- a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java +++ b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java @@ -32,7 +32,7 @@ final class HStoreStore implements Store { private long writtenBeforeReopen; private long readBeforeReopen; - static final long DEFAULT_CACHE_BYTES = (1L << 20) * 16 * 1024; + static final long DEFAULT_CACHE_BYTES = Runtime.getRuntime().maxMemory() / 2; private final int history; diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 46ae359..131c206 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 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 | +| Cache | node cache of half the heap, 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 | @@ -251,7 +251,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 the node cache), costs more than JE's latch-free cache hits. At scale 1 + churn (the hot set exceeded the 1,048,576-node cache these runs used), 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 @@ -279,16 +279,16 @@ 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's node cache gets 16 GiB, which 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 half the heap +(2 GiB with the default `HEAP=4g`). HyperGraphDB gets JE's default of 30% of the heap. `CACHE_MB` gives both the same budget: | HStore | HyperGraphDB | |---|---| -| node cache of `CACHE_MB`, each node counted at the size of its page | JE cache of exactly `CACHE_MB` | +| node cache of `CACHE_MB`, each node counted at an estimate of its heap size | JE cache of exactly `CACHE_MB` | -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 +HStore's estimate runs 10 to 40% below the heap a node really takes, 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-`. ```