From 0f63938a7744d11fa98915cc38b9f0f89119bc12 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 16:51:52 +0530 Subject: [PATCH 1/4] feat(engine): tag tree references and let a small leaf live inside one A stored reference used to start with a raw page id, with page id 0 meaning an empty tree. References now start with a one byte tag (empty, stored, inline), and the new inline variant carries a small frozen leaf directly in the parent value instead of pointing at a page. Inline leaves are limited to 32 entries, no nested trees, and an encoded size no bigger than a stored reference can take, so a parent leaf that budgeted for a reference can never overflow. Inline references compare by their entries, not by identity, and a catalog leaf counts the heap of the inline leaves it holds. Nothing creates inline references yet. The storage format moves to 6 because every reference on disk gained the tag byte. --- .../java/io/hstore/engine/StorageEngine.java | 4 +- .../io/hstore/engine/catalog/AtomCodec.java | 12 ++- .../main/java/io/hstore/engine/tree/Leaf.java | 32 ++++++- .../java/io/hstore/engine/tree/NodeCodec.java | 31 +++++-- .../main/java/io/hstore/engine/tree/Ref.java | 90 +++++++++++++++++-- .../io/hstore/engine/tree/TreeVerifier.java | 5 +- .../io/hstore/engine/tree/TreeWalker.java | 9 +- .../java/io/hstore/engine/tree/Trees.java | 1 + .../io/hstore/engine/tree/ValueCodec.java | 17 +++- .../java/io/hstore/engine/EngineTest.java | 10 +++ .../java/io/hstore/engine/tree/TreeTest.java | 75 ++++++++++++++++ 11 files changed, 256 insertions(+), 30 deletions(-) diff --git a/engine/src/main/java/io/hstore/engine/StorageEngine.java b/engine/src/main/java/io/hstore/engine/StorageEngine.java index c1f0f26..35208f1 100644 --- a/engine/src/main/java/io/hstore/engine/StorageEngine.java +++ b/engine/src/main/java/io/hstore/engine/StorageEngine.java @@ -42,7 +42,7 @@ public final class StorageEngine implements AutoCloseable { private static final System.Logger LOG = System.getLogger("hstore.engine"); - private static final String FORMAT_VERSION = "5"; + private static final String FORMAT_VERSION = "6"; private static final int CHANGED_COUNTS_LIMIT = 1 << 20; private static final int MAX_ATTEMPTS = 8; @@ -132,7 +132,7 @@ private static void verifyFormat(Path directory, EngineOptions options) throws I } if (!format.getProperty("format", "1").equals(FORMAT_VERSION)) { throw HStoreException.invalid("database " + directory + " uses storage format " + format.getProperty("format", "1") - + "; this build reads format " + FORMAT_VERSION + " (reference counts); export and reload it"); + + "; this build reads format " + FORMAT_VERSION + " (inline member trees); export and reload it"); } int stored = Integer.parseInt(format.getProperty("page-size")); if (stored != options.pageSize()) { diff --git a/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java b/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java index 3b4727b..557bb31 100644 --- a/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java +++ b/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java @@ -69,8 +69,8 @@ private static AtomRecord read(ByteCursor in) { return new NodeRecord(type, key, in.getVarLong(), in.getVarLong(), in.getVarInt(), in.getVarInt(), in.getVarInt()); } EdgeKind kind = EdgeKind.values()[in.getUnsignedByte()]; - Ref members = Ref.read(in); - Ref order = Ref.read(in); + Ref members = Ref.read(in, TopologySchemas.members(kind)); + Ref order = Ref.read(in, TopologySchemas.ORDER_INDEX); return new EdgeRecord(type, kind, members, order, in.getVarLong(), in.getVarLong(), in.getVarInt(), in.getVarInt()); } @@ -79,6 +79,14 @@ public boolean holdsRefs() { return true; } + @Override + public void forEachRef(AtomRecord record, RefVisitor visitor) { + if (record instanceof EdgeRecord edge) { + visitor.visit(edge.members(), TopologySchemas.members(edge.kind())); + visitor.visit(edge.order(), TopologySchemas.ORDER_INDEX); + } + } + @Override public boolean refersToPages(AtomRecord record) { return record instanceof EdgeRecord edge && (edge.members() instanceof Ref.Stored || edge.order() instanceof Ref.Stored); 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 79dae89..ff9e6de 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,7 @@ import java.util.AbstractList; import java.util.Arrays; import java.util.List; +import java.util.Objects; public final class Leaf extends Node { @@ -60,8 +61,37 @@ private void remeasure() { @Override int heapBytes() { - return NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * keys.length + Integer.BYTES * values.length + int bytes = NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * keys.length + Integer.BYTES * values.length + ENTRY_HEAP_BYTES * size + valueBytes; + return schema.codec().holdsRefs() ? bytes + inlineHeapBytes(schema) : bytes; + } + + private int inlineHeapBytes(TreeSchema typed) { + int[] total = new int[1]; + ValueCodec.RefVisitor visitor = (ref, _) -> { + if (ref instanceof Ref.Inline(Leaf inline)) { + total[0] += inline.heapBytes(); + } + }; + for (int i = 0; i < size; i++) { + typed.codec().forEachRef(typed.cast(values[i]), visitor); + } + return total[0]; + } + + boolean sameEntries(Leaf other) { + if (this == other) { + return true; + } + if (schema.id() != other.schema.id() || size != other.size || !Arrays.equals(keys, 0, size, other.keys, 0, size)) { + return false; + } + for (int i = 0; i < size; i++) { + if (!Objects.equals(value(i), other.value(i))) { + return false; + } + } + return true; } int search(long key) { diff --git a/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java b/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java index 75908f6..7d08966 100644 --- a/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java +++ b/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java @@ -54,6 +54,31 @@ private static void encodeLeaf(ByteCursor out, Leaf leaf) { encodeValues(out, leaf.schema, keys, leaf); } + static void encodeInline(ByteCursor out, Leaf leaf) { + out.putVarLong(leaf.size()); + encodeLeaf(out, leaf); + } + + static Leaf decodeInline(ByteCursor in, TreeSchema schema) { + int count = Math.toIntExact(in.getVarLong()); + if (count < 1 || count > Ref.INLINE_MAX_ENTRIES) { + throw new IllegalStateException("inline tree of " + count + " entries in " + schema); + } + long[] keys = decodeKeys(in, count); + Object[] values = new Object[count]; + schema.codec().decode(in, keys, values); + return Leaf.decoded(schema, keys, values, null); + } + + private static long[] decodeKeys(ByteCursor in, int count) { + long[] keys = new long[count]; + keys[0] = in.getSignedVarLong(); + for (int i = 1; i < count; i++) { + keys[i] = keys[i - 1] + in.getVarLong(); + } + return keys; + } + private static void encodeValues(ByteCursor out, TreeSchema schema, long[] keys, Leaf leaf) { @SuppressWarnings("unchecked") List values = (List) leaf.valueList(); @@ -98,11 +123,7 @@ public static Node decode(MemorySegment page, PageHeader header, long pageId, Tr in.getSignedVarLong(), in.getSignedVarLong(), in.getSignedVarLong(), in.getSignedVarLong(), in.getSignedVarLong(), in.getLong(), header.fingerprint()); int count = header.slotCount(); - long[] keys = new long[count]; - keys[0] = in.getSignedVarLong(); - for (int i = 1; i < count; i++) { - keys[i] = keys[i - 1] + in.getVarLong(); - } + long[] keys = decodeKeys(in, count); return switch (header.type()) { case LEAF -> { Object[] values = new Object[count]; diff --git a/engine/src/main/java/io/hstore/engine/tree/Ref.java b/engine/src/main/java/io/hstore/engine/tree/Ref.java index 502df75..a2751bd 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Ref.java +++ b/engine/src/main/java/io/hstore/engine/tree/Ref.java @@ -12,6 +12,14 @@ public sealed interface Ref { int MAX_UNITS_BYTES = 3; + int INLINE_MAX_ENTRIES = 32; + + int EMPTY_TAG = 0; + + int STORED_TAG = 1; + + int INLINE_TAG = 2; + Summary summary(); long count(); @@ -52,40 +60,108 @@ public long count() { } } + record Inline(Leaf leaf) implements Ref { + public Inline { + if (leaf.owner != null || !fitsInline(leaf)) { + throw new IllegalArgumentException("only a small frozen leaf without nested trees can be inline, not " + leaf.size() + + " entries of " + leaf.schema); + } + } + + @Override + public Summary summary() { + return leaf.summary(); + } + + @Override + public long count() { + return leaf.count(); + } + + @Override + public boolean equals(Object other) { + return other instanceof Inline(Leaf that) && leaf.sameEntries(that); + } + + @Override + public int hashCode() { + return Long.hashCode(leaf.summary().fingerprint()) * 31 + leaf.size(); + } + + @Override + public String toString() { + return "Inline[" + leaf.schema + ", n=" + leaf.size() + "]"; + } + } + static boolean same(Ref a, Ref b) { return switch (a) { case Empty _ -> b instanceof Empty; case Stored(long page, _, _) -> b instanceof Stored(long other, _, _) && page == other; case Pending(Node node) -> b instanceof Pending(Node other) && node == other; + case Inline inline -> inline.equals(b); }; } + static boolean fitsInline(Leaf leaf) { + return leaf.size() > 0 && leaf.size() <= INLINE_MAX_ENTRIES && !leaf.schema.codec().holdsRefs() + && inlineSize(leaf) <= maxEncodedSize(); + } + static void write(ByteCursor out, Ref ref) { switch (ref) { - case Empty _ -> out.putLong(PageId.NONE); + case Empty _ -> out.putByte(EMPTY_TAG); case Stored(long pageId, int units, Summary summary) -> { - out.putLong(pageId); - out.putVarLong(units); + out.putByte(STORED_TAG).putLong(pageId).putVarLong(units); summary.writeTo(out); } + case Inline(Leaf leaf) -> { + out.putByte(INLINE_TAG); + NodeCodec.encodeInline(out, leaf); + } case Pending _ -> throw new IllegalStateException("pending reference cannot be encoded"); } } static Ref read(ByteCursor in) { + int tag = in.getUnsignedByte(); + return switch (tag) { + case EMPTY_TAG -> EMPTY; + case STORED_TAG -> readStored(in); + case INLINE_TAG -> throw new IllegalStateException("an inline tree can only be read where its schema is known"); + default -> throw new IllegalStateException("unknown tree reference tag " + tag); + }; + } + + static Ref read(ByteCursor in, TreeSchema schema) { + int tag = in.getUnsignedByte(); + return switch (tag) { + case EMPTY_TAG -> EMPTY; + case STORED_TAG -> readStored(in); + case INLINE_TAG -> new Inline(NodeCodec.decodeInline(in, schema)); + default -> throw new IllegalStateException("unknown tree reference tag " + tag); + }; + } + + private static Ref readStored(ByteCursor in) { long pageId = in.getLong(); - return pageId == PageId.NONE ? EMPTY : new Stored(pageId, Math.toIntExact(in.getVarLong()), Summary.readFrom(in)); + return new Stored(pageId, Math.toIntExact(in.getVarLong()), Summary.readFrom(in)); } static int encodedSize(Ref ref) { return switch (ref) { - case Empty _ -> 8; - case Stored(long _, int units, Summary summary) -> 8 + ByteCursor.varLongSize(units) + summary.encodedSize(); + case Empty _ -> 1; + case Stored(long _, int units, Summary summary) -> 1 + 8 + ByteCursor.varLongSize(units) + summary.encodedSize(); + case Inline(Leaf leaf) -> inlineSize(leaf); case Pending _ -> maxEncodedSize(); }; } static int maxEncodedSize() { - return 8 + MAX_UNITS_BYTES + Summary.MAX_ENCODED_BYTES; + return 1 + 8 + MAX_UNITS_BYTES + Summary.MAX_ENCODED_BYTES; + } + + private static int inlineSize(Leaf leaf) { + return 1 + ByteCursor.varLongSize(leaf.size()) + leaf.bytes(); } } diff --git a/engine/src/main/java/io/hstore/engine/tree/TreeVerifier.java b/engine/src/main/java/io/hstore/engine/tree/TreeVerifier.java index 6914582..2714d51 100644 --- a/engine/src/main/java/io/hstore/engine/tree/TreeVerifier.java +++ b/engine/src/main/java/io/hstore/engine/tree/TreeVerifier.java @@ -93,10 +93,7 @@ private Report nested(TreeSchema schema, Object value) { return NOTHING; } List nested = new ArrayList<>(); - schema.codec().mapRefs(schema.cast(value), (root, nestedSchema) -> { - nested.add(new Nested(root, nestedSchema)); - return root; - }); + schema.codec().forEachRef(schema.cast(value), (root, nestedSchema) -> nested.add(new Nested(root, nestedSchema))); Report total = NOTHING; for (Nested tree : nested) { total = total.plus(verify(tree.root(), tree.schema())).plus(new Report(0, 0, tree.root() instanceof Ref.Empty ? 0 : 1, 0)); diff --git a/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java b/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java index b0bc36c..b8bc6a9 100644 --- a/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java +++ b/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java @@ -29,14 +29,13 @@ public void references(long pageId, TreeSchema schema, int height, Reference switch (node) { case Leaf leaf -> { if (schema.codec().holdsRefs()) { - ValueCodec.RefMapper mapper = (nested, nestedSchema) -> { + ValueCodec.RefVisitor visitor = (nested, nestedSchema) -> { if (nested instanceof Ref.Stored(long child, int _, Summary _)) { into.accept(child, nestedSchema, UNKNOWN_HEIGHT); } - return nested; }; for (int i = 0; i < leaf.size(); i++) { - nested(schema, leaf.value(i), mapper); + nested(schema, leaf.value(i), visitor); } } } @@ -50,7 +49,7 @@ public void references(long pageId, TreeSchema schema, int height, Reference } } - private static void nested(TreeSchema schema, Object value, ValueCodec.RefMapper mapper) { - schema.codec().mapRefs(schema.cast(value), mapper); + private static void nested(TreeSchema schema, Object value, ValueCodec.RefVisitor visitor) { + schema.codec().forEachRef(schema.cast(value), visitor); } } diff --git a/engine/src/main/java/io/hstore/engine/tree/Trees.java b/engine/src/main/java/io/hstore/engine/tree/Trees.java index cf7b270..bccc5a1 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Trees.java +++ b/engine/src/main/java/io/hstore/engine/tree/Trees.java @@ -14,6 +14,7 @@ private Trees() { static Node node(NodeSource source, TreeSchema schema, Ref ref) { return switch (ref) { case Ref.Pending(Node node) -> node; + case Ref.Inline(Leaf leaf) -> leaf; case Ref.Stored(long pageId, int _, Summary _) -> source.load(pageId, schema); case Ref.Empty _ -> throw new IllegalStateException("empty reference has no node"); }; diff --git a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java index ef60d3c..8ae54bd 100644 --- a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java +++ b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java @@ -30,12 +30,16 @@ default V mapRefs(V value, RefMapper mapper) { return value; } - default boolean refersToPages(V value) { - boolean[] found = new boolean[1]; - mapRefs(value, (ref, _) -> { - found[0] |= ref instanceof Ref.Stored; + default void forEachRef(V value, RefVisitor visitor) { + mapRefs(value, (ref, schema) -> { + visitor.visit(ref, schema); return ref; }); + } + + default boolean refersToPages(V value) { + boolean[] found = new boolean[1]; + forEachRef(value, (ref, _) -> found[0] |= ref instanceof Ref.Stored); return found[0]; } @@ -64,4 +68,9 @@ public void decode(ByteCursor in, long[] keys, Object[] into) { interface RefMapper { Ref map(Ref ref, TreeSchema schema); } + + @FunctionalInterface + interface RefVisitor { + void visit(Ref ref, TreeSchema schema); + } } diff --git a/engine/src/test/java/io/hstore/engine/EngineTest.java b/engine/src/test/java/io/hstore/engine/EngineTest.java index 6ed9f71..3e26ec3 100644 --- a/engine/src/test/java/io/hstore/engine/EngineTest.java +++ b/engine/src/test/java/io/hstore/engine/EngineTest.java @@ -31,6 +31,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; @@ -603,4 +605,12 @@ void dictionaryInternsRolesAcrossRestarts() { assertEquals(List.of(roleSet), engine.dictionary().roleSetsContaining("buyer")); } } + + @Test + void directoriesFromTheReferenceCountFormatAreRefused() throws IOException { + Files.writeString(directory.resolve("FORMAT"), "format=5\npage-size=1024\n"); + HStoreException refused = assertThrows(HStoreException.class, () -> StorageEngine.open(directory, small()).close()); + assertTrue(refused.getMessage().endsWith("uses storage format 5; this build reads format 6 (inline member trees); export and reload it"), + refused.getMessage()); + } } diff --git a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java index f3d8eb1..4328201 100644 --- a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java +++ b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java @@ -9,6 +9,8 @@ import io.hstore.engine.page.PageHeader; import io.hstore.engine.page.PageId; import io.hstore.engine.topology.EdgeKind; +import io.hstore.engine.topology.Incidence; +import io.hstore.engine.topology.TopologySchemas; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -31,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class TreeTest { @@ -296,6 +299,78 @@ void aLeafSaysInItsHeaderWhetherItRefersToOtherPages() { } } + @Test + void inlineTreesRoundTripInsideTheRecordThatHoldsThem() { + Leaf members = memberLeaf(10, 20, 30); + AtomRecord edge = new AtomRecord.EdgeRecord(2, EdgeKind.SET, new Ref.Inline(members), Ref.EMPTY, 3, 0, 0, 0); + AtomRecord stored = new AtomRecord.EdgeRecord(2, EdgeKind.SET, new Ref.Stored(PageId.pack(7, 0), 1, Summary.EMPTY), Ref.EMPTY, 0, 0, 0, 0); + long pageId = PageId.pack(1, 0); + for (List values : List.of(List.of(edge), List.of(edge, stored))) { + Leaf leaf = Leaf.owned(EngineSlots.ATOM_SCHEMA, null, LongStream.range(0, values.size()).toArray(), values.toArray(), values.size()); + MemorySegment image = NodeCodec.encode(leaf, pageId, 1, 4096); + assertEquals(values.contains(stored), PageHeader.verify(image, pageId).refersToPages()); + Leaf decoded = (Leaf) NodeCodec.decode(image, pageId, EngineSlots.ATOM_SCHEMA); + assertEquals(values, decoded.valueList()); + AtomRecord.EdgeRecord read = (AtomRecord.EdgeRecord) decoded.value(0); + Ref.Inline inline = (Ref.Inline) read.members(); + assertTrue(Ref.same(new Ref.Inline(members), inline)); + assertEquals(members.summary(), inline.summary()); + assertEquals(List.of(10L, 20L, 30L), new Tree<>(TopologySchemas.SET_MEMBERS, NodeSource.ephemeral(4096), inline).keys().boxed().toList()); + } + } + + @Test + void inlineReferencesAreTheSameOnlyWhenTheirEntriesAre() { + Ref a = new Ref.Inline(memberLeaf(1, 2, 3)); + Ref b = new Ref.Inline(memberLeaf(1, 2, 3)); + Ref c = new Ref.Inline(memberLeaf(1, 2, 4)); + Leaf weighted = memberLeaf(1, 2, 3); + Ref d = new Ref.Inline(Leaf.decoded(TopologySchemas.SET_MEMBERS, weighted.keyArray(), + new Object[]{Incidence.of(1), Incidence.of(2), Incidence.of(3).withWeight(2.0)}, null)); + assertTrue(Ref.same(a, b)); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertFalse(Ref.same(a, c)); + assertFalse(Ref.same(a, d)); + assertFalse(Ref.same(a, new Ref.Pending(memberLeaf(1, 2, 3)))); + assertFalse(Ref.same(a, Ref.EMPTY)); + } + + @Test + void referencesCarryATagAndInlineTreesNeedTheirSchema() { + Ref inline = new Ref.Inline(memberLeaf(5, 6)); + Ref stored = new Ref.Stored(PageId.pack(9, 0), 2, memberLeaf(5, 6).summary()); + ByteCursor out = ByteCursor.growable(256); + for (Ref ref : List.of(Ref.EMPTY, stored, inline)) { + long start = out.position(); + Ref.write(out, ref); + assertTrue(out.position() - start <= Ref.encodedSize(ref)); + assertTrue(Ref.encodedSize(ref) <= Ref.maxEncodedSize()); + } + ByteCursor in = ByteCursor.wrap(out.toByteArray()); + assertEquals(Ref.EMPTY, Ref.read(in, TopologySchemas.SET_MEMBERS)); + assertEquals(stored, Ref.read(in, TopologySchemas.SET_MEMBERS)); + assertEquals(inline, Ref.read(in, TopologySchemas.SET_MEMBERS)); + ByteCursor schemaless = ByteCursor.wrap(out.toByteArray()); + Ref.read(schemaless); + Ref.read(schemaless); + assertThrows(IllegalStateException.class, () -> Ref.read(schemaless)); + } + + @Test + void onlySmallLeavesWithoutNestedTreesFitInline() { + assertTrue(Ref.fitsInline(memberLeaf(LongStream.rangeClosed(1, Ref.INLINE_MAX_ENTRIES).toArray()))); + assertFalse(Ref.fitsInline(memberLeaf(LongStream.rangeClosed(1, Ref.INLINE_MAX_ENTRIES + 1).toArray()))); + assertFalse(Ref.fitsInline(memberLeaf(LongStream.rangeClosed(1, 20).map(i -> i << 40).toArray()))); + Leaf catalog = Leaf.owned(EngineSlots.ATOM_SCHEMA, null, new long[]{1}, new Object[]{AtomRecord.NodeRecord.of(1, "n")}, 1); + assertFalse(Ref.fitsInline(catalog)); + } + + private static Leaf memberLeaf(long... members) { + Object[] values = Arrays.stream(members).mapToObj(Incidence::of).toArray(); + return Leaf.decoded(TopologySchemas.SET_MEMBERS, members.clone(), values, null); + } + private static Summary verify(Tree tree, Ref ref) { Node node = tree.node(ref); Summary.Builder builder = Summary.builder(tree.schema().fingerprint()); From 09a83df7bd8f7abf0c7e561663ea5ca21896c6a6 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 16:59:38 +0530 Subject: [PATCH 2/4] feat(engine): keep small member trees inline in their edge record When a commit writes a catalog leaf, a member or order tree that is a single leaf of at most 32 entries, small enough to fit where a stored reference would go, now stays in the edge record instead of getting a page of its own. A three member edge no longer costs an extra page, an extra cache entry and an extra read. Only codecs that opt in through inlinesSmallTrees get this, which today is just the catalog. Slot roots and branch children are still always written as pages. The page quota check skips trees that will be inlined. Growing past the limit turns the tree into a page on its next commit, and shrinking below it brings it back the next time its leaf is rewritten. Reference counts need no special handling since an inline tree has no page to count. Refs #55 --- .../io/hstore/engine/catalog/AtomCodec.java | 5 + .../io/hstore/engine/tree/Materializer.java | 27 +- .../io/hstore/engine/tree/ValueCodec.java | 4 + .../io/hstore/engine/InlineMembersTest.java | 481 ++++++++++++++++++ .../java/io/hstore/engine/tree/TreeTest.java | 32 ++ 5 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 engine/src/test/java/io/hstore/engine/InlineMembersTest.java diff --git a/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java b/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java index 557bb31..bfbbc07 100644 --- a/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java +++ b/engine/src/main/java/io/hstore/engine/catalog/AtomCodec.java @@ -79,6 +79,11 @@ public boolean holdsRefs() { return true; } + @Override + public boolean inlinesSmallTrees() { + return true; + } + @Override public void forEachRef(AtomRecord record, RefVisitor visitor) { if (record instanceof EdgeRecord edge) { diff --git a/engine/src/main/java/io/hstore/engine/tree/Materializer.java b/engine/src/main/java/io/hstore/engine/tree/Materializer.java index 698d1c3..4dfe978 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Materializer.java +++ b/engine/src/main/java/io/hstore/engine/tree/Materializer.java @@ -55,7 +55,8 @@ public static long pendingNodes(Ref ref, TreeSchema schema) { switch (node) { case Leaf leaf -> { if (leaf.schema.codec().holdsRefs()) { - leaf.valueList().forEach(value -> countNested(leaf.schema, value, count)); + boolean inlines = leaf.schema.codec().inlinesSmallTrees(); + leaf.valueList().forEach(value -> countNested(leaf.schema, value, inlines, count)); } } case Branch branch -> { @@ -67,25 +68,35 @@ public static long pendingNodes(Ref ref, TreeSchema schema) { return count[0]; } - private static void countNested(TreeSchema schema, Object value, long[] count) { - schema.codec().mapRefs(schema.cast(value), (nested, nestedSchema) -> { - count[0] += pendingNodes(nested, nestedSchema); - return nested; + private static void countNested(TreeSchema schema, Object value, boolean inlines, long[] count) { + schema.codec().forEachRef(schema.cast(value), (nested, nestedSchema) -> { + if (!(inlines && staysInline(nested))) { + count[0] += pendingNodes(nested, nestedSchema); + } }); } + private static boolean staysInline(Ref ref) { + return ref instanceof Ref.Pending(Leaf leaf) && Ref.fitsInline(leaf); + } + private Leaf freeze(Leaf leaf) { Object[] values = leaf.valueList().toArray(); if (leaf.schema.codec().holdsRefs()) { + ValueCodec.RefMapper nested = leaf.schema.codec().inlinesSmallTrees() ? this::inlineOrMaterialize : this::materialize; for (int i = 0; i < values.length; i++) { - values[i] = mapRefs(leaf.schema, values[i]); + values[i] = mapRefs(leaf.schema, values[i], nested); } } return leaf.frozenWith(values); } - private Object mapRefs(TreeSchema schema, Object value) { - return schema.codec().mapRefs(schema.cast(value), this::materialize); + private Ref inlineOrMaterialize(Ref ref, TreeSchema schema) { + return ref instanceof Ref.Pending(Leaf leaf) && Ref.fitsInline(leaf) ? new Ref.Inline(freeze(leaf)) : materialize(ref, schema); + } + + private static Object mapRefs(TreeSchema schema, Object value, ValueCodec.RefMapper mapper) { + return schema.codec().mapRefs(schema.cast(value), mapper); } private Branch freeze(Branch branch) { diff --git a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java index 8ae54bd..100d33d 100644 --- a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java +++ b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java @@ -30,6 +30,10 @@ default V mapRefs(V value, RefMapper mapper) { return value; } + default boolean inlinesSmallTrees() { + return false; + } + default void forEachRef(V value, RefVisitor visitor) { mapRefs(value, (ref, schema) -> { visitor.visit(ref, schema); diff --git a/engine/src/test/java/io/hstore/engine/InlineMembersTest.java b/engine/src/test/java/io/hstore/engine/InlineMembersTest.java new file mode 100644 index 0000000..f0cc0f5 --- /dev/null +++ b/engine/src/test/java/io/hstore/engine/InlineMembersTest.java @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine; + +import io.hstore.engine.catalog.AtomRecord.EdgeRecord; +import io.hstore.engine.catalog.EngineSlots; +import io.hstore.engine.feed.CommitEvent; +import io.hstore.engine.maintenance.Compactor; +import io.hstore.engine.topology.EdgeKind; +import io.hstore.engine.topology.Incidence; +import io.hstore.engine.topology.MemberChange; +import io.hstore.engine.tree.Ref; +import io.hstore.engine.tree.TreeVerifier; +import io.hstore.engine.txn.CommitResult; +import io.hstore.engine.txn.CrashPoint; +import io.hstore.engine.txn.Isolation; +import io.hstore.engine.txn.Snapshot; +import io.hstore.engine.txn.Transaction; +import io.hstore.engine.txn.TxnOptions; +import io.hstore.engine.txn.View; +import io.hstore.engine.txn.WalMode; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.LongStream; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InlineMembersTest { + + private static final long BASE = 1_000; + + @TempDir + Path directory; + + private static EngineOptions options() { + return EngineTest.small().withPagesPerSegment(64).withHistoryLimit(50).withCheckpointWalBytes(Long.MAX_VALUE); + } + + @Test + void smallEdgesKeepTheirMembersInTheirRecord() { + Map> expected = new HashMap<>(); + Map> shapes = new HashMap<>(); + try (StorageEngine engine = StorageEngine.open(directory, options())) { + for (int size : new int[]{0, 1, 3, 32, 33}) { + long edge = setEdge(engine, LongStream.range(BASE, BASE + size).boxed().toList()); + expected.put(edge, LongStream.range(BASE, BASE + size).boxed().toList()); + shapes.put(edge, size == 0 ? Ref.Empty.class : size <= Ref.INLINE_MAX_ENTRIES ? Ref.Inline.class : Ref.Stored.class); + } + long wide = setEdge(engine, LongStream.range(0, 20).map(i -> i << 40).boxed().toList()); + expected.put(wide, LongStream.range(0, 20).map(i -> i << 40).boxed().toList()); + shapes.put(wide, Ref.Stored.class); + assertShapes(engine, expected, shapes); + engine.verifyReferences(); + verifyTrees(engine); + } + try (StorageEngine engine = StorageEngine.open(directory, options())) { + assertShapes(engine, expected, shapes); + engine.verifyReferences(); + verifyTrees(engine); + } + } + + @Test + void orderedEdgesKeepBothTreesInline() { + List order = List.of(BASE + 7, BASE + 2, BASE + 5); + long edge; + try (StorageEngine engine = StorageEngine.open(directory, options())) { + edge = engine.write(txn -> { + long created = txn.createEdge(EngineTest.EDGE, EdgeKind.ORDERED); + order.forEach(member -> txn.append(created, Incidence.of(member))); + return created; + }); + EdgeRecord record = record(engine, edge); + assertInstanceOf(Ref.Inline.class, record.members()); + assertInstanceOf(Ref.Inline.class, record.order()); + engine.write(txn -> { + txn.insertAt(edge, 1, Incidence.of(BASE + 9)); + txn.removeAt(edge, 0); + return null; + }); + assertEquals(List.of(BASE + 9, BASE + 2, BASE + 5), ordered(engine, edge)); + engine.write(txn -> { + for (int i = 0; i < 40; i++) { + txn.insertAt(edge, 0, Incidence.of(BASE + 100 + i)); + } + return null; + }); + assertInstanceOf(Ref.Stored.class, record(engine, edge).members()); + assertInstanceOf(Ref.Stored.class, record(engine, edge).order()); + engine.write(txn -> { + for (int i = 0; i < 40; i++) { + txn.removeAt(edge, 0); + } + return null; + }); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + assertInstanceOf(Ref.Inline.class, record(engine, edge).order()); + engine.verifyReferences(); + verifyTrees(engine); + } + try (StorageEngine engine = StorageEngine.open(directory, options())) { + assertEquals(List.of(BASE + 9, BASE + 2, BASE + 5), ordered(engine, edge)); + assertEquals(1L, (long) engine.read(snapshot -> snapshot.requireEdge(edge).indexOf(BASE + 2))); + engine.verifyReferences(); + } + } + + @Test + void crossingTheThresholdKeepsReferenceCountsExact() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long edge = setEdge(engine, LongStream.range(BASE, BASE + 31).boxed().toList()); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + for (int round = 0; round < 6; round++) { + engine.write(txn -> { + txn.insert(edge, BASE + 31); + txn.insert(edge, BASE + 32); + return null; + }); + assertInstanceOf(Ref.Stored.class, record(engine, edge).members()); + engine.verifyReferences(); + engine.write(txn -> { + txn.remove(edge, BASE + 31); + txn.remove(edge, BASE + 32); + return null; + }); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + engine.verifyReferences(); + if (round % 2 == 1) { + engine.checkpoint(); + } + } + engine.write(txn -> { + txn.upsert(edge, BASE, incidence -> incidence.withWeight(2.5)); + return null; + }); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + assertEquals(2.5, engine.read(snapshot -> snapshot.incidence(edge, BASE)).orElseThrow().weightValue()); + engine.write(txn -> { + txn.delete(edge); + return null; + }); + engine.verifyReferences(); + assertTrue(engine.read(snapshot -> snapshot.atom(edge)).isEmpty()); + } + try (StorageEngine engine = StorageEngine.open(directory, options())) { + engine.verifyReferences(); + } + } + + @Test + void randomSmallEdgeWorkKeepsCountsAndContents() { + Random random = new Random(55); + Map> model = new HashMap<>(); + try (StorageEngine engine = StorageEngine.open(directory, options())) { + for (int i = 0; i < 12; i++) { + model.put(setEdge(engine, List.of()), new TreeSet<>()); + } + List edges = List.copyOf(model.keySet()); + for (int round = 0; round < 150; round++) { + long edge = edges.get(random.nextInt(edges.size())); + TreeSet members = model.get(edge); + List added = new ArrayList<>(); + List removed = new ArrayList<>(); + for (int i = 0; i < 1 + random.nextInt(8); i++) { + long member = BASE + random.nextInt(48); + if (members.contains(member) && !removed.contains(member) && !added.contains(member)) { + removed.add(member); + } else if (!members.contains(member) && !added.contains(member)) { + added.add(member); + } + } + engine.write(txn -> { + added.forEach(member -> txn.insert(edge, member)); + removed.forEach(member -> txn.remove(edge, member)); + return null; + }); + members.addAll(added); + removed.forEach(members::remove); + if (round % 25 == 24) { + engine.checkpoint(); + engine.verifyReferences(); + } + } + assertModel(engine, model); + engine.verifyReferences(); + } + try (StorageEngine engine = StorageEngine.open(directory, options())) { + assertModel(engine, model); + engine.verifyReferences(); + verifyTrees(engine); + } + } + + static Stream crashMatrix() { + return Stream.of(WalMode.values()).flatMap(mode -> EnumSet.range(CrashPoint.PAGE, CrashPoint.CATALOG_PUBLISH).stream() + .map(point -> Arguments.of(mode, point))); + } + + @ParameterizedTest + @MethodSource("crashMatrix") + void aCrashLeavesSmallEdgesBeforeOrAfterTheirCommit(WalMode mode, CrashPoint point) { + AtomicBoolean armed = new AtomicBoolean(false); + EngineOptions options = options().withWalMode(mode).withFaults(reached -> { + if (reached == point && armed.compareAndSet(true, false)) { + throw new CrashPoint.SimulatedCrash(point); + } + }); + StorageEngine engine = StorageEngine.open(directory, options); + long growing = setEdge(engine, LongStream.range(BASE, BASE + 3).boxed().toList()); + long shrinking = setEdge(engine, LongStream.range(BASE, BASE + 40).boxed().toList()); + long fresh = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + armed.set(true); + Transaction doomed = engine.begin(); + LongStream.range(BASE + 3, BASE + 40).forEach(member -> doomed.insert(growing, member)); + LongStream.range(BASE + 3, BASE + 40).forEach(member -> doomed.remove(shrinking, member)); + LongStream.range(BASE, BASE + 5).forEach(member -> doomed.insert(fresh, member)); + assertThrows(CrashPoint.SimulatedCrash.class, doomed::commit); + engine.halt(); + boolean committed = point.ordinal() >= CrashPoint.COMMIT_APPEND.ordinal(); + try (StorageEngine recovered = StorageEngine.open(directory, options().withWalMode(mode))) { + recovered.verifyReferences(); + verifyTrees(recovered); + assertEquals(committed ? 40L : 3L, (long) recovered.read(snapshot -> snapshot.requireEdge(growing).size())); + assertEquals(committed ? 3L : 40L, (long) recovered.read(snapshot -> snapshot.requireEdge(shrinking).size())); + assertEquals(committed ? 5L : 0L, (long) recovered.read(snapshot -> snapshot.requireEdge(fresh).size())); + recovered.write(txn -> { + txn.insert(shrinking, BASE + 500); + return null; + }); + } + try (StorageEngine reopened = StorageEngine.open(directory, options().withWalMode(mode))) { + assertTrue((boolean) reopened.read(snapshot -> snapshot.contains(shrinking, BASE + 500))); + reopened.verifyReferences(); + } + } + + @ParameterizedTest + @EnumSource(WalMode.class) + void inlineEdgesCommittedAfterTheCheckpointComeBackFromTheLog(WalMode mode) { + EngineOptions options = options().withWalMode(mode); + List edges = new ArrayList<>(); + StorageEngine engine = StorageEngine.open(directory, options); + engine.checkpoint(); + for (int i = 0; i < 20; i++) { + int size = i % 5 == 4 ? 40 : 1 + i % 5; + edges.add(setEdge(engine, LongStream.range(BASE + i, BASE + i + size).boxed().toList())); + } + long reshaped = edges.getFirst(); + engine.write(txn -> { + LongStream.range(BASE + 100, BASE + 140).forEach(member -> txn.insert(reshaped, member)); + return null; + }); + engine.write(txn -> { + LongStream.range(BASE + 100, BASE + 140).forEach(member -> txn.remove(reshaped, member)); + return null; + }); + engine.halt(); + try (StorageEngine recovered = StorageEngine.open(directory, options)) { + assertTrue(recovered.recovery().replayedCommits() >= 22); + for (int i = 0; i < edges.size(); i++) { + int size = i % 5 == 4 ? 40 : 1 + i % 5; + assertEquals(LongStream.range(BASE + i, BASE + i + size).boxed().toList(), members(recovered, edges.get(i))); + } + assertInstanceOf(Ref.Inline.class, record(recovered, reshaped).members()); + recovered.verifyReferences(); + verifyTrees(recovered); + } + } + + @Test + void compactionCarriesInlineEdgesAlong() { + EngineOptions options = options().withHistoryLimit(2); + Map> model = new HashMap<>(); + try (StorageEngine engine = StorageEngine.open(directory, options)) { + for (int i = 0; i < 30; i++) { + List members = LongStream.range(BASE + i, BASE + i + 1 + i % 4).boxed().toList(); + model.put(setEdge(engine, members), new TreeSet<>(members)); + } + List edges = List.copyOf(model.keySet()); + for (int round = 0; round < 80; round++) { + long edge = edges.get(round % edges.size()); + long member = BASE + 200 + round; + engine.write(txn -> { + txn.insert(edge, member); + return null; + }); + model.get(edge).add(member); + if (model.get(edge).size() > 4) { + long first = model.get(edge).first(); + engine.write(txn -> { + txn.remove(edge, first); + return null; + }); + model.get(edge).remove(first); + } + } + Compactor.Report report = engine.compact(); + assertFalse(report.compacted().isEmpty()); + assertModel(engine, model); + engine.verifyReferences(); + } + try (StorageEngine engine = StorageEngine.open(directory, options)) { + assertModel(engine, model); + engine.verifyReferences(); + verifyTrees(engine); + } + } + + @Test + void historyAndTheFeedFollowAnEdgeAcrossTheThreshold() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long start = engine.transactions().current().id(); + long edge = setEdge(engine, LongStream.range(BASE, BASE + 3).boxed().toList()); + long small = engine.stats().generation(); + engine.write(txn -> { + LongStream.range(BASE + 3, BASE + 40).forEach(member -> txn.insert(edge, member)); + return null; + }); + long large = engine.stats().generation(); + engine.write(txn -> { + LongStream.range(BASE + 1, BASE + 39).forEach(member -> txn.remove(edge, member)); + return null; + }); + try (Snapshot then = engine.snapshotAt(small, 0); Snapshot later = engine.snapshotAt(large, 0)) { + assertInstanceOf(Ref.Inline.class, record(then, edge).members()); + assertInstanceOf(Ref.Stored.class, record(later, edge).members()); + assertEquals(LongStream.range(BASE, BASE + 3).boxed().toList(), members(then, edge)); + assertEquals(LongStream.range(BASE, BASE + 40).boxed().toList(), members(later, edge)); + } + assertEquals(List.of(BASE, BASE + 39), members(engine, edge)); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + TreeSet replayed = new TreeSet<>(); + for (CommitEvent event : engine.feed().replay(start).toList()) { + for (MemberChange change : event.members()) { + if (change.edge() == edge) { + switch (change) { + case MemberChange.Added added -> assertTrue(replayed.add(added.member())); + case MemberChange.Removed removed -> assertTrue(replayed.remove(removed.member())); + case MemberChange.Updated _ -> { + } + } + } + } + } + assertEquals(new TreeSet<>(List.of(BASE, BASE + 39)), replayed); + } + } + + @Test + void inlineEdgesAreComparedByContentWhenCommitting() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long edge = setEdge(engine, List.of(BASE, BASE + 1)); + long other = setEdge(engine, List.of()); + + Transaction strict = engine.begin(TxnOptions.defaults().withIsolation(Isolation.SERIALIZABLE)); + assertFalse(strict.contains(edge, BASE + 5)); + strict.insert(other, BASE + 5); + engine.write(txn -> { + txn.insert(edge, BASE + 7); + return null; + }); + engine.write(txn -> { + txn.remove(edge, BASE + 7); + return null; + }); + strict.commit(); + + Transaction reader = engine.begin(TxnOptions.defaults().withIsolation(Isolation.SERIALIZABLE)); + assertFalse(reader.contains(edge, BASE + 5)); + reader.insert(other, BASE + 6); + engine.write(txn -> { + txn.insert(edge, BASE + 5); + return null; + }); + assertThrows(HStoreException.Conflict.class, reader::commit); + + Transaction first = engine.begin(); + Transaction second = engine.begin(); + first.insert(edge, BASE + 20); + second.insert(edge, BASE + 21); + assertEquals(CommitResult.Outcome.COMMITTED, first.commit().outcome()); + assertEquals(CommitResult.Outcome.REBASED, second.commit().outcome()); + + Transaction weighs = engine.begin(); + Transaction drops = engine.begin(); + weighs.upsert(edge, BASE, incidence -> incidence.withWeight(4.0)); + drops.remove(edge, BASE); + weighs.commit(); + assertThrows(HStoreException.Conflict.class, drops::commit); + + assertEquals(List.of(BASE, BASE + 1, BASE + 5, BASE + 20, BASE + 21), members(engine, edge)); + assertInstanceOf(Ref.Inline.class, record(engine, edge).members()); + engine.verifyReferences(); + } + } + + @Test + void inlineTreesNeedNoPagesOfTheirOwn() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + Transaction txn = engine.begin(TxnOptions.defaults().withMaxPages(60)); + for (int i = 0; i < 100; i++) { + long edge = txn.createEdge(EngineTest.EDGE, EdgeKind.SET); + for (int m = 0; m < 3; m++) { + txn.insert(edge, BASE + i + m); + } + } + CommitResult result = txn.commit(); + assertTrue(result.pagesWritten() <= 60, result.pagesWritten() + " pages"); + engine.verifyReferences(); + } + } + + private static long setEdge(StorageEngine engine, List members) { + return engine.write(txn -> { + long edge = txn.createEdge(EngineTest.EDGE, EdgeKind.SET); + members.forEach(member -> txn.insert(edge, member)); + return edge; + }); + } + + private static EdgeRecord record(StorageEngine engine, long edge) { + return engine.read(snapshot -> record(snapshot, edge)); + } + + private static EdgeRecord record(View view, long edge) { + return (EdgeRecord) view.atom(edge).orElseThrow(); + } + + private static List members(StorageEngine engine, long edge) { + return engine.read(snapshot -> members(snapshot, edge)); + } + + private static List members(View view, long edge) { + return view.requireEdge(edge).memberIds().boxed().toList(); + } + + private static List ordered(StorageEngine engine, long edge) { + return engine.read(snapshot -> snapshot.requireEdge(edge).stream().map(Incidence::member).toList()); + } + + private static void assertShapes(StorageEngine engine, Map> expected, Map> shapes) { + expected.forEach((edge, members) -> { + assertEquals(members, members(engine, edge)); + assertInstanceOf(shapes.get(edge), record(engine, edge).members(), "edge of " + members.size() + " members"); + assertEquals(members.size(), (long) engine.read(snapshot -> snapshot.cardinality(edge))); + members.forEach(member -> assertTrue((boolean) engine.read(snapshot -> snapshot.contains(edge, member)))); + }); + } + + private static void assertModel(StorageEngine engine, Map> model) { + model.forEach((edge, members) -> assertEquals(List.copyOf(members), members(engine, edge))); + } + + private static void verifyTrees(StorageEngine engine) { + try (Snapshot snapshot = engine.snapshot()) { + TreeVerifier.Report report = new TreeVerifier(engine.source()).verify(snapshot.roots().get(EngineSlots.CATALOG), EngineSlots.ATOM_SCHEMA); + assertTrue(report.entries() > 0); + } + } +} diff --git a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java index 4328201..7940a37 100644 --- a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java +++ b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -366,6 +367,37 @@ void onlySmallLeavesWithoutNestedTreesFitInline() { assertFalse(Ref.fitsInline(catalog)); } + @Test + void materializingAnEdgeRecordInlinesSmallTreesWithTheSameSummary() { + MemoryPages pages = new MemoryPages(4096); + WriteScope scope = new WriteScope(); + Tree small = Tree.empty(TopologySchemas.SET_MEMBERS, pages); + Tree large = Tree.empty(TopologySchemas.SET_MEMBERS, pages); + for (long member = 1; member <= 40; member++) { + Incidence incidence = Incidence.of(member).withWeight(member * 0.5); + large = large.put(scope, member, incidence); + if (member <= 5) { + small = small.put(scope, member, incidence); + } + } + Summary stored = pages.persist(small).summary(); + AtomRecord.EdgeRecord edge = new AtomRecord.EdgeRecord(2, EdgeKind.SET, small.root(), Ref.EMPTY, 0, 0, 0, 0); + AtomRecord.EdgeRecord big = new AtomRecord.EdgeRecord(2, EdgeKind.SET, large.root(), Ref.EMPTY, 0, 0, 0, 0); + Tree catalog = Tree.empty(EngineSlots.ATOM_SCHEMA, pages).put(scope, 1, edge).put(scope, 2, big); + assertEquals(2, Materializer.pendingNodes(catalog.root(), EngineSlots.ATOM_SCHEMA)); + Tree written = pages.persist(catalog); + AtomRecord.EdgeRecord inlined = (AtomRecord.EdgeRecord) written.get(1).orElseThrow(); + AtomRecord.EdgeRecord paged = (AtomRecord.EdgeRecord) written.get(2).orElseThrow(); + assertInstanceOf(Ref.Inline.class, inlined.members()); + assertInstanceOf(Ref.Stored.class, paged.members()); + assertEquals(stored, inlined.members().summary()); + assertEquals(edge.contentHash(), inlined.contentHash()); + assertEquals(small.stream().toList(), new Tree<>(TopologySchemas.SET_MEMBERS, pages, inlined.members()).stream().toList()); + Leaf root = (Leaf) written.node(written.root()); + Leaf bare = Leaf.decoded(EngineSlots.ATOM_SCHEMA, new long[]{1}, new Object[]{AtomRecord.EdgeRecord.of(2, EdgeKind.SET)}, null); + assertTrue(root.heapBytes() >= bare.heapBytes() + ((Ref.Inline) inlined.members()).leaf().heapBytes()); + } + private static Leaf memberLeaf(long... members) { Object[] values = Arrays.stream(members).mapToObj(Incidence::of).toArray(); return Leaf.decoded(TopologySchemas.SET_MEMBERS, members.clone(), values, null); From b146be1e4b190047fcfc3ebf86c6933c6a73ff00 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 17:06:02 +0530 Subject: [PATCH 3/4] perf(engine): stop keeping an Incidence per member in plain member leaves Most member leaves have no roles, weights, validity, data or qualifiers, so every Incidence in them is just Incidence.of(member). Such a leaf now keeps only its member ids (the keys themselves for set edges) and builds the Incidence when someone asks for a value. The file format is the same: the codec spots a leaf whose column flags are all clear, and writes one back out straight from the ids. A decoded 200 member leaf drops from about 77 to 9 bytes per member on the heap for set edges and to 17 for ordered ones, and decoding it allocates about 2 KB instead of 16 KB. Writable copies still hold real Incidence objects, so the write path is unchanged. --- .../engine/topology/IncidenceCodec.java | 63 +++++++++++++++++++ .../main/java/io/hstore/engine/tree/Leaf.java | 58 +++++++++++++---- .../java/io/hstore/engine/tree/NodeCodec.java | 26 +++++--- .../io/hstore/engine/tree/TreeSchema.java | 15 +++++ .../io/hstore/engine/tree/ValueCodec.java | 21 +++++++ .../java/io/hstore/engine/tree/TreeTest.java | 58 +++++++++++++++++ 6 files changed, 222 insertions(+), 19 deletions(-) diff --git a/engine/src/main/java/io/hstore/engine/topology/IncidenceCodec.java b/engine/src/main/java/io/hstore/engine/topology/IncidenceCodec.java index b3c4549..075a91d 100644 --- a/engine/src/main/java/io/hstore/engine/topology/IncidenceCodec.java +++ b/engine/src/main/java/io/hstore/engine/topology/IncidenceCodec.java @@ -141,6 +141,69 @@ public void decode(ByteCursor in, long[] keys, Object[] into) { } } + @Override + public long[] decodeImplied(ByteCursor in, long[] keys) { + long start = in.position(); + if (in.getUnsignedByte() != 0) { + in.position(start); + return null; + } + if (!sequenced) { + return keys; + } + long[] members = new long[keys.length]; + for (int i = 0; i < members.length; i++) { + members[i] = in.getSignedVarLong(); + } + return members; + } + + @Override + public void encodeImplied(ByteCursor out, long[] keys, long[] members) { + out.putByte(0); + if (sequenced) { + for (long member : members) { + out.putSignedVarLong(member); + } + } + } + + @Override + public long[] impliedWords(long[] keys, List values) { + long[] members = sequenced ? new long[values.size()] : keys; + for (int i = 0; i < values.size(); i++) { + Incidence incidence = values.get(i); + if (!plain(incidence)) { + return null; + } + if (sequenced) { + members[i] = incidence.member(); + } else if (incidence.member() != keys[i]) { + return null; + } + } + return members; + } + + @Override + public Incidence implied(long member) { + return Incidence.of(member); + } + + @Override + public int impliedSize(long member) { + return sequenced ? ByteCursor.signedVarLongSize(member) : 0; + } + + private static boolean plain(Incidence incidence) { + for (Column column : COLUMNS) { + if (column.present.test(incidence)) { + return false; + } + } + return true; + } + private static long value(long[][] columns, Column column, int index, long fallback) { long[] decoded = columns[column.ordinal()]; return decoded == null ? fallback : decoded[index]; 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 ff9e6de..74169b0 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Leaf.java +++ b/engine/src/main/java/io/hstore/engine/tree/Leaf.java @@ -16,18 +16,24 @@ public final class Leaf extends Node { private long[] keys; private Object[] values; + private final long[] words; private int size; private int keyBytes; private int valueBytes; - private Leaf(TreeSchema schema, Object owner, long[] keys, Object[] values, int size, Summary summary) { + private Leaf(TreeSchema schema, Object owner, long[] keys, Object[] values, long[] words, int size, Summary summary) { super(schema, owner, summary); this.keys = keys; this.values = values; + this.words = words; this.size = size; remeasure(); } + private Leaf(TreeSchema schema, Object owner, long[] keys, Object[] values, int size, Summary summary) { + this(schema, owner, keys, values, null, size, summary); + } + static Leaf owned(TreeSchema schema, Object owner, long[] keys, Object[] values, int size) { return new Leaf(schema, owner, keys, values, size, null); } @@ -36,6 +42,10 @@ static Leaf decoded(TreeSchema schema, long[] keys, Object[] values, Summary return new Leaf(schema, null, keys, values, keys.length, summary); } + static Leaf implied(TreeSchema schema, long[] keys, long[] words, Summary summary) { + return new Leaf(schema, null, keys, null, words, keys.length, summary); + } + static int keyStreamBytes(long[] keys, int size) { if (size == 0) { return 0; @@ -55,12 +65,15 @@ private void remeasure() { keyBytes = keyStreamBytes(keys, size); valueBytes = 0; for (int i = 0; i < size; i++) { - valueBytes += schema.entryBytes(values[i]); + valueBytes += values != null ? schema.entryBytes(values[i]) : schema.impliedBytes(words[i]); } } @Override int heapBytes() { + if (values == null) { + return NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * keys.length + (words == keys ? 0 : Long.BYTES * words.length); + } int bytes = NODE_HEAP_BYTES + 2 * ARRAY_HEAP_BYTES + Long.BYTES * keys.length + Integer.BYTES * values.length + ENTRY_HEAP_BYTES * size + valueBytes; return schema.codec().holdsRefs() ? bytes + inlineHeapBytes(schema) : bytes; @@ -74,7 +87,7 @@ private int inlineHeapBytes(TreeSchema typed) { } }; for (int i = 0; i < size; i++) { - typed.codec().forEachRef(typed.cast(values[i]), visitor); + typed.codec().forEachRef(typed.cast(value(i)), visitor); } return total[0]; } @@ -86,6 +99,9 @@ boolean sameEntries(Leaf other) { if (schema.id() != other.schema.id() || size != other.size || !Arrays.equals(keys, 0, size, other.keys, 0, size)) { return false; } + if (values == null && other.values == null) { + return Arrays.equals(words, 0, size, other.words, 0, size); + } for (int i = 0; i < size; i++) { if (!Objects.equals(value(i), other.value(i))) { return false; @@ -119,8 +135,13 @@ long key(int index) { return keys[index]; } + long[] impliedWords() { + return values == null ? words : null; + } + Object value(int index) { - return values[index]; + Object[] stored = values; + return stored != null ? stored[index] : schema.implied(words[index]); } int bytes() { @@ -139,7 +160,7 @@ List valueList() { return new AbstractList<>() { @Override public Object get(int index) { - return values[index]; + return value(index); } @Override @@ -155,11 +176,26 @@ long[] keyArray() { Leaf copyFor(WriteScope scope) { int capacity = size + Math.max(4, size >> 3); - return new Leaf(schema, scope.token(), Arrays.copyOf(keys, capacity), Arrays.copyOf(values, capacity), size, null); + return new Leaf(schema, scope.token(), Arrays.copyOf(keys, capacity), valueArray(capacity), size, null); } Leaf frozenWith(Object[] mappedValues) { - return new Leaf(schema, null, keyArray(), Arrays.copyOf(mappedValues, size), size, summary()); + long[] frozenKeys = keyArray(); + long[] implied = schema.impliedWords(frozenKeys, Arrays.asList(mappedValues).subList(0, size)); + return implied != null + ? new Leaf(schema, null, frozenKeys, null, implied, size, summary()) + : new Leaf(schema, null, frozenKeys, Arrays.copyOf(mappedValues, size), size, summary()); + } + + private Object[] valueArray(int capacity) { + if (values != null) { + return Arrays.copyOf(values, capacity); + } + Object[] copy = new Object[capacity]; + for (int i = 0; i < size; i++) { + copy[i] = value(i); + } + return copy; } void insert(int position, long key, Object value) { @@ -230,15 +266,15 @@ Leaf splitOff(int budget) { private int cost(int index) { int key = index == 0 ? ByteCursor.signedVarLongSize(keys[0]) : gap(keys[index - 1], keys[index]); - return key + schema.entryBytes(values[index]); + return key + schema.entryBytes(value(index)); } static Leaf concat(TreeSchema schema, Object owner, Leaf left, Leaf right) { int total = left.size + right.size; long[] keys = Arrays.copyOf(left.keys, total + 4); - Object[] values = Arrays.copyOf(left.values, total + 4); + Object[] values = left.valueArray(total + 4); System.arraycopy(right.keys, 0, keys, left.size, right.size); - System.arraycopy(right.values, 0, values, left.size, right.size); + System.arraycopy(right.valueArray(right.size), 0, values, left.size, right.size); return owned(schema, owner, keys, values, total); } @@ -246,7 +282,7 @@ static Leaf concat(TreeSchema schema, Object owner, Leaf left, Leaf right) { Summary computeSummary() { Summary.Builder builder = Summary.builder(schema.fingerprint()); for (int i = 0; i < size; i++) { - schema.measureEntry(keys[i], values[i], builder); + schema.measureEntry(keys[i], value(i), builder); } return builder.build(); } diff --git a/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java b/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java index 7d08966..373db8c 100644 --- a/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java +++ b/engine/src/main/java/io/hstore/engine/tree/NodeCodec.java @@ -9,7 +9,6 @@ import io.hstore.engine.page.PageType; import java.lang.foreign.MemorySegment; -import java.util.Arrays; import java.util.List; public final class NodeCodec { @@ -64,10 +63,17 @@ static Leaf decodeInline(ByteCursor in, TreeSchema schema) { if (count < 1 || count > Ref.INLINE_MAX_ENTRIES) { throw new IllegalStateException("inline tree of " + count + " entries in " + schema); } - long[] keys = decodeKeys(in, count); - Object[] values = new Object[count]; + return decodeLeaf(in, schema, decodeKeys(in, count), null); + } + + private static Leaf decodeLeaf(ByteCursor in, TreeSchema schema, long[] keys, Summary summary) { + long[] words = schema.codec().decodeImplied(in, keys); + if (words != null) { + return Leaf.implied(schema, keys, words, summary); + } + Object[] values = new Object[keys.length]; schema.codec().decode(in, keys, values); - return Leaf.decoded(schema, keys, values, null); + return Leaf.decoded(schema, keys, values, summary); } private static long[] decodeKeys(ByteCursor in, int count) { @@ -80,6 +86,11 @@ private static long[] decodeKeys(ByteCursor in, int count) { } private static void encodeValues(ByteCursor out, TreeSchema schema, long[] keys, Leaf leaf) { + long[] words = leaf.impliedWords(); + if (words != null) { + schema.codec().encodeImplied(out, keys, words); + return; + } @SuppressWarnings("unchecked") List values = (List) leaf.valueList(); schema.codec().encode(out, keys, values); @@ -126,12 +137,11 @@ public static Node decode(MemorySegment page, PageHeader header, long pageId, Tr long[] keys = decodeKeys(in, count); return switch (header.type()) { case LEAF -> { - Object[] values = new Object[count]; - schema.codec().decode(in, keys, values); - if (header.refersToPages() != (schema.codec().holdsRefs() && refersToPages(schema, Arrays.asList(values)))) { + Leaf leaf = decodeLeaf(in, schema, keys, summary); + if (header.refersToPages() != refersToPages(leaf)) { throw HStoreException.corrupt(pageId, "page header and contents disagree on whether it refers to other pages"); } - yield Leaf.decoded(schema, keys, values, summary); + yield leaf; } case INTERNAL -> { Ref[] children = new Ref[count]; diff --git a/engine/src/main/java/io/hstore/engine/tree/TreeSchema.java b/engine/src/main/java/io/hstore/engine/tree/TreeSchema.java index 3f483c5..d5a2869 100644 --- a/engine/src/main/java/io/hstore/engine/tree/TreeSchema.java +++ b/engine/src/main/java/io/hstore/engine/tree/TreeSchema.java @@ -3,6 +3,8 @@ package io.hstore.engine.tree; +import java.util.List; + public record TreeSchema(int id, String name, FingerprintMode fingerprint, ValueCodec codec, EntryMeasure measure) { public static final int KEY_BYTES = 10; @@ -22,6 +24,19 @@ int entryBytes(Object value) { return codec.maxSize(cast(value)); } + Object implied(long word) { + return codec.implied(word); + } + + int impliedBytes(long word) { + return codec.impliedSize(word); + } + + @SuppressWarnings("unchecked") + long[] impliedWords(long[] keys, List values) { + return codec.impliedWords(keys, (List) values); + } + int leafOverhead(int entries) { return codec.leafOverhead(entries); } diff --git a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java index 100d33d..77171bd 100644 --- a/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java +++ b/engine/src/main/java/io/hstore/engine/tree/ValueCodec.java @@ -5,6 +5,7 @@ import io.hstore.engine.page.ByteCursor; +import java.util.Arrays; import java.util.List; import java.util.function.BiConsumer; import java.util.function.Function; @@ -18,6 +19,26 @@ public interface ValueCodec { void decode(ByteCursor in, long[] keys, Object[] into); + default long[] decodeImplied(ByteCursor in, long[] keys) { + return null; + } + + default long[] impliedWords(long[] keys, List values) { + return null; + } + + default void encodeImplied(ByteCursor out, long[] keys, long[] words) { + encode(out, keys, Arrays.stream(words).mapToObj(this::implied).toList()); + } + + default V implied(long word) { + throw new UnsupportedOperationException(getClass().getSimpleName() + " has no values implied by a single word"); + } + + default int impliedSize(long word) { + return maxSize(implied(word)); + } + default int leafOverhead(int entries) { return 0; } diff --git a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java index 7940a37..097426a 100644 --- a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java +++ b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.provider.ValueSource; import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -398,6 +399,63 @@ void materializingAnEdgeRecordInlinesSmallTreesWithTheSameSummary() { assertTrue(root.heapBytes() >= bare.heapBytes() + ((Ref.Inline) inlined.members()).leaf().heapBytes()); } + @Test + void plainMemberLeavesKeepNoIncidencePerMember() { + long pageId = PageId.pack(3, 0); + for (TreeSchema schema : List.of(TopologySchemas.SET_MEMBERS, TopologySchemas.ORDERED_MEMBERS)) { + long[] keys = LongStream.range(0, 200).map(i -> 10 + 3 * i).toArray(); + boolean set = schema == TopologySchemas.SET_MEMBERS; + Object[] incidences = Arrays.stream(keys).mapToObj(key -> Incidence.of(set ? key : 7 * key)).toArray(); + Leaf objects = Leaf.decoded(schema, keys, incidences, null); + MemorySegment image = NodeCodec.encode(objects, pageId, 1, 4096); + Leaf decoded = (Leaf) NodeCodec.decode(image, pageId, schema); + assertArrayEquals(image.toArray(ValueLayout.JAVA_BYTE), + NodeCodec.encode(decoded, pageId, 1, 4096).toArray(ValueLayout.JAVA_BYTE)); + assertEquals(objects.valueList(), decoded.valueList()); + assertEquals(objects.summary(), decoded.summary()); + assertEquals(objects.bytes(), decoded.bytes()); + assertTrue(decoded.heapBytes() + 64 * keys.length <= objects.heapBytes(), decoded.heapBytes() + " vs " + objects.heapBytes()); + Leaf frozen = objects.frozenWith(incidences); + assertEquals(decoded.heapBytes(), frozen.heapBytes()); + assertTrue(decoded.sameEntries(objects) && objects.sameEntries(frozen)); + + Object[] mixed = incidences.clone(); + mixed[5] = ((Incidence) mixed[5]).withWeight(2.0); + Leaf weighted = Leaf.decoded(schema, keys, mixed, null); + Leaf kept = (Leaf) NodeCodec.decode(NodeCodec.encode(weighted, pageId, 1, 4096), pageId, schema); + assertEquals(weighted.valueList(), kept.valueList()); + assertEquals(weighted.heapBytes(), kept.heapBytes()); + assertFalse(kept.sameEntries(decoded)); + } + } + + @ParameterizedTest + @ValueSource(longs = {1, 2, 3}) + void memberTreesMatchAnOracleAcrossPlainAndWeightedLeaves(long seed) { + RandomGenerator random = random(seed); + MemoryPages pages = new MemoryPages(1024); + WriteScope scope = new WriteScope(); + Tree tree = Tree.empty(TopologySchemas.SET_MEMBERS, pages); + TreeMap oracle = new TreeMap<>(); + for (int round = 0; round < 40; round++) { + for (int step = 0; step < 100; step++) { + long member = random.nextLong(3000); + if (random.nextInt(10) < 6) { + Incidence incidence = member < 400 && random.nextInt(4) == 0 ? Incidence.of(member).withWeight(1.5) : Incidence.of(member); + tree = tree.put(scope, member, incidence); + oracle.put(member, incidence); + } else { + tree = tree.remove(scope, member); + oracle.remove(member); + } + } + tree = pages.persist(tree); + scope.freeze(); + assertEquals(List.copyOf(oracle.values()), tree.values().toList()); + checkInvariants(tree); + } + } + private static Leaf memberLeaf(long... members) { Object[] values = Arrays.stream(members).mapToObj(Incidence::of).toArray(); return Leaf.decoded(TopologySchemas.SET_MEMBERS, members.clone(), values, null); From 577a02671ca9c4fb8d57904f20c97bb567a51ecf Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Sun, 11 Oct 2026 17:27:18 +0530 Subject: [PATCH 4/4] docs: describe inline member trees, implied member values and format 6 Covers the tagged reference encoding, when a tree is kept inline and why that can't overflow a page, how equality, reference counts, compaction and the change feed treat inline trees, the leaves that build their Incidence values on demand, and the format 6 refusal message. The example outputs from clinical-claims.hql were regenerated, and the benchmark page gets a before and after comparison. --- docs/architecture/overview.md | 22 ++++++------ docs/benchmarks.md | 34 +++++++++++++++++- docs/database/data-model.md | 8 ++++- docs/database/evidence-and-temporal.md | 4 +-- docs/operations/cli.md | 9 ++--- docs/operations/docker.md | 4 +-- docs/operations/logging.md | 2 +- docs/storage/catalog-and-generations.md | 4 +-- docs/storage/change-feed.md | 2 +- docs/storage/maintenance.md | 20 ++++++++--- docs/storage/pages.md | 30 +++++++++++----- docs/storage/persistent-tree.md | 46 +++++++++++++++++++------ docs/storage/topology.md | 14 +++++--- docs/transactions/transactions.md | 6 ++-- docs/transactions/wal-and-recovery.md | 4 +-- 15 files changed, 151 insertions(+), 58 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 63deeda..6011bf2 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -84,7 +84,7 @@ The engine has no dependencies outside `java.base`. All three modules build into |---|---| | `io.hstore.engine` | `StorageEngine` (open/close, maintenance thread, checkpoint/compact entry points), `EngineOptions`, `EngineStats`, `Dictionary` (interned role names and role sets), `HStoreException` (error codes, retryable conflicts). | | `engine.page` | Byte-level storage. `PageStore` and `SegmentFile` hold segment files and allocation. `PageHeader` is the 80-byte header and crc32c seal. `PageId` is a page's permanent number, `PageAddress` its place in a segment, and `PageDirectory` the memory-mapped `pages.dir` that maps one to the other. `ReferenceCounts` is `pages.ref`, the count of references to each page, with its checkpoint journal. `ByteCursor` provides little-endian and varint codecs over `MemorySegment`. `IoTrace` counts page visits per query through a `ScopedValue`. | -| `engine.tree` | The copy-on-write counted B+tree. `Tree` is the immutable handle and `TreeWriter` does path-copy updates. Nodes are `Leaf` and `Branch`, referenced by `Ref` (`Empty`, `Pending` or `Stored`). Also here: `Summary` (monoid annotations), `TreeCursor`, `TreeAlgebra` (leapfrog, set algebra), `TreeDiff`, `BulkBuilder`, `NodeCodec`, `NodeCache`, `Materializer`, `TreeWalker` and `TreeVerifier`. | +| `engine.tree` | The copy-on-write counted B+tree. `Tree` is the immutable handle and `TreeWriter` does path-copy updates. Nodes are `Leaf` and `Branch`, referenced by `Ref` (`Empty`, `Pending`, `Stored`, or `Inline` for a small leaf carried inside an edge record). Also here: `Summary` (monoid annotations), `TreeCursor`, `TreeAlgebra` (leapfrog, set algebra), `TreeDiff`, `BulkBuilder`, `NodeCodec`, `NodeCache`, `Materializer`, `TreeWalker` and `TreeVerifier`. | | `engine.topology` | Hyperedge representation: `Hyperedge` (members tree plus order index), `Incidence` (one member's role set, weight, validity, payload and qualifier), `IncidenceCodec` (columnar leaf encoding), `MemberChange`, `Weight` (fixed point) and `Validity`. | | `engine.index` | `PostingIndex`, a two-level index from a key to a postings set. Small sets are inlined and large ones are promoted to their own tree. Used by the reverse incidence, type, canonical-key and symbol indexes. | | `engine.catalog` | `AtomRecord` (`NodeRecord`/`EdgeRecord`) and its codec, `EngineSlots` (built-in slots, schemas and key functions), `Slot`/`SlotRegistry`, `RootVector`, `Generation`, `Branch`, `Symbol`, and `CatalogImage`/`CatalogStore` (checkpoint files). | @@ -229,25 +229,25 @@ Readers take no locks at all. The following is real output from `hstore init` followed by loading `examples/clinical-claims.hql` and running `CHECKPOINT;` (default 16 KiB pages): ```text -FORMAT 55 B format=5, page-size=16384 (java.util.Properties) +FORMAT 55 B format=6, page-size=16384 (java.util.Properties) LOCK 0 B FileChannel.tryLock() held while open -hstore.conf 2863 B server configuration template written by `hstore init` +hstore.conf 2903 B server configuration template written by `hstore init` catalog/manifest 20 B name of the latest catalog file catalog/generations/0000000000000000.cat 37 B checkpoint of generation 0 -catalog/generations/000000000000009d.cat 49851 B checkpoint of generation 157 (current + retained history) -data/segments/00000001.seg 1213651 B segment 1: packed node images +catalog/generations/000000000000009d.cat 50554 B checkpoint of generation 157 (current + retained history) +data/segments/00000001.seg 1029664 B segment 1: packed node images data/segments/pages.dir 8388608 B page directory, one 8 MiB chunk mapped so far (sparse) data/segments/pages.ref 8388608 B reference counts, same layout (sparse) data/segments/pages.ref.journal 0 B emptied after every checkpoint -wal/segments/0000000000000000.wal 165595 B WAL segment starting at LSN 0 -feed/0000000000000000.feed 29846 B change feed segment starting at byte 0 +wal/segments/0000000000000000.wal 161389 B WAL segment starting at LSN 0 +feed/0000000000000000.feed 28782 B change feed segment starting at byte 0 payload/000001.pay 0 B payload segment (database layer) semantic/state 53 B persisted HNSW index state (database layer) ``` | Path | Owner | Format | |---|---|---| -| `FORMAT` | `StorageEngine.verifyFormat` | `Properties` with `format=5` (page numbers resolved through a page directory, reference counts per page) and `page-size` (the maximum node size). Opening a directory written in an older format, or opening with a different page size, fails. | +| `FORMAT` | `StorageEngine.verifyFormat` | `Properties` with `format=6` (page numbers resolved through a page directory, reference counts per page, small member trees inline in their edge record) and `page-size` (the maximum node size). Opening a directory written in an older format, or opening with a different page size, fails. | | `LOCK` | `StorageEngine.open` | An exclusive OS file lock. A second process gets `database ... is opened by another process`. | | `catalog/generations/%016x.cat` | `CatalogStore.publish` | `int magic 0x54414348`, `int crc32c(payload)`, `long length`, then the `CatalogImage` payload. Written to `*.tmp`, fsynced, atomically renamed, and the directory fsynced. The three newest files are kept. | | `catalog/manifest` | `CatalogStore` | The file name of the latest `.cat`, written durably the same way. | @@ -260,7 +260,7 @@ semantic/state 53 B persisted HNSW index state (datab | `semantic/state`, `semantic/hnsw-.idx` | `db.semantic.SemanticPlane` | A `Properties` file with `generation` and `models`, plus one serialized HNSW graph per model, written atomically at checkpoints and on close. | | `hstore.conf` | `server.ServerConfig` | Settings, overridden by `HSTORE_*` environment variables and then by `--setting` flags. | -Every hyperedge's membership is a separate nested tree; `hstore check` reports 85 nested trees under the `catalog` slot. Most of them are a single small leaf of 100–200 bytes. Segments pack node images at 64-byte alignment instead of reserving a full `page_size` slot per node, so the whole example occupies 1.2 MB of segment space. With fixed 16 KiB slots it took 17.6 MB. See [pages.md](../storage/pages.md#segment-files). +Every hyperedge's membership is a nested tree; `hstore check` reports 85 nested trees under the `catalog` slot. All of them are small enough to be kept inline in their edge record, so the whole catalog, edges and members included, is one page. Segments pack node images at 64-byte alignment instead of reserving a full `page_size` slot per node, so the whole example occupies 1.0 MB of segment space, most of it older images still kept for history. With fixed 16 KiB slots it took 17.6 MB. See [pages.md](../storage/pages.md#segment-files). ## Verifying a directory @@ -280,6 +280,6 @@ branch main property-index 162 entries 9 pages 8 nested trees height 1 ... reverse 53 entries 1 pages 0 nested trees height 1 - catalog 458 entries 86 pages 85 nested trees height 1 -verified 105 pages: checksums, ordering, balance and summaries are consistent + catalog 458 entries 1 pages 85 nested trees height 1 +verified 20 pages: checksums, ordering, balance and summaries are consistent ``` diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 1d69b7f..80869f5 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -247,7 +247,8 @@ not what drives HStore's footprint: record carries roles, weight, validity, position and qualifier. The cost buys per-member metadata and snapshot-consistent reads, which a plain link cannot express. The benchmark now reads ids through `Reader.forEachMemberId`, which builds neither `Member` records nor tree entries; the numbers below predate that. - What remains is that every hyperedge keeps its members in a tree of its own, one more page to read. + Small hyperedges now keep their members inline in their catalog record, so the extra page per edge is gone + too (see *Inline member trees* below); the tables above predate that as well. * **Edge ingest at small batch sizes (about 0.6–0.8× in async mode).** Each commit rewrites the incident-tree paths of every touched member, which is copy-on-write write amplification. JE appends log records and defers the work to its cleaner. In `sync` mode group commit amortises the fsync and HStore leads (1.35×). @@ -263,6 +264,37 @@ not what drives HStore's footprint: ([maintenance.md](storage/maintenance.md)). Raising `compaction_live_ratio` trades write amplification for space. +### Inline member trees + +Storage format 6 keeps a hyperedge's member tree inside its catalog record when it is a single leaf of at most 32 +members that fits in about 100 bytes ([persistent-tree.md](storage/persistent-tree.md#inline-trees)), and member +leaves without per-member columns no longer hold an `Incidence` object per member. The table compares HStore +before and after those two changes, alone, at scale 1 with `async` durability and `CACHE_MB=64`: three runs of +each build, alternated, median shown. The machine was busy with other work during these runs, so the +throughput column moves around by a lot from run to run. Allocation, bytes read and disk are steady and are the +numbers to trust. + +| Workload | Before | After | Allocated per op | Read from storage per op | +|---|---:|---:|---:|---:| +| `read.members` | 615,661 ops/s | 726,507 ops/s | 979 B → 493 B | 73 B → 0 B | +| `read.twohop` | 63,817 ops/s | 90,044 ops/s | 8,760 B → 5,947 B | 337 B → 0 B | +| `large.scan` | 799 M ops/s | 687 M ops/s | 0 B → 0 B | 0 B → 0 B | +| `large.probe` | 2.00 M ops/s | 1.72 M ops/s | 168 B → 232 B | 0 B → 2 B | +| `ingest.edges` | 16,115 ops/s | 15,941 ops/s | 58.1 KB → 34.9 KB | | +| `churn.remove` | 8,589 ops/s | 12,425 ops/s | 61.0 KB → 47.4 KB | | + +| Size | Before | After | +|---|---:|---:| +| `disk` after load | 197.2 MiB | 145.4 MiB | +| `disk.churned` | 430.3 MiB | 296.5 MiB | +| `disk.compacted` | 201.6 MiB | 66.1 MiB | +| bytes written by `ingest.edges` | 152.5 MiB | 127.8 MiB | +| heap retained after ingest | 53.7 MiB | 39.1 MiB | + +Member reads no longer touch a page of their own, which removes the reads that missed the 64 MiB cache. The one +cost is in `large.probe`, which looks up a single member of the 50,000 member edge: its `Incidence` is now built +when it is read instead of being kept in the cached leaf, about 64 bytes per lookup. + ## Reproducing ``` diff --git a/docs/database/data-model.md b/docs/database/data-model.md index bfd0356..ff0cec3 100644 --- a/docs/database/data-model.md +++ b/docs/database/data-model.md @@ -22,7 +22,7 @@ record EdgeRecord(int type, EdgeKind kind, Ref members, Ref order, long version, |---|---| | `type` | id of the `TypeDef` in the schema slot `TYPES` | | `canonicalKey` | optional, at most `NodeRecord.MAX_KEY_LENGTH` = 512 characters; unique per `(tenant, type)` | -| `members` / `order` | roots of the membership tree and (for ordered edges) the order tree, see [topology](../storage/topology.md) | +| `members` / `order` | roots of the membership tree and (for ordered edges) the order tree, see [topology](../storage/topology.md). A small tree (at most 32 members that fit in about 100 bytes) is kept inside the record itself, so reading a typical edge needs no page beyond the catalog leaf | | `version` | incremented on every `withRoots` (membership change) | | `embeddingRef` | payload reference of the node's current embedding vector (set by `Writer.embed`) | | `tenant` | owning tenant; enforced by every `Reader` (see [security](security.md)) | @@ -306,6 +306,12 @@ yields `Incident(edge, roles, locator)`; `Reader.position(incident)` converts th position. Membership trees, their summaries (role bitmaps, weight min/max/sum, validity bounds) and the reverse index are described in [topology](../storage/topology.md). +How a membership is stored is invisible through the API. A small edge's members live inline in its catalog record, +and a larger one's in a tree of its own pages; an edge moves between the two as it grows and shrinks, and reads, +history, the change feed and conflict checks behave the same either way. A member with no roles, weight, validity, +payload or qualifier costs no `Incidence` object while it sits in the node cache. The record is built when it is +read. + `MemberSpec` (`MemberSpec.java`) carries the per-member options on writes: `MemberSpec.roles("host").withWeight(0.75).withValidity(new Validity(0, 1_000))`. diff --git a/docs/database/evidence-and-temporal.md b/docs/database/evidence-and-temporal.md index 4b02b27..a0fb643 100644 --- a/docs/database/evidence-and-temporal.md +++ b/docs/database/evidence-and-temporal.md @@ -149,8 +149,8 @@ MATCH NODE p:Patient WHERE p.name = 'Asha Rao' `Temporal.diff(before, after)` compares two readers (two generations or two branches): 1. `TreeDiff.diff` over the two catalog trees yields added, removed and changed atom records; structural sharing - between copy-on-write trees lets the diff skip shared subtrees in `O(1)` (`Ref.same`: same stored page id or - same in-memory node), and subtrees with disjoint key ranges are expanded on one side only + between copy-on-write trees lets the diff skip shared subtrees in `O(1)` (`Ref.same`: same stored page id, + same in-memory node, or equal entries for a small inline tree), and subtrees with disjoint key ranges are expanded on one side only ([persistent tree](../storage/persistent-tree.md)). 2. Added atoms become `AtomCreated` plus, for edges, a `MembersChanged` delta against an empty edge. 3. Removed atoms become `AtomDeleted`. diff --git a/docs/operations/cli.md b/docs/operations/cli.md index 7421b9b..df43fce 100644 --- a/docs/operations/cli.md +++ b/docs/operations/cli.md @@ -183,12 +183,13 @@ checks page checksums, key ordering, node balance, monoid summaries and nested t ``` $ hstore check /srv/hstore -generation 158, recovered 0 commits from the log +generation 157, recovered 0 commits from the log branch main - property-index 162 entries 9 pages 8 nested trees height 1 + catalog 458 entries 1 pages 85 nested trees height 1 … - catalog 458 entries 88 pages 85 nested trees height 2 -verified 110 pages: checksums, ordering, balance and summaries are consistent + property-index 162 entries 9 pages 8 nested trees height 1 +verified 20 pages: checksums, ordering, balance and summaries are consistent +reference counts match the trees: 20 pages in use, 423 waiting for older readers ``` A violated invariant aborts the check with the offending page and a reason such as diff --git a/docs/operations/docker.md b/docs/operations/docker.md index 0d0ee3d..f7f76b9 100644 --- a/docs/operations/docker.md +++ b/docs/operations/docker.md @@ -259,8 +259,8 @@ skipped because `FORMAT` exists. To verify a backup, run `docker run --rm -v restored:/var/lib/hstore/data hstore check /var/lib/hstore/data`. To upgrade, stop the container, back up the volume, and start the new image on the same volume. `FORMAT` -records the on-disk format version (`format=5`, reference counts) and the page size. Both are checked at +records the on-disk format version (`format=6`, inline member trees) and the page size. Both are checked at open. A directory written in another format is refused with -`database … uses storage format N; this build reads format 5 (reference counts); export and reload it`, +`database … uses storage format N; this build reads format 6 (inline member trees); export and reload it`, so the server never misreads it. Export the data with HQL against the old version and reload it into a fresh volume. diff --git a/docs/operations/logging.md b/docs/operations/logging.md index cb65fbb..7987f32 100644 --- a/docs/operations/logging.md +++ b/docs/operations/logging.md @@ -115,7 +115,7 @@ and in Docker it is the last line of `docker logs`. The storage-integrity failur | Message | Meaning | |---|---| -| `database uses storage format N; this build reads format 5 (reference counts); export and reload it` | `FORMAT` was written by an incompatible build. | +| `database uses storage format N; this build reads format 6 (inline member trees); export and reload it` | `FORMAT` was written by an incompatible build. | | `database uses N byte pages, options request M` | `page_size` differs from the value fixed at `init`. | | `write-ahead log segment is damaged () but later segments exist; replay would silently drop committed transactions` | `CORRUPT_LOG`: damage in a non-tail WAL segment. A torn record at the end of the *last* segment is normal after a crash. Replay simply ends there. Damage before later segments means storage corruption, so recovery refuses to guess. Restore from backup. | diff --git a/docs/storage/catalog-and-generations.md b/docs/storage/catalog-and-generations.md index 7fbc7d6..3812edb 100644 --- a/docs/storage/catalog-and-generations.md +++ b/docs/storage/catalog-and-generations.md @@ -141,7 +141,7 @@ MERGE BRANCH what_if INTO main ON CONFLICT FAIL; ```text Generation := id | wallTime | txnId | nextAtom | nextTxn | branchCount | Branch* Branch := id | name | parent | baseGeneration | createdAt | u8 state | rootCount | (slot | Ref)* -Ref := i64le pageId | Summary (pageId 0 ⇒ empty, no summary) +Ref := u8 0 (empty) | u8 1 | i64le pageId | varint units | Summary (slot roots are never inline) CatalogImage:= Generation(current) | historyCount | Generation* | segmentCount | (segmentId | u8 state | pages | retiredAt)* | checkpointLsn | feedEnd ``` @@ -185,7 +185,7 @@ varint slotCount | slotChange* | [before value] | [after value] (the slot schema's own ValueCodec) ``` -Values whose codec holds references are **detached** before encoding: every nested `Ref` is replaced with `Ref.EMPTY`. An `EdgeRecord` in the feed therefore carries its type, kind and version but not page pointers, which would be meaningless to a consumer and invalid after compaction. Topology is conveyed by the `members` list instead. +Values whose codec holds references are **detached** before encoding: every nested `Ref` is replaced with `Ref.EMPTY`, inline ones included. An `EdgeRecord` in the feed therefore carries its type, kind and version but not page pointers, which would be meaningless to a consumer and invalid after compaction, nor a copy of a small edge's members. Topology is conveyed by the `members` list instead. ### File format diff --git a/docs/storage/change-feed.md b/docs/storage/change-feed.md index 2fe6268..8418b94 100644 --- a/docs/storage/change-feed.md +++ b/docs/storage/change-feed.md @@ -124,7 +124,7 @@ for each flagged column, in that order: A column is flagged only when it differs from the default (`roleSet != NO_ROLES`, `weight != Weight.ONE`, `validFrom != Long.MIN_VALUE`, `validTo != Long.MAX_VALUE`, `dataRef != 0`, `qualifier != 0`), so a plain membership costs 1 byte of flags plus the member id. -Slot values that contain tree references (codecs with `holdsRefs()`, for example an `EdgeRecord` with member and order roots) are **detached** before encoding: every embedded `Ref` is replaced with `Ref.EMPTY`. Feed events therefore never pin pages, and compaction may move or reclaim any page without invalidating the feed. Consumers that need the topology of an edge read it from a snapshot, or use the event's `members` list. +Slot values that contain tree references (codecs with `holdsRefs()`, for example an `EdgeRecord` with member and order roots) are **detached** before encoding: every embedded `Ref` is replaced with `Ref.EMPTY`, including a small edge's inline member tree. Feed events therefore never pin pages, and compaction may move or reclaim any page without invalidating the feed. Consumers that need the topology of an edge read it from a snapshot, or use the event's `members` list. ### Example diff --git a/docs/storage/maintenance.md b/docs/storage/maintenance.md index 094248a..54d7642 100644 --- a/docs/storage/maintenance.md +++ b/docs/storage/maintenance.md @@ -90,6 +90,13 @@ places that point at it in the current state of the database. A place is a branc reference in a branch node, or a nested tree's root inside a leaf value, such as an edge's member tree in the catalog. A page that two branches share through one parent is counted once for that parent, not once per branch. +A small member or order tree kept inline in its edge record ([persistent-tree.md](persistent-tree.md#inline-trees)) +is not a place: it has no page, and its entries hold no references, so the counter never sees it. `TreeWalker` +reports only `Ref.Stored` references, and a catalog leaf whose edges are all inline doesn't set `REFERS_TO_PAGES`, +so the counter doesn't even decode it. When an edge grows past the inline limit, the commit writes its tree as a new +page and the new catalog leaf points at it, which adds one like any new page. When it shrinks back, the new catalog +leaf no longer points at that page, which takes one away like any other change. Neither case needs special code. + **After each commit.** When the group committer publishes a generation, it hands `ReferenceCounter` the generation before and the one after. The counter works on its own thread (`hstore-references`), in commit order, so the commit itself only pays for putting that pair on a queue. For each branch whose roots changed it compares the two root @@ -190,6 +197,10 @@ list of the pages moved in the current 4 MiB. `StorageEngine.compact()` checkpoi that died since the last checkpoint are released and counted as free, and again afterwards, which forces the page directory and persists the segment states. Then it calls `reclaim()`. +Compaction only ever moves whole pages, and an inline member tree is part of its catalog page's bytes, so it moves +with that page and needs no handling of its own. Since most edges are inline, a compaction of a typical database +has far fewer member pages to move than it used to. + Nothing here takes the commit lock and nothing commits. Commits carry on while pages move, readers keep using the node cache, whose entries are keyed by page number and stay valid, and retained history keeps working because old generations reach the moved pages through the same directory. @@ -237,7 +248,8 @@ History and pins are also what hold dead pages back. A page waits until every ge * every node summary equal to its entries; * the root reference's summary equal to the tree. -Nested trees are verified recursively and shared images only once. +Nested trees are verified recursively and shared images only once. Inline trees are checked like any other +nested tree, keys and summary included, but they are not pages, so they add to `nested trees` and not to `pages`. It then calls `StorageEngine.verifyReferences()`, which counts the references in the current state again from scratch and compares every entry of `pages.ref` with the result. It also walks the retained history and checks that @@ -252,9 +264,9 @@ branch main property-index 162 entries 9 pages 8 nested trees height 1 properties 122 entries 1 pages 0 nested trees height 1 ... - catalog 458 entries 86 pages 85 nested trees height 1 -verified 105 pages: checksums, ordering, balance and summaries are consistent -reference counts match the trees: 105 pages in use, 423 waiting for older readers + catalog 458 entries 1 pages 85 nested trees height 1 +verified 20 pages: checksums, ordering, balance and summaries are consistent +reference counts match the trees: 20 pages in use, 423 waiting for older readers ``` A failure names the page, for example `HStoreException.corrupt(pageId, "checksum mismatch")` or `"separator does not route child 3 in atoms#1"`, and the command exits non-zero. diff --git a/docs/storage/pages.md b/docs/storage/pages.md index 95f5a27..ff9b2bb 100644 --- a/docs/storage/pages.md +++ b/docs/storage/pages.md @@ -36,7 +36,7 @@ Sizes are computed exactly before writing (`varLongSize`, `signedVarLongSize`, ` | 4 | 1 | `format` | Page format version, `1` (`PageHeader.FORMAT`). Distinct from the data-directory format (see [Format versioning](#format-versioning)). | | 5 | 1 | `type` | `PageType`: `1` = `INTERNAL` (branch), `2` = `LEAF` | | 6 | 1 | `schema` | `TreeSchema.id` (1–255) of the tree the node belongs to | -| 7 | 1 | `flags` | Bit 0 (`REFERS_TO_PAGES`): a leaf with at least one value that points at another page, such as a catalog edge with a member tree. `NodeCodec.decode` checks it against the values. The other bits are `0`. | +| 7 | 1 | `flags` | Bit 0 (`REFERS_TO_PAGES`): a leaf with at least one value that points at another page, such as a catalog edge whose member tree is stored as a page. A leaf whose nested trees are all inline or empty leaves it clear. `NodeCodec.decode` checks it against the values. The other bits are `0`. | | 8 | 8 | `pageId` | The image's own page number (below). It is written last, after allocation (`PageHeader.assign`). | | 16 | 8 | `creationEpoch` | Id of the generation whose commit wrote the image. Spilled pages use `head + 1`. | | 24 | 4 | `payloadLength` | Payload bytes after the header. Readers use it to size the second read. | @@ -77,12 +77,26 @@ i64le roleBits keys: zigzag keys[0], then varint (keys[i] − keys[i−1]) for i = 1..slotCount−1 LEAF: schema.codec().encode(out, keys, values) (value format owned by the schema) INTERNAL: Ref × slotCount - Ref := i64le pageId | Summary (pageId 0 = empty, followed by nothing) + Ref := u8 0 empty tree + | u8 1 | i64le pageId | varint units | Summary stored tree + | u8 2 | varint n | keys | values inline tree (nested references only) Summary := varint count | zigzag min | varint (max − min) | zigzag weightSum | zigzag weightMin | zigzag weightMax | zigzag timeMin | zigzag timeMax | i64le roleBits | i64le fingerprint ``` -For an internal node, "keys" are the separators. `separators[0]` is the first child's minimum (set by `Branch.frozenWith`), so the delta stream is non-negative. Each child reference embeds the child's image size in 64-byte units and its **complete** summary. This lets readers prune and count without loading children, lets compaction measure live bytes without reading pages, and is why a branch entry costs up to `KEY_BYTES + 8 + 3 + 96` bytes in the fanout calculation (`Layout`). +For an internal node, "keys" are the separators. `separators[0]` is the first child's minimum (set by `Branch.frozenWith`), so the delta stream is non-negative. Each child reference embeds the child's image size in 64-byte units and its **complete** summary. This lets readers prune and count without loading children, lets compaction measure live bytes without reading pages, and is why a branch entry costs up to `KEY_BYTES + 1 + 8 + 3 + 96` bytes in the fanout calculation (`Layout`). An empty summary is a single `0` byte. + +**Reference encoding** (`tree/Ref.java`). Every reference starts with a tag byte. `0` is the empty tree and nothing follows. `1` is a stored tree: the page number, the image size in 64-byte units and the subtree's summary. `2` is an inline tree: the entry count `n` (1 to 32), then the leaf exactly as a leaf page payload encodes it, keys as a zigzag first key and varint gaps followed by `schema.codec().encode(...)`. There is no summary, because the reader recomputes it from the entries. An inline tree can only be decoded where the reader knows its schema, through `Ref.read(in, schema)`. `AtomCodec` passes the member schema of the edge's kind for `members` and `order-index` for `order`. Everywhere else (branch children, catalog images, WAL `Root` records, posting and property directories) `Ref.read(in)` rejects tag `2`, since only nested references in codecs that opt in with `inlinesSmallTrees()` are ever inline. An inline reference is never larger than `Ref.maxEncodedSize()` (108 bytes); see [persistent-tree.md](persistent-tree.md#inline-trees) for why that keeps pages from overflowing. + +An edge record with three plain members `{1000, 1001, 1005}` holds this as its `members` reference. It takes 7 bytes. A stored reference to the same tree would take 65 bytes, most of them summary, and point at an image of at least 128: + +```text +02 tag: inline +03 n = 3 +d0 0f zigzag(1000) +01 04 gaps 1 and 4 +00 IncidenceCodec flags: no columns, so no bitmaps or column values follow +``` **Size invariant.** Splits are decided from each value codec's `maxSize`, summed per entry, plus the codec's `leafOverhead(n)` and the exact key-stream size (`Leaf.bytes`, `BulkBuilder`). `IncidenceCodec.leafOverhead(n)` is `1 + 6·⌈n/8⌉`: the flags byte plus one bitmap per column, assuming every column is present. The accounting is therefore an upper bound for any leaf, and exact when all columns are present (`IncidenceCodecTest`). Other codecs have no per-leaf overhead. `Layout` still keeps a 32-byte reserve beyond `leafBudget` (`RESERVED = 32`), but correctness no longer depends on it. `NodeCodec.encode` encodes into a cursor of exactly `pageSize` bytes and would fail loudly (`node of N entries overflows a P byte page`) if the invariant were broken. @@ -90,7 +104,7 @@ Leaf value formats in use: | Schema | Codec | Format | |---|---|---| -| `atoms` #1 | `catalog/AtomCodec` | Tagged node/edge records with embedded `Ref`s to member and order trees ([topology.md](topology.md#encoding-catalogatomcodecjava)) | +| `atoms` #1 | `catalog/AtomCodec` | Tagged node/edge records with embedded `Ref`s to member and order trees, which are inline when small ([topology.md](topology.md#encoding-catalogatomcodecjava)) | | `set-members` #2, `ordered-members` #3 | `topology/IncidenceCodec` | Columnar: presence flags, per-column bitmaps, varint columns ([topology.md](topology.md#leaf-encoding-of-incidences-topologyincidencecodecjava)) | | `order-index` #4 | `TopologySchemas.LONG_CODEC` | zigzag varint per value | | posting directories (#5, #7, #9, #12, …) | `index/PostingsCodec` | `u8 0 \| varint n \| delta keys \| values` (inline) or `u8 1 \| Ref` (promoted) | @@ -185,7 +199,7 @@ header (`i32 magic 0x53464552`, `i32 format 1`, `i64` state), then one little-en | Entry | Meaning | |---|---| | `0` | No count: the number was never used, was released, or belongs to a page nothing has committed yet. | -| `n > 0` | The page is in use and `n` places point at it: branch roots, branch nodes, or leaf values holding a nested tree. | +| `n > 0` | The page is in use and `n` places point at it: branch roots, branch nodes, or leaf values holding a nested tree that is stored as a page. Inline trees have no page and are never counted. | | `0x8000_0000_0000_0000 \| g` (negative) | The page died in generation `g`. Generations before `g` can still reach it, so it keeps its address until it is released. | The header's state is `0` when the counts can be trusted and `1` when they have to be rebuilt on the next open. @@ -258,7 +272,7 @@ sequenceDiagram * `PageStore.write(pageId, image)` looks the number up in the directory and performs a positional `FileChannel.write` at `offset × 64`, then marks the file dirty. It refuses an image longer than the units allocated for it. The write happens later, inside `TransactionManager.append`, after the WAL records have been appended (see [wal-and-recovery.md](../transactions/wal-and-recovery.md)). * `PageStore.sync()` calls `FileChannel.force(false)` on every dirty segment. The group committer calls it once per batch in `PAGE_REFERENCES` WAL mode, before the WAL sync. `PageStore.checkpoint()` does the same and then forces the page directory; only `Checkpointer` calls it. -Compared with fixed page slots, packing removes almost all internal fragmentation for the many small nodes a hypergraph produces. A two-member hyperedge's member tree is one leaf of roughly 100–150 bytes, which now occupies 2–3 units instead of a full 16 KiB page. +Compared with fixed page slots, packing removes almost all internal fragmentation for the many small nodes a hypergraph produces. A member tree stored as a page is still an image of at least 128 bytes (two units), even for two members. That is why small member trees are not stored as pages at all, but inline in their edge record ([persistent-tree.md](persistent-tree.md#inline-trees)). ### Reading (`SegmentFile.read(position, maximum)`) @@ -314,7 +328,7 @@ read-only memory, so nothing can write into a stored image through a 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: * an open-addressing table of `Slot(pageId, node, volatile referenced)`, probed linearly from `mix(pageId)`, so a lookup needs no boxed key. It is kept at most half full, counting removed slots, and is rebuilt into a new array when it would pass that; -* 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 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. A leaf whose values are implied by its keys or member ids counts only its `long` arrays, and a catalog leaf adds the estimate of every inline tree it holds. 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` probes the table, reading each slot with acquire ordering, plus setting `referenced = true` if it was clear. Writers change the table only under the shard lock, publishing each slot with release ordering, and a rebuild publishes the new array through a volatile field. A reader still probing the old array can find a node that was just evicted; that is harmless, because a page number's contents never change. The check avoids a redundant volatile write on hot entries. @@ -353,7 +367,7 @@ Because images are never overwritten in place, a torn write can only damage an i | Version | Where | Meaning | |---|---|---| -| `format=5` in `/FORMAT` | `StorageEngine.verifyFormat` | Reference counts in `pages.ref`, page numbers resolved through `pages.dir`, images packed in 64-byte units, image sizes in every stored reference. Directories written by older builds (`format=1` fixed page slots, `format=2` references without sizes, `format=3` references by address, `format=4` no reference counts) fail to open: `uses storage format 4; this build reads format 5 (reference counts); export and reload it`. | +| `format=6` in `/FORMAT` | `StorageEngine.verifyFormat` | Tagged references, with small member and order trees inline in their edge record. Also reference counts in `pages.ref`, page numbers resolved through `pages.dir`, images packed in 64-byte units, image sizes in every stored reference. Directories written by older builds (`format=1` fixed page slots, `format=2` references without sizes, `format=3` references by address, `format=4` no reference counts, `format=5` untagged references with every nested tree on its own page) fail to open: `uses storage format 5; this build reads format 6 (inline member trees); export and reload it`. | | `page-size=N` in `/FORMAT` | same | Fixed at creation. A mismatching `--page_size` is rejected. | | header byte 4 = `1` | `PageHeader.FORMAT` | Node image layout; unchanged by packing and by page numbers. | | `pages.dir` header | `PageDirectory` | `i32 magic 0x52494450`, `i32 format 1`. | diff --git a/docs/storage/persistent-tree.md b/docs/storage/persistent-tree.md index a9b08ae..7191e0d 100644 --- a/docs/storage/persistent-tree.md +++ b/docs/storage/persistent-tree.md @@ -10,9 +10,9 @@ This document describes the node structure, the write path, the summaries and th |---|---|---| | `Tree(schema, source, root)` | `Tree.java` | An immutable handle. Every update returns a new `Tree`; the old one stays valid. | | `TreeSchema(id, name, fingerprint, codec, measure)` | `TreeSchema.java` | Identity (1 byte, stored in every page header), fingerprint mode, value codec and entry measure. `KEY_BYTES = 10` is the worst-case varint key size. | -| `Ref` = `Empty` / `Pending(Node)` / `Stored(pageId, Summary)` | `Ref.java` | A child pointer. `Pending` points at an in-memory node that has not been written yet. `Stored` points at a page and carries the child's full summary. | +| `Ref` = `Empty` / `Pending(Node)` / `Stored(pageId, units, Summary)` / `Inline(Leaf)` | `Ref.java` | A child pointer. `Pending` points at an in-memory node that has not been written yet. `Stored` points at a page and carries the child's full summary. `Inline` carries a whole small leaf inside the value that refers to it (see [Inline trees](#inline-trees)). | | `Node` = `Leaf` / `Branch` | `Node.java`, `Leaf.java`, `Branch.java` | The schema, an `owner` token (null means frozen and shared), and a lazily computed, cached `Summary`. | -| `NodeSource` | `NodeSource.java`, `PagedNodeSource.java` | Loads `Stored` refs into nodes, through `NodeCache`, then `PageStore`, then `NodeCodec.decode`. `NodeSource.ephemeral(pageSize)` is for purely in-memory trees. | +| `NodeSource` | `NodeSource.java`, `PagedNodeSource.java` | Loads `Stored` refs into nodes, through `NodeCache`, then `PageStore`, then `NodeCodec.decode`. `NodeSource.ephemeral(pageSize)` is for purely in-memory trees. `Pending` and `Inline` refs already hold their node and never go through it. | | `Layout(pageSize, leafBudget, maxFanout)` | `Layout.java` | Size limits derived from the page size. | | `WriteScope` | `WriteScope.java` | The ownership token that decides which nodes may be mutated in place, plus an optional spill sink. | | `TreeWriter` | `TreeWriter.java` | Path-copying insert, update, delete and join. | @@ -27,7 +27,8 @@ This document describes the node structure, the write path, the summaries and th ```text Leaf Branch long[] keys (sorted, unique) long[] separators (separators[0] unused for routing) - Object[] values Ref[] children + Object[] values (null when implied) Ref[] children + long[] words (only when implied) int size int size, height (leaf = 0) int keyBytes (exact varint size) long count (cached sum of child counts) int valueBytes(codec.maxSize sum; bytes() adds codec.leafOverhead(size)) @@ -41,7 +42,7 @@ Leaf Branch ```text leafBudget = pageSize − 80 (PageHeader.SIZE) − 96 (Summary.MAX_ENCODED_BYTES) − 32 (reserve) -maxFanout = leafBudget / (KEY_BYTES 10 + Ref.maxEncodedSize() (8 + 3 + 96)) +maxFanout = leafBudget / (KEY_BYTES 10 + Ref.maxEncodedSize() (1 + 8 + 3 + 96)) maxValueBytes = leafBudget / 2 − KEY_BYTES (largest inline value; larger values throw HStoreException.limit) leaf underfull ⇔ leaf.bytes() < leafBudget / 4 branch underfull ⇔ branch.size() < max(2, maxFanout / 4) @@ -49,9 +50,9 @@ branch underfull ⇔ branch.size() < max(2, maxFanout / 4) | page size | leafBudget | maxFanout | maxValueBytes | leaf underfull below | branch underfull below | |---:|---:|---:|---:|---:|---:| -| 4 096 | 3 888 | 34 | 1 934 | 972 B | 8 children | -| 16 384 (default) | 16 176 | 141 | 8 078 | 4 044 B | 35 children | -| 65 536 | 65 328 | 573 | 32 654 | 16 332 B | 143 children | +| 4 096 | 3 888 | 32 | 1 934 | 972 B | 8 children | +| 16 384 (default) | 16 176 | 137 | 8 078 | 4 044 B | 34 children | +| 65 536 | 65 328 | 553 | 32 654 | 16 332 B | 138 children | **Exact leaf sizing.** A leaf tracks its encoded size incrementally instead of counting entries: @@ -59,6 +60,8 @@ branch underfull ⇔ branch.size() < max(2, maxFanout / 4) * `Leaf.insert` and `Leaf.remove` adjust it with `insertionDelta`, which accounts for the two gaps a key creates or closes. * `valueBytes` is the sum of `codec.maxSize(value)`. +**Values implied by one number.** Some codecs can say that every value in a leaf follows from a single `long`. `IncidenceCodec` does this for a member leaf whose column flags are all clear: each value is just `Incidence.of(member)`. Such a leaf, when decoded or frozen, keeps `values = null` and a `long[] words` of member ids instead (for set edges that is the key array itself, so nothing extra is stored). `Leaf.value(i)` builds the `Incidence` when asked, so callers see the same values as before. A 200 member leaf goes from about 77 to about 9 bytes of heap per member for set edges, and to about 17 for ordered ones. Writable copies (`copyFor`, `concat`) still hold real objects, so nothing on the write path changes. Encoding such a leaf writes straight from the ids. The codec hooks are `decodeImplied`, `encodeImplied`, `impliedWords`, `implied` and `impliedSize` on `ValueCodec`. + A leaf splits as soon as `bytes() > leafBudget`. `Leaf.splitOff(budget)` walks entries until the left part holds half the bytes, then keeps advancing until the right part fits the budget. Leaves therefore fill by *bytes*, not by entry count. A leaf of 8-byte dense keys holds thousands of entries, and a leaf of large records holds only a few. ## Summaries: a monoid on every reference @@ -163,13 +166,34 @@ A `WriteScope` built with a `PageSink` (every `Transaction` has one) can spill c ### Materialization -`Materializer.materialize(ref, schema)` (`Materializer.java`) runs at commit, inside `TransactionManager.append`. It turns every `Pending` reference reachable from the new root vector into a `Stored` one, post-order: +`Materializer.materialize(ref, schema)` (`Materializer.java`) runs at commit, inside `TransactionManager.append`. It turns every `Pending` reference reachable from the new root vector into a `Stored` or `Inline` one, post-order: 1. For a branch, materialize every child first, then freeze the branch (`frozenWith`, which also sets `separators[0]` to the first child's min). -2. For a leaf whose codec `holdsRefs()`, map every nested reference through `materialize` first. An `EdgeRecord` value in the catalog holds the roots of that edge's member tree and order tree; a promoted posting list holds a postings tree root. Nested trees are therefore always written before the leaf that points at them. +2. For a leaf whose codec `holdsRefs()`, map every nested reference first. An `EdgeRecord` value in the catalog holds the roots of that edge's member tree and order tree; a promoted posting list holds a postings tree root. If the codec `inlinesSmallTrees()` and the nested tree is a pending leaf that `Ref.fitsInline`, it is frozen and becomes a `Ref.Inline`. Every other nested tree goes through `materialize`. Nested trees are therefore always settled before the leaf that points at them. 3. Allocate a page id, encode with `NodeCodec.encode(frozen, pageId, epoch = new generation, pageSize)`, and hand the image to the sink. The sink records a WAL `Page` or `PageRef` record, queues the page write, and admits the frozen node into the `NodeCache` under its new page id, so the next reader finds it without decoding. 4. Return `Ref.Stored(pageId, frozen.summary())`. Parents embed the child's full summary in their own page, which is what lets readers prune without loading children. +### Inline trees + +Most hyperedges are small. In the benchmark dataset the mean is 3.6 members, and a page of its own for each of them costs a page write, a node cache entry and a page read every time the edge is read. So a small nested tree is kept inside the value that refers to it instead, as a `Ref.Inline(leaf)`. + +A tree is kept inline when all of these hold (`Ref.fitsInline`): + +* it is a nested tree in a value whose codec opts in with `inlinesSmallTrees()`. Only `AtomCodec` does today, so only the member and order trees of edges are ever inline. Slot roots and branch children are always pages; +* it is a single leaf of 1 to 32 entries (`Ref.INLINE_MAX_ENTRIES`); +* its codec holds no nested references itself; +* `1 + varint(n) + leaf.bytes()` is no more than `Ref.maxEncodedSize()`, the 108 bytes a parent leaf already reserves for a pending reference. + +The last rule is what keeps pages from overflowing. While an edge is being changed, its record holds a `Pending` reference, which `AtomCodec.maxSize` counts at the full 108 bytes, and the catalog leaf is split against that. At commit the reference becomes either a stored one or an inline one, and both are at most that size, so the frozen leaf can only shrink. `leaf.bytes()` is the same upper bound that leaf splits already use, so the estimate never undercounts. The `Ref.Inline` constructor checks the rule again, so a decoded or hand-built inline reference that breaks it is rejected. + +The decision is made again at every commit that rewrites the tree. An edge that grows past the limit gets a page at its next commit, and one that shrinks back below it becomes inline again the next time its leaf is rewritten. A tree whose root is a branch that collapses to a single stored child keeps that page until the child is rewritten. That is harmless, since an inline tree and a stored tree with the same entries read the same way. + +An inline leaf is frozen (`owner == null`), so a writer copies it before changing it, exactly as for a leaf loaded from a page. Its summary is computed from its entries when first asked for, and it is the same summary the leaf would carry as a page, fingerprint included. `Materializer.pendingNodes`, which checks a commit against its page budget, does not count trees that will end up inline. + +**Equality.** `Ref.same` compares stored references by page id and pending ones by identity. Inline references have no identity worth comparing: two reads of the same catalog page decode two separate leaves. They are compared by content instead. Two inline references are the same when they belong to the same schema and have the same keys and equal values (`Leaf.sameEntries`). `Ref.Inline.equals` uses the same test, so `EdgeRecord.equals` compares inline member trees by content too. This is what the conflict checks in `Transaction` and `Op`, the shortcuts in `TreeAlgebra` and `TreeDiff`, and the read validation of serializable transactions rely on. A stored and an inline reference are never the same, even with equal entries. Every caller treats "not the same" as "look closer", so that only costs a slower path. + +**Heap.** The node cache weighs a catalog leaf with `Leaf.heapBytes()`, which adds the heap estimate of every inline leaf it holds, so inline trees count against `node_cache_mb` like any other node. + ## Reading ### Point lookups and ranks @@ -260,11 +284,11 @@ The cost is proportional to the changed region plus O(height) per boundary, not * each node's summary equal to its recomputed contents; * the root's summary equal to the root reference's summary. -Nested trees (edge memberships, promoted postings) are verified recursively. Pages shared between branches or trees are counted once. +Nested trees (edge memberships, promoted postings) are verified recursively, inline ones included. Pages shared between branches or trees are counted once, and an inline tree counts as a nested tree but not as a page. ## Complexity summary -n is the tree size, B the fanout (141 for 16 KiB pages), and k the number of results. +n is the tree size, B the fanout (137 for 16 KiB pages), and k the number of results. | Operation | Pages read (cold) | Notes | |---|---|---| diff --git a/docs/storage/topology.md b/docs/storage/topology.md index eff2e45..ffd5dce 100644 --- a/docs/storage/topology.md +++ b/docs/storage/topology.md @@ -20,7 +20,7 @@ sealed interface AtomRecord { | `dataRef` | Locator of the atom's property bag or payload, owned by the database layer. | | `embeddingRef` | Locator of the node's embedding. | | `kind` | `SET` or `ORDERED` (`topology/EdgeKind.java`). | -| `members`, `order` | Roots of the edge's nested membership tree and order index. These are `Ref`s embedded *inside* the catalog value. | +| `members`, `order` | Roots of the edge's nested membership tree and order index. These are `Ref`s embedded *inside* the catalog value. A tree of up to 32 entries that fits in a reference's space is carried whole, as a `Ref.Inline`; a larger one is a `Ref.Stored` page reference. | | `version` | Incremented by `EdgeRecord.withRoots` on every topology change. | | `tenant`, `isolation` | The tenant that owns the atom. The tenant id scopes every derived key (see below). | @@ -33,12 +33,14 @@ NodeRecord: u8 tag=0 | varint type | u8 hasKey | [varint len | utf8 key] | varin | varint flags | varint tenant | varint isolation EdgeRecord: u8 tag=1 | varint type | u8 kind (0=SET, 1=ORDERED) | Ref members | Ref order | varint version | varint dataRef | varint tenant | varint isolation -Ref: i64le pageId (0 = empty) | [Summary encoding, only when pageId != 0] +Ref: u8 0 empty + | u8 1 | i64le pageId | varint units | Summary stored as a page + | u8 2 | varint n | keys | values inline (n ≤ 32) ``` -Because `AtomCodec.holdsRefs()` is true, the materializer writes each edge's member and order trees *before* the catalog leaf that references them. `TreeWalker` and `TreeVerifier` descend into them, so the reference counts and `hstore check` cover them, and compaction moves their pages like any other. The catalog therefore forms a two-level tree of trees: catalog leaves hold edge records, and each edge record roots its own membership tree. +Because `AtomCodec.holdsRefs()` is true, the materializer settles each edge's member and order trees *before* the catalog leaf that references them. Because `AtomCodec.inlinesSmallTrees()` is also true, a tree that is a single leaf small enough to fit where a stored reference would go is kept in the record itself rather than written as a page (the exact rule is in [persistent-tree.md](persistent-tree.md#inline-trees)). With the benchmark's mean of 3.6 members, almost every edge is stored this way: reading its members needs only the catalog leaf. `TreeWalker` and `TreeVerifier` descend into the trees that are pages, so the reference counts and `hstore check` cover them, and compaction moves their pages like any other. Inline trees travel inside the catalog page, so none of those need to know about them. The catalog therefore forms a two-level tree of trees: catalog leaves hold edge records, and each edge record holds or roots its own membership tree. -A record's `contentHash()` feeds the catalog's SET fingerprint. The hash of an edge record is computed from `(1, type, kind, members.count, members.fingerprint, version, dataRef, tenant, isolation)`. It is a function of the edge's member *contents*, never of page ids, and compaction never changes page ids anyway, so it leaves catalog fingerprints alone. +A record's `contentHash()` feeds the catalog's SET fingerprint. The hash of an edge record is computed from `(1, type, kind, members.count, members.fingerprint, version, dataRef, tenant, isolation)`. It is a function of the edge's member *contents*, never of page ids or of whether the member tree is inline, and compaction never changes page ids anyway, so it leaves catalog fingerprints alone. ## Hyperedges @@ -103,6 +105,8 @@ for each present column c in order: A plain set edge with no roles or weights therefore encodes to one flag byte plus the delta-varint member keys, about 1–3 bytes per member. +When a decoded or freshly written leaf has no column flags at all, the codec tells the leaf that its values are implied (`decodeImplied`, `impliedWords`). The leaf then keeps no `Incidence` objects: a set leaf keeps only its keys, and a sequenced leaf keeps its keys plus a `long[]` of member ids. `Leaf.value(i)` returns `Incidence.of(member)` on demand. This saves about 64 bytes of heap per member in the node cache, and decoding such a leaf allocates about an eighth of what it used to. The bytes on disk are the same either way. + `IncidenceCodec.maxSize` charges each entry `Σ present column sizes` (plus the member varint when sequenced), and `IncidenceCodec.leafOverhead(n) = 1 + 6·⌈n/8⌉` charges the leaf for its flags byte and column bitmaps, so leaf sizes are an exact upper bound ([pages.md](pages.md#payload-treenodecodecjava)). The change feed reuses this codec to serialize a single incidence (`FeedCodec.writeIncidence`). ### Filtered member scans with summary pruning @@ -139,7 +143,7 @@ A member is any atom id, so a hyperedge can contain other hyperedges. Nothing in ### Cardinality and degree in O(1) -* **Cardinality** of an edge is `EdgeRecord.members.count()`, which reads `Ref.summary().count` from the catalog value without loading the member tree (`View.cardinality`). +* **Cardinality** of an edge is `EdgeRecord.members.count()`, which reads `Ref.summary().count` from the catalog value without loading the member tree (`View.cardinality`). For an inline tree it is the leaf's entry count. * **Degree** of an atom is the size of its posting set in the reverse index (`PostingIndex.count`): the array length for inline postings, the root count for promoted ones. In the database layer, `Reader.edge(id)` and `Reader.members(id)` resolve the catalog record once and build the `Hyperedge` from it with `View.edge(id, EdgeRecord)`, so a member listing costs one catalog lookup plus the member-tree scan. Role names come from `Dictionary.roles(roleSet)`, which caches the resolved name list per role-set id. Symbols are append-only, so the cache never needs invalidation. diff --git a/docs/transactions/transactions.md b/docs/transactions/transactions.md index c612db0..b0dcdeb 100644 --- a/docs/transactions/transactions.md +++ b/docs/transactions/transactions.md @@ -147,7 +147,7 @@ If any touched slot moved, the commit re-executes the op log against the latest | Latest state of the edge | Result | |---|---| | Deleted, and it existed at the base | Conflict: `hyperedge e was deleted concurrently` | -| Member root `Ref.same` as base | OK, nothing happened to the edge | +| Member root `Ref.same` as base | OK, nothing happened to the edge. For a small edge whose members are inline this compares the members themselves, so an edge that was changed and then changed back counts as untouched | | Changed, and footprint not commutative or edge is not a `SET` edge | Conflict: `hyperedge e root changed since the transaction began` | | Changed, `SET` edge, commutative footprint | Diff base vs latest member trees with `TreeDiff.diff`; conflict only if any changed member key is one this transaction touched | @@ -163,7 +163,7 @@ The transaction keeps the numbers of the pages it spilled. If it aborts, its com ### 5.4 Page budget -`append` counts the pending tree nodes, nested trees included, with `Materializer.pendingNodes` and rejects the commit with `HStoreException.limit` (`ABORTED_RESOURCE_LIMIT`, not retryable) if that count exceeds `txn.options().maxPages() - txn.spilledPages()`. The check runs before anything is materialized, so a rejected commit allocates no segment space, admits nothing to the node cache and does no WAL or data I/O. +`append` counts the pending tree nodes, nested trees included but not the ones that will be kept inline in their edge record, with `Materializer.pendingNodes` and rejects the commit with `HStoreException.limit` (`ABORTED_RESOURCE_LIMIT`, not retryable) if that count exceeds `txn.options().maxPages() - txn.spilledPages()`. The check runs before anything is materialized, so a rejected commit allocates no segment space, admits nothing to the node cache and does no WAL or data I/O. ### 5.5 Outcomes @@ -185,7 +185,7 @@ The transaction keeps the numbers of the pages it spilled. If it aborts, its com **SERIALIZABLE.** `View` calls `observeAtom` / `observeKey` on every read; under `SERIALIZABLE` the transaction records the value as of the **base** snapshot for each atom (`atomReads`) and each `(slot, key)` (`keyReads`) on first observation. On the slow path, `validateReads(latest)` re-reads each recorded key in the latest roots and raises a conflict if: * a key read: `now.equals(seen)` is false; -* an atom read: presence differs, or the record differs **unless** both are `EdgeRecord`s with the same member root (`Ref.same(was.members(), is.members())`). Membership changes made by replay are still validated through `verifyEdge`. +* an atom read: presence differs, or the record differs **unless** both are `EdgeRecord`s with the same member root (`Ref.same(was.members(), is.members())`). Records compare inline member trees by content, so they count as equal when the members are. Membership changes made by replay are still validated through `verifyEdge`. The fast path never needs read validation: it is only taken when every touched slot, including every read slot, is byte-for-byte the same root as at the base. Validation is therefore at key granularity when slots moved and free when they did not. diff --git a/docs/transactions/wal-and-recovery.md b/docs/transactions/wal-and-recovery.md index 00b8288..e0c8cdb 100644 --- a/docs/transactions/wal-and-recovery.md +++ b/docs/transactions/wal-and-recovery.md @@ -24,7 +24,7 @@ Related: [transactions](transactions.md), [change feed](../storage/change-feed.m ```text / - FORMAT properties: format=5, page-size= + FORMAT properties: format=6, page-size= LOCK exclusive FileLock held while open catalog/manifest name of the current catalog image file catalog/generations/%016x.cat checkpointed catalog images (3 newest retained) @@ -49,7 +49,7 @@ All fixed-width integers are **little-endian** (`ByteCursor` uses `JAVA_INT_UNAL | `svarlong` | zigzag (`(v << 1) ^ (v >> 63)`) then `varlong` | | `blob` | `varint` length, then that many bytes | | `string` | `blob` of UTF-8 | -| `Ref` | `i64 pageId`; `0` (`PageId.NONE`) means the empty tree and ends the encoding; otherwise followed by `varlong units` (the image size in 64-byte units, at most 3 bytes) and a `Summary` | +| `Ref` | `u8 tag`. Tag `0` is the empty tree and ends the encoding. Tag `1` is followed by `i64 pageId`, `varlong units` (the image size in 64-byte units, at most 3 bytes) and a `Summary`. Tag `2` is an inline tree, which only appears nested inside catalog records, never in the log's own `Root` records ([pages.md](../storage/pages.md#payload-treenodecodecjava)) | | `Summary` | `varlong count`; if `count == 0` nothing else; otherwise `svarlong min`, `varlong (max - min)`, `svarlong weightSum`, `svarlong weightMin`, `svarlong weightMax`, `svarlong timeMin`, `svarlong timeMax`, `i64 roleBits`, `i64 fingerprint` (at most `Summary.MAX_ENCODED_BYTES = 96`) | ## 3. Segments and frames