diff --git a/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java b/benchmarks/src/main/java/io/hstore/bench/HStoreStore.java index 9dd9c3f..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; @@ -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/architecture/overview.md b/docs/architecture/overview.md index 4c05555..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 `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/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-`. ``` diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index ec398c4..be4b152 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` | `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. | @@ -139,13 +139,22 @@ 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 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 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, +plus the leaves you read all the time. The Dashboard *Cache hit rate* tile and `STATS;` show whether it is big +enough. + +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`. **`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..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_CACHE_MB=1024`, +`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/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/docs/storage/pages.md b/docs/storage/pages.md index 87b1ea4..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, 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. 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..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.cacheBytes()); + 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/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/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/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()); } 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)); + } } diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 6568fb2..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) - .withCacheBytes(number(Setting.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/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index 208a6c9..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"), - 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 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"), @@ -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/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..66d7cfe 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,23 @@ 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())); + 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(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"); + 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")))); + } + 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())); } @Test